nutrition docker file fix

This commit is contained in:
2026-09-30 14:58:30 +05:30
parent c49f5372a5
commit d0804ae84f
8 changed files with 430 additions and 112 deletions

2
.env
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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`.

View File

@@ -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
View 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)
}
}