nutrition docker file fix
This commit is contained in:
2
.env
2
.env
@@ -76,7 +76,7 @@ USER_CONTEXT_KEY=
|
||||
# password link. Blank is the documented off state: the server boots, onboarding
|
||||
# works, and every create answers `invited: false` with the reason.
|
||||
#
|
||||
# Turn it on by putting the Postal host and its per-application credentials in
|
||||
# Turn it on by putting the Google Workspace host and App Password in
|
||||
# `.env.secrets`, which is read first and is the only one of these git ignores.
|
||||
MAIL_HOST=
|
||||
MAIL_PORT=587
|
||||
|
||||
22
.env.example
22
.env.example
@@ -146,18 +146,24 @@ NUTRITION_BASE=https://mcp.nearle.ai.in/api
|
||||
# five variables: its host, 587, the API key as MAIL_PASSWORD, and whatever
|
||||
# username it documents.
|
||||
#
|
||||
# WE RUN POSTAL — a self-hosted transactional mail server. Its SMTP endpoint is
|
||||
# the host below, and the credentials are a per-application pair generated in
|
||||
# Postal's UI, NOT a mailbox login. See docs/MAIL_SETUP.md for standing it up
|
||||
# and for the DNS records, which are what actually decide whether the invitation
|
||||
# reaches an inbox.
|
||||
# WE USE GOOGLE WORKSPACE SMTP, authenticating as care@nearledaily.com with a
|
||||
# 16-character App Password — never the account's login password, because an App
|
||||
# Password can be revoked on its own. See docs/MAIL_SETUP.md for the setup and
|
||||
# for the DNS records, which are what actually decide whether the invitation
|
||||
# reaches an inbox rather than a spam folder.
|
||||
#
|
||||
# Postal (ours): postal.nearledaily.com 587 credentials per-app
|
||||
# Gmail / Workspace: smtp.gmail.com 587 an app password, and only
|
||||
# ever for a smoke test
|
||||
# Self-hosting (Postal) was the earlier plan and is the better answer at volume.
|
||||
# At a few dozen invitations a month the work is not the software, it is IP
|
||||
# reputation, rDNS and blocklists — so this buys the reputation instead.
|
||||
#
|
||||
# Google Workspace: smtp.gmail.com 587 an App Password
|
||||
# via an SMTP relay: smtp-relay.gmail.com 587 if an admin sets one up
|
||||
# Amazon SES: email-smtp.<region>.amazonaws.com 587
|
||||
# SendGrid: smtp.sendgrid.net 587 username literally "apikey"
|
||||
# Resend: smtp.resend.com 587 username literally "resend"
|
||||
#
|
||||
# Credentials belong in .env.secrets (git-ignored, read first), or in the
|
||||
# deployment platform's own environment — NOT in this file and not in .env.
|
||||
MAIL_HOST=
|
||||
MAIL_PORT=587
|
||||
# Optional. Leave both empty for a relay that authenticates by network rather
|
||||
|
||||
17
Dockerfile
17
Dockerfile
@@ -44,6 +44,23 @@ COPY nearle-gear-firebase-adminsdk-l9oha-23ca3b3609.json .
|
||||
# thing the assistant needs to come up.
|
||||
ENV ASSISTANT_API_KEY=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
|
||||
|
||||
# Where the nutrition panel and health score come from.
|
||||
#
|
||||
# A PUBLIC URL, not a secret — it is the catalogue-intelligence service the
|
||||
# console already reads its health score card from, and the same value is in
|
||||
# .env.example. So it is a build-time default rather than a platform setting,
|
||||
# for the same reason ASSISTANT_API_KEY is: one variable, set in one place,
|
||||
# that cannot be missed on a deploy.
|
||||
#
|
||||
# It has been missed twice. Unset, `getproductbyvariant` simply omits
|
||||
# `nutrition` and `healthscore`, which is indistinguishable from a product the
|
||||
# service has not scored — so the feature ships switched off and looks broken
|
||||
# rather than absent. The startup log now names which state it is in.
|
||||
#
|
||||
# A value set on the platform still wins: `docker run -e` overrides a Dockerfile
|
||||
# ENV, so this is a default and not a lock-in.
|
||||
ENV NUTRITION_BASE=https://mcp.nearle.ai.in/api
|
||||
|
||||
# No `.env.*` is copied in (see .dockerignore), so this only decides which rules
|
||||
# config.Load applies: production insists on a signing secret and never falls
|
||||
# back to localhost values. Every other real value comes from the platform's
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Sending email.
|
||||
@@ -97,10 +98,56 @@ func MailFromEnv() MailConfig {
|
||||
Host: env("MAIL_HOST", ""),
|
||||
Port: port,
|
||||
Username: env("MAIL_USERNAME", ""),
|
||||
Password: env("MAIL_PASSWORD", ""),
|
||||
Password: smtpPassword(env("MAIL_HOST", ""), env("MAIL_PASSWORD", "")),
|
||||
// A name is optional; an address is not.
|
||||
FromAddress: env("MAIL_FROM", ""),
|
||||
FromName: env("MAIL_FROM_NAME", "Nearle"),
|
||||
ConsoleURL: env("MAIL_CONSOLE_URL", "https://app.nearledaily.com"),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
smtpPassword takes the spaces out of a Google App Password.
|
||||
|
||||
Google shows a 16-character App Password formatted for reading — "abcd efgh
|
||||
ijkl mnop" — and the spaces are presentation, not part of the secret. Pasted
|
||||
verbatim they survive into the credential and Gmail refuses the login, which
|
||||
Fiesta reports as "the mail server refused our credentials". That sends somebody
|
||||
to revoke a perfectly good password and generate another one with the same four
|
||||
spaces in it.
|
||||
|
||||
ONLY for Google's own SMTP hosts, and only when what is left is the 16
|
||||
alphanumeric characters an App Password actually is. A password is a secret and
|
||||
quietly editing one is normally the wrong thing: another relay's password may
|
||||
legitimately contain a space, and stripping it there would turn a working
|
||||
credential into a silent authentication failure — the exact bug this avoids,
|
||||
pointed the other way.
|
||||
*/
|
||||
func smtpPassword(host, password string) string {
|
||||
if !isGoogleSMTP(host) {
|
||||
return password
|
||||
}
|
||||
|
||||
stripped := strings.Join(strings.Fields(password), "")
|
||||
if stripped == password || len(stripped) != googleAppPasswordLength {
|
||||
return password
|
||||
}
|
||||
for _, r := range stripped {
|
||||
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
|
||||
return password
|
||||
}
|
||||
}
|
||||
return stripped
|
||||
}
|
||||
|
||||
// googleAppPasswordLength is what Google issues: sixteen characters, shown in
|
||||
// four groups of four.
|
||||
const googleAppPasswordLength = 16
|
||||
|
||||
func isGoogleSMTP(host string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(host)) {
|
||||
case "smtp.gmail.com", "smtp-relay.gmail.com", "aspmx.l.google.com":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -34,3 +34,62 @@ func TestCommittedEnvLeavesMailOffWithAReason(t *testing.T) {
|
||||
t.Fatalf("wrong SMTP address: %q", on.Address())
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Google App Passwords ────────────────────────────────────────────────── */
|
||||
|
||||
func TestAGoogleAppPasswordSurvivesBeingPastedWithItsSpaces(t *testing.T) {
|
||||
// Google shows it as "abcd efgh ijkl mnop". The spaces are presentation.
|
||||
// Pasted verbatim they reach Gmail, which refuses the login — reported as
|
||||
// "the mail server refused our credentials", sending somebody to revoke a
|
||||
// password that was fine.
|
||||
t.Setenv("MAIL_HOST", "smtp.gmail.com")
|
||||
t.Setenv("MAIL_PORT", "587")
|
||||
t.Setenv("MAIL_USERNAME", "care@nearledaily.com")
|
||||
t.Setenv("MAIL_PASSWORD", "abcd efgh ijkl mnop")
|
||||
t.Setenv("MAIL_FROM", "care@nearledaily.com")
|
||||
t.Setenv("MAIL_CONSOLE_URL", "https://app.nearledaily.com")
|
||||
|
||||
if got := MailFromEnv().Password; got != "abcdefghijklmnop" {
|
||||
t.Fatalf("password reached the relay as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAlreadyCleanAppPasswordIsUntouched(t *testing.T) {
|
||||
t.Setenv("MAIL_HOST", "smtp.gmail.com")
|
||||
t.Setenv("MAIL_PASSWORD", "abcdefghijklmnop")
|
||||
|
||||
if got := MailFromEnv().Password; got != "abcdefghijklmnop" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnotherRelaysPasswordIsNeverEdited(t *testing.T) {
|
||||
// A secret is a secret. Another relay's password may legitimately contain a
|
||||
// space, and stripping it there turns a working credential into a silent
|
||||
// authentication failure — this bug pointed the other way.
|
||||
for _, host := range []string{"smtp.sendgrid.net", "email-smtp.ap-south-1.amazonaws.com", "postal.nearledaily.com"} {
|
||||
t.Setenv("MAIL_HOST", host)
|
||||
t.Setenv("MAIL_PASSWORD", "two words here x")
|
||||
|
||||
if got := MailFromEnv().Password; got != "two words here x" {
|
||||
t.Errorf("%s: password was edited to %q", host, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSomethingThatIsNotAnAppPasswordIsLeftAlone(t *testing.T) {
|
||||
// Only the exact shape Google issues — sixteen alphanumerics — is treated
|
||||
// as display formatting. Anything else is somebody's real password.
|
||||
t.Setenv("MAIL_HOST", "smtp.gmail.com")
|
||||
|
||||
for _, password := range []string{
|
||||
"short one", // not 16 after stripping
|
||||
"a much longer pass phrase here", // not 16
|
||||
"abcd efgh ijkl mno!", // punctuation: not an App Password
|
||||
} {
|
||||
t.Setenv("MAIL_PASSWORD", password)
|
||||
if got := MailFromEnv().Password; got != password {
|
||||
t.Errorf("%q was rewritten to %q", password, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Mail setup — Postal, sending as care@nearledaily.com
|
||||
# Mail setup — Google Workspace SMTP, sending as care@nearledaily.com
|
||||
|
||||
What this is for: the first-password invitation. Every back-office account on
|
||||
Fiesta is created with an empty password, and the link in this email is the only
|
||||
@@ -7,93 +7,87 @@ meant that knowing a merchant's email address was enough to claim their account.
|
||||
|
||||
So this is not newsletter plumbing. **If the mail lands in spam, a business that
|
||||
was just onboarded cannot sign in**, and the first anyone hears of it is a phone
|
||||
call. The DNS section below matters more than the install.
|
||||
call. Step 3 is the one that decides that, and it is the one people skip.
|
||||
|
||||
---
|
||||
|
||||
## The decisions already made
|
||||
## The decisions
|
||||
|
||||
| | | why |
|
||||
|---|---|---|
|
||||
| Server | Postal, self-hosted | open source, purpose-built for transactional mail, speaks plain SMTP so nothing in Go changes |
|
||||
| Relay | Google Workspace SMTP | no server to run, no IP to warm, no port 25 exception to beg for. At a few dozen invitations a month that is the whole argument |
|
||||
| Sender | `care@nearledaily.com` | the link points at `app.nearledaily.com`; a password mail whose sender and destination are different domains is the shape of a phishing mail |
|
||||
| `care@` not `no-reply@` | | someone replying "I never got this" is the most useful reply this system can get, and it should reach a person |
|
||||
| Outbound | relay through a smarthost at first | see [Delivery](#delivery-the-hard-half) |
|
||||
| `care@` not `no-reply@` | | somebody replying "I never got this" is the most useful reply this system can receive, and it should reach a person |
|
||||
| Auth | an App Password, never the login password | it can be revoked on its own if it leaks |
|
||||
|
||||
This replaces an earlier plan to self-host Postal. Postal is the better answer at
|
||||
volume; it is the wrong answer for tens of emails a month, because the work is
|
||||
not the software — it is IP reputation, rDNS and blocklists.
|
||||
|
||||
---
|
||||
|
||||
## 1. Stand Postal up
|
||||
## Step 1 — Google Workspace on nearledaily.com
|
||||
|
||||
Postal needs a host of its own — it wants ports 25, 80 and 443, plus MariaDB and
|
||||
RabbitMQ. A 2 vCPU / 4 GB box is ample for our volume.
|
||||
1. Sign up at workspace.google.com with `nearledaily.com`. Business Starter is
|
||||
enough.
|
||||
2. Verify the domain with the TXT record Google gives you.
|
||||
3. Create `care@nearledaily.com`. It is a real mailbox and **somebody has to read
|
||||
it** — merchant replies and bounce notices both land there, and a bounce is how
|
||||
you learn an invitation never arrived.
|
||||
|
||||
```sh
|
||||
# on the mail host
|
||||
git clone https://github.com/postalserver/install /opt/postal/install
|
||||
ln -s /opt/postal/install/bin/postal /usr/bin/postal
|
||||
postal bootstrap postal.nearledaily.com
|
||||
postal initialize
|
||||
postal make-user # your admin login
|
||||
postal start
|
||||
```
|
||||
|
||||
Then in Postal's web UI:
|
||||
|
||||
1. **Create an organisation** — `Nearle`.
|
||||
2. **Add a mail server** under it — call it `transactional`. Keep marketing mail
|
||||
out of this one forever; shared reputation is the whole point.
|
||||
3. **Add the domain** `nearledaily.com`. Postal prints the DNS records it wants.
|
||||
Section 2 is those records.
|
||||
4. **Create a credential** of type *SMTP*, scoped to that server. Postal gives
|
||||
you a username and password pair. **This is not a mailbox login** — it exists
|
||||
only for Fiesta to authenticate with, and it can be revoked on its own.
|
||||
|
||||
---
|
||||
|
||||
## 2. DNS on nearledaily.com
|
||||
|
||||
This is the part that decides whether the invitation is read or binned. Postal's
|
||||
domain page shows the exact values; the shapes are:
|
||||
## Step 2 — DNS on nearledaily.com
|
||||
|
||||
| Record | Name | Value |
|
||||
|---|---|---|
|
||||
| TXT (SPF) | `nearledaily.com` | `v=spf1 a mx include:spf.postal.nearledaily.com ~all` |
|
||||
| TXT (DKIM) | `postal._domainkey.nearledaily.com` | the public key Postal generates |
|
||||
| CNAME (Return-Path) | `psrp.nearledaily.com` | `rp.postal.nearledaily.com` |
|
||||
| TXT (DMARC) | `_dmarc.nearledaily.com` | `v=DMARC1; p=none; rua=mailto:care@nearledaily.com` |
|
||||
| PTR (rDNS) | the mail host's IP | `postal.nearledaily.com` — set at your VPS provider, not in DNS |
|
||||
| MX | `nearledaily.com` | `smtp.google.com` (priority 1) |
|
||||
| TXT (SPF) | `nearledaily.com` | `v=spf1 include:_spf.google.com ~all` |
|
||||
| TXT (DKIM) | `google._domainkey` | the key from Step 3 |
|
||||
| TXT (DMARC) | `_dmarc` | `v=DMARC1; p=none; rua=mailto:care@nearledaily.com` |
|
||||
|
||||
Notes that save an afternoon:
|
||||
|
||||
- **One SPF record per domain.** If `nearledaily.com` already has one, merge the
|
||||
`include:` into it rather than adding a second — two SPF records is a permerror
|
||||
and fails every check.
|
||||
- **Start DMARC at `p=none`.** It reports without rejecting. Read the reports for
|
||||
a couple of weeks, confirm everything legitimate is aligned, then move to
|
||||
`p=quarantine`. Going straight to `p=reject` is how you discover a misaligned
|
||||
- **One SPF record only.** If the domain already has one, merge
|
||||
`include:_spf.google.com` into it. Two SPF records is a permerror and fails
|
||||
every check.
|
||||
- **Remove old MX records** if the domain receives mail somewhere else today, or
|
||||
that mail keeps going to the old place.
|
||||
- **Keep DMARC at `p=none`** for a couple of weeks, read the reports, then move to
|
||||
`p=quarantine`. Going straight to `p=reject` is how you find a misaligned
|
||||
sender by losing its mail.
|
||||
- **The Return-Path CNAME is not optional.** Without it, bounces go nowhere and
|
||||
Postal cannot tell you a merchant's address is dead — which, for this mail, is
|
||||
exactly the fact you most need.
|
||||
- Verify with `dig TXT nearledaily.com`, and send a test to a
|
||||
[mail-tester.com](https://mail-tester.com) address. Aim for 9/10 or better
|
||||
before the first real merchant.
|
||||
|
||||
---
|
||||
## Step 3 — DKIM (the step people skip)
|
||||
|
||||
## 3. Point Fiesta at it
|
||||
Admin console → Apps → Google Workspace → Gmail → **Authenticate email**.
|
||||
|
||||
Credentials go in `.env.secrets`, which is read first and is the only env file
|
||||
git ignores. Never in `.env` — that one is tracked and shared.
|
||||
1. **Generate new record** (2048-bit), add the TXT record it prints to DNS.
|
||||
2. Wait for DNS to propagate — minutes to hours.
|
||||
3. Come back and click **Start authentication**.
|
||||
|
||||
Until you click that last button the mail is unsigned, and unsigned mail carrying
|
||||
a password link goes to spam.
|
||||
|
||||
## Step 4 — An App Password for Fiesta
|
||||
|
||||
1. Sign in as `care@nearledaily.com` → Google Account → Security → turn on
|
||||
**2-Step Verification**.
|
||||
2. Security → **App passwords** → create one named `Fiesta`. You get 16
|
||||
characters.
|
||||
3. That is what Fiesta uses. Never the account's real password.
|
||||
|
||||
No App passwords option? An admin has to allow it, or set up Admin console →
|
||||
Gmail → Routing → **SMTP relay service** with "require SMTP authentication" and
|
||||
"require TLS". In that case `MAIL_HOST` becomes `smtp-relay.gmail.com`.
|
||||
|
||||
## Step 5 — Point Fiesta at it
|
||||
|
||||
Credentials go in **`.env.secrets`**, which is read first and is the only env
|
||||
file git ignores. Never in `.env` — that one is tracked and shared.
|
||||
|
||||
```sh
|
||||
# .env.secrets on the Fiesta host
|
||||
MAIL_HOST=postal.nearledaily.com
|
||||
MAIL_USERNAME=<from Postal's SMTP credential>
|
||||
MAIL_PASSWORD=<from Postal's SMTP credential>
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_USERNAME=care@nearledaily.com
|
||||
MAIL_PASSWORD=<16-character app password>
|
||||
```
|
||||
|
||||
Everything else is already set in `.env`:
|
||||
Already set in `.env`:
|
||||
|
||||
```sh
|
||||
MAIL_PORT=587
|
||||
@@ -102,62 +96,76 @@ MAIL_FROM_NAME=Nearle
|
||||
MAIL_CONSOLE_URL=https://app.nearledaily.com
|
||||
```
|
||||
|
||||
Google shows the App Password as four groups — `abcd efgh ijkl mnop`. Paste it
|
||||
with or without the spaces; Fiesta strips them for Google SMTP hosts only, and
|
||||
only when what remains is the sixteen alphanumerics an App Password actually is.
|
||||
Another relay's password is never edited.
|
||||
|
||||
`MAIL_CONSOLE_URL` is the **merchant** console and never the platform one — a
|
||||
merchant sets their password at `app.nearledaily.com/set-password` and nowhere
|
||||
else.
|
||||
|
||||
Restart and read the first log line:
|
||||
Restart. The log says which state it is in:
|
||||
|
||||
```
|
||||
mail: sending as care@nearledaily.com via postal.nearledaily.com:587
|
||||
mail: sending as care@nearledaily.com via smtp.gmail.com:587
|
||||
mail: OFF — <reason naming the missing variable>
|
||||
```
|
||||
|
||||
If it instead says `mail: OFF — <reason>`, the reason names the missing variable.
|
||||
Nothing else breaks: the server boots, onboarding works, and every create answers
|
||||
`invited: false` with that same reason on screen.
|
||||
**On a hosted deployment these belong in the platform's own environment**
|
||||
(Dokploy), not in a file in the repo. A `.env` committed to the repository is
|
||||
overwritten at build time — that is how the nutrition service shipped switched
|
||||
off.
|
||||
|
||||
---
|
||||
### What Fiesta does with them
|
||||
|
||||
## 4. Prove it end to end
|
||||
`utils/mail.go` upgrades to TLS with STARTTLS before authenticating, and
|
||||
**refuses to send at all if a relay offers no encryption while credentials are
|
||||
configured**. Go's own `smtp.PlainAuth` would decline to hand over the password
|
||||
anyway, so nothing leaks either way — but it reports that as the server refusing
|
||||
the credentials, which sends somebody to check the password when the problem is
|
||||
the connection. It also closes a downgrade, where an attacker strips STARTTLS
|
||||
from the greeting.
|
||||
|
||||
Not "the config looks right" — actually watch one arrive.
|
||||
## Step 6 — Check the DNS
|
||||
|
||||
1. Onboard a test merchant in the platform console with an address you can read.
|
||||
2. The success screen should say the invitation is on its way. If it says
|
||||
**No invitation was sent**, the reason on screen is the server's own.
|
||||
3. Open the mail. Check it is **not** in spam — that is the whole test.
|
||||
```sh
|
||||
dig TXT nearledaily.com +short # SPF, with _spf.google.com
|
||||
dig TXT google._domainkey.nearledaily.com +short # DKIM key
|
||||
dig TXT _dmarc.nearledaily.com +short # DMARC
|
||||
dig MX nearledaily.com +short # smtp.google.com
|
||||
```
|
||||
|
||||
Google's Check MX tool at toolbox.googleapps.com does the same job.
|
||||
|
||||
## Step 7 — Prove it end to end
|
||||
|
||||
Not "the config looks right" — watch one arrive.
|
||||
|
||||
1. Send a test to a [mail-tester.com](https://mail-tester.com) address. Aim for
|
||||
9/10 or better **before** a real merchant sees one.
|
||||
2. Onboard a test merchant with an address you can read.
|
||||
3. Confirm it is in the **inbox, not spam**. In Gmail, "Show original" should
|
||||
show SPF, DKIM and DMARC all PASS.
|
||||
4. Follow the link, set a password, sign in at `app.nearledaily.com`.
|
||||
5. Press **Resend invite** on that tenant. It must refuse, naming the business:
|
||||
5. Press **Resend invite**. It must refuse, naming the business:
|
||||
*"… has already set a password — send them to the sign-in page instead."*
|
||||
That refusal is what stops this becoming a password reset.
|
||||
|
||||
---
|
||||
|
||||
## Delivery, the hard half
|
||||
## Worth knowing
|
||||
|
||||
Postal is the easy part. Getting mail *accepted* from your own IP is not:
|
||||
- **Limit:** about 2,000 messages a day per user. Onboarding runs at a few dozen
|
||||
a month, so this is not a constraint.
|
||||
- **Bounces** arrive as "Delivery failed" in the `care@` inbox. Nothing in Fiesta
|
||||
watches for them, so somebody has to read that mailbox after onboarding.
|
||||
- **Not for bulk.** Google does not permit marketing sends through Workspace. If
|
||||
newsletters are ever wanted, that is a separate provider — not this mailbox.
|
||||
- **If the App Password leaks:** revoke it in Google Account → Security, issue a
|
||||
new one, update `.env.secrets`. Nothing else has to change.
|
||||
|
||||
- Most clouds block outbound port 25 by default. AWS, GCP, Azure, DigitalOcean,
|
||||
Oracle and Hetzner all require an exception request; some decline.
|
||||
- A fresh IP has no sending reputation. Gmail and Outlook throttle or spam-folder
|
||||
it until it is warmed over weeks. A recycled VPS IP is frequently already on
|
||||
Spamhaus — check before you commit to one.
|
||||
- rDNS must match the HELO hostname. Not every provider lets you set it.
|
||||
|
||||
**So configure Postal to relay outbound through a smarthost to begin with.** You
|
||||
keep the open-source stack, your own queue, your own logs and the freedom to
|
||||
move — and you borrow established IP reputation for the last hop only. Postal
|
||||
supports this per mail server under *Settings → SMTP relays*. Once you have
|
||||
volume and a warm dedicated IP, cut over to sending directly; nothing on the
|
||||
Fiesta side changes, because it only ever talks to Postal.
|
||||
|
||||
At our volume — a few dozen invitations a month — carrying full deliverability
|
||||
operations to save roughly ₹1,000 a year is a bad trade against one merchant
|
||||
locked out of their own business.
|
||||
|
||||
---
|
||||
|
||||
## What the merchant actually receives
|
||||
## What the merchant receives
|
||||
|
||||
Plain text, deliberately. A password link arriving as an image-heavy HTML
|
||||
template is the shape of a phishing mail, and plain text renders identically
|
||||
@@ -165,4 +173,4 @@ everywhere. The body names the business, puts the link on its own line, and says
|
||||
it expires in seven days — because an invitation found three weeks later needs to
|
||||
explain itself rather than look broken.
|
||||
|
||||
The wording lives in `inviteMessage` in `services/inviteService.go`.
|
||||
The wording is `inviteMessage` in `services/inviteService.go`.
|
||||
|
||||
@@ -121,11 +121,34 @@ func (m *smtpMailer) dial() (*smtp.Client, error) {
|
||||
return nil, fmt.Errorf("could not reach the mail server at %s: %w", m.cfg.Address(), err)
|
||||
}
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
ok, _ := client.Extension("STARTTLS")
|
||||
if ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: m.cfg.Host}); err != nil {
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("the mail server offered TLS and then refused it: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// No TLS on offer, and we are about to send a password.
|
||||
//
|
||||
// Refused rather than continued. `smtp.PlainAuth` would decline to hand over
|
||||
// credentials on a plaintext connection anyway — so nothing leaks either way
|
||||
// — but it reports that as the server refusing our credentials, which sends
|
||||
// somebody to check the password when the problem is the connection.
|
||||
//
|
||||
// It also closes a downgrade: an attacker between us and the relay can strip
|
||||
// the STARTTLS advertisement from the greeting, and "carry on unencrypted"
|
||||
// is the wrong answer to that.
|
||||
//
|
||||
// A relay that authenticates by network rather than by credentials has no
|
||||
// username set, and is left alone: those are usually a local MTA on the same
|
||||
// host, where there is no wire to protect.
|
||||
if m.cfg.Username != "" {
|
||||
client.Close()
|
||||
return nil, fmt.Errorf(
|
||||
"%s does not offer TLS, and MAIL_PASSWORD would have to cross the wire in clear",
|
||||
m.cfg.Address())
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
158
utils/mail_test.go
Normal file
158
utils/mail_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nearle/config"
|
||||
)
|
||||
|
||||
/*
|
||||
Sending the invitation.
|
||||
|
||||
These run against a fake SMTP server on a local port rather than a mock, because
|
||||
the thing worth testing is the conversation: Go's `net/smtp` decides on its own
|
||||
whether to hand over a password, and the question is what this code does when a
|
||||
relay does not offer encryption.
|
||||
*/
|
||||
|
||||
// smtpStub answers just enough of the protocol to get to the interesting part.
|
||||
// `offerTLS` is the switch the tests turn.
|
||||
func smtpStub(t *testing.T, offerTLS bool) string {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
reader := bufio.NewReader(conn)
|
||||
write := func(line string) { _, _ = conn.Write([]byte(line + "\r\n")) }
|
||||
|
||||
write("220 stub ESMTP")
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch verb := strings.ToUpper(strings.TrimSpace(line)); {
|
||||
case strings.HasPrefix(verb, "EHLO"):
|
||||
write("250-stub")
|
||||
if offerTLS {
|
||||
write("250-STARTTLS")
|
||||
}
|
||||
write("250 AUTH PLAIN LOGIN")
|
||||
case strings.HasPrefix(verb, "QUIT"):
|
||||
write("221 bye")
|
||||
return
|
||||
default:
|
||||
// Anything else is past the point these tests reach.
|
||||
write("250 ok")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
return listener.Addr().String()
|
||||
}
|
||||
|
||||
func mailerFor(t *testing.T, address string, username string) Mailer {
|
||||
t.Helper()
|
||||
|
||||
host, portText, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
t.Fatalf("splitting %q: %v", address, err)
|
||||
}
|
||||
port := 0
|
||||
for _, digit := range portText {
|
||||
port = port*10 + int(digit-'0')
|
||||
}
|
||||
|
||||
mailer, err := NewMailer(config.MailConfig{
|
||||
Host: host, Port: port,
|
||||
Username: username,
|
||||
Password: "a-password-that-must-not-cross-the-wire",
|
||||
FromAddress: "care@nearledaily.com", FromName: "Nearle",
|
||||
ConsoleURL: "https://app.nearledaily.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("building the mailer: %v", err)
|
||||
}
|
||||
if mailer == nil {
|
||||
t.Fatal("no mailer from a configured sender")
|
||||
}
|
||||
return mailer
|
||||
}
|
||||
|
||||
func TestAPasswordIsNeverSentToARelayWithNoTLS(t *testing.T) {
|
||||
// The downgrade this guards. An attacker between us and the relay can strip
|
||||
// STARTTLS from the greeting; "carry on unencrypted" is the wrong answer,
|
||||
// and we are about to send a real Google app password.
|
||||
mailer := mailerFor(t, smtpStub(t, false), "care@nearledaily.com")
|
||||
|
||||
err := mailer.Send("owner@rmart.example", "Set your Nearle password", "link")
|
||||
if err == nil {
|
||||
t.Fatal("sent credentials to a relay offering no encryption")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "TLS") {
|
||||
// The reason has to name the connection. Reported as a credential
|
||||
// refusal it sends somebody to check the password, which is fine.
|
||||
t.Errorf("the failure does not name the real problem: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "a-password-that-must-not-cross-the-wire") {
|
||||
t.Error("the password is in the error message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARelayWithNoCredentialsIsLeftAlone(t *testing.T) {
|
||||
// A relay that authenticates by network rather than by password is usually
|
||||
// a local MTA on the same host, where there is no wire to protect. It must
|
||||
// not be refused for want of TLS it does not need.
|
||||
mailer := mailerFor(t, smtpStub(t, false), "")
|
||||
|
||||
// The stub accepts the envelope and the body, so this gets past the point
|
||||
// the guard above would have stopped at. Whether the stub completes the
|
||||
// whole conversation is not the question — being refused for TLS is.
|
||||
err := mailer.Send("owner@rmart.example", "Set your Nearle password", "link")
|
||||
if err != nil && strings.Contains(err.Error(), "does not offer TLS") {
|
||||
t.Fatalf("refused an unauthenticated relay over TLS: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnconfiguredMailerIsNilRatherThanBroken(t *testing.T) {
|
||||
// A deployment with no mail still boots and still onboards; the outcome
|
||||
// says `invited: false` with the reason. See config.MailConfig.Why.
|
||||
mailer, err := NewMailer(config.MailConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("an unconfigured mailer reported an error: %v", err)
|
||||
}
|
||||
if mailer != nil {
|
||||
t.Fatal("built a mailer with no host")
|
||||
}
|
||||
}
|
||||
|
||||
func TestABadRecipientIsNamedRatherThanDialled(t *testing.T) {
|
||||
// A merchant's primary email is typed by whoever onboarded them, so a typo
|
||||
// is ordinary. It should be refused by name, before any connection.
|
||||
mailer := mailerFor(t, smtpStub(t, true), "care@nearledaily.com")
|
||||
|
||||
err := mailer.Send("not an email", "Set your Nearle password", "link")
|
||||
if err == nil {
|
||||
t.Fatal("accepted an address that is not one")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a valid email address") {
|
||||
t.Errorf("unhelpful message: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user