From d0804ae84f3728fcf6aeccd32845b1fc5d211343 Mon Sep 17 00:00:00 2001 From: abhishek Date: Wed, 30 Sep 2026 14:58:30 +0530 Subject: [PATCH] nutrition docker file fix --- .env | 2 +- .env.example | 22 +++-- Dockerfile | 17 ++++ config/mail.go | 49 ++++++++++- config/mail_test.go | 59 +++++++++++++ docs/MAIL_SETUP.md | 210 +++++++++++++++++++++++--------------------- utils/mail.go | 25 +++++- utils/mail_test.go | 158 +++++++++++++++++++++++++++++++++ 8 files changed, 430 insertions(+), 112 deletions(-) create mode 100644 utils/mail_test.go diff --git a/.env b/.env index d06bc7c..622d47e 100644 --- a/.env +++ b/.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 diff --git a/.env.example b/.env.example index c0d4f67..c038374 100644 --- a/.env.example +++ b/.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..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 diff --git a/Dockerfile b/Dockerfile index b1c78dc..d74b5b2 100644 --- a/Dockerfile +++ b/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 diff --git a/config/mail.go b/config/mail.go index b3a7a1d..6041540 100644 --- a/config/mail.go +++ b/config/mail.go @@ -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 +} diff --git a/config/mail_test.go b/config/mail_test.go index 60da73d..658145a 100644 --- a/config/mail_test.go +++ b/config/mail_test.go @@ -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) + } + } +} diff --git a/docs/MAIL_SETUP.md b/docs/MAIL_SETUP.md index db0fd40..b540c47 100644 --- a/docs/MAIL_SETUP.md +++ b/docs/MAIL_SETUP.md @@ -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= -MAIL_PASSWORD= +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 — ``` -If it instead says `mail: OFF — `, 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`. diff --git a/utils/mail.go b/utils/mail.go index 27a52d5..16e7abd 100644 --- a/utils/mail.go +++ b/utils/mail.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 } diff --git a/utils/mail_test.go b/utils/mail_test.go new file mode 100644 index 0000000..134bcd2 --- /dev/null +++ b/utils/mail_test.go @@ -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) + } +}