Email-out — going live¶
Maud sends one transactional email today: the account email-change confirmation (apps/households/views.py:account_email_change). When you change your account email in Settings, we mail a signed, 3-day, single-use link to the new address; clicking it while signed in to the same account applies the change. The link is what stops someone who briefly has your session from silently swapping your email and locking you out.
The sending code is wired up and deployed, but it reads its transport from env (config/settings.py → EMAIL_*). The default is Django's console backend, which prints the email to the server log instead of delivering it. So in prod, until the secrets below are set:
- The email-change form looks like it works but the link never arrives.
- The view fails safe:
send_mailraising (or a console backend that goes nowhere) is caught, the address is not changed, and the user sees "We couldn't send the confirmation email just now. Try again later." No 500, no half-applied change.
Setting the SMTP secrets flips delivery live — no redeploy needed (Fly restarts the machine on secrets set).
Plan ~30 minutes, most of it waiting for DNS to propagate for domain authentication.
Which domain we send from¶
We send from heymaud.com — the domain we actually own. A few things worth fixing in your head before you start:
lifefile.appis not ours — it's a different, unrelated site. You'll seelifefile.appleft around as a placeholder in some older docs and inbound-email notes; ignore it for sending. TheDEFAULT_FROM_EMAILdefault insettings.pyis now[email protected].- You can't send from
maud.fly.dev. Email authentication (DKIM/SPF) lives in the sending domain's DNS, and you don't controlfly.dev's DNS — so no provider can verify it. Sending always needs a domain whose DNS you own. - This is portable. If the brand moves off
heymaud.comlater (the name may be too crowded), switching is cheap: verify the new domain in Postmark and override one Fly secret (DEFAULT_FROM_EMAIL). No code change. So usingheymaud.comnow doesn't lock you in. - The from-domain and the app URL don't have to match. The confirmation link is built from whatever host serves the request (
maud.fly.devtoday). Sending fromheymaud.comwith a link tomaud.fly.devis fine and normal for transactional mail.
1. Reuse the existing Postmark server¶
You already have a Postmark server (maud-prod) for email-in. Every Postmark server has a default outbound transactional message stream alongside its inbound stream — you don't need a second account or a second server.
- Open https://postmarkapp.com/ → your
maud-prodserver. - Confirm the Default Transactional Stream (
outbound) exists. It does by default. This is the right stream for confirmation links — do not use a broadcast stream for transactional mail. - Find the Server API Token: server → API Tokens tab. You'll use this as both the SMTP username and password in step 3 (this is a Postmark quirk, not a typo — see below).
2. Authenticate heymaud.com (SPF + DKIM)¶
This is the step that takes real time and the one that keeps confirmation links out of spam. We send as [email protected], so authenticate the whole heymaud.com domain rather than a single address — that way DMARC aligns and any future from-address on the domain just works.
- In Postmark: Sender Signatures → Add Domain →
heymaud.com. - Postmark shows two DNS records:
- a DKIM
TXTrecord (selector like<id>._domainkey.heymaud.com), and - a Return-Path
CNAME(e.g.pm-bounces.heymaud.com→pm.mtasv.net), which gives SPF alignment on the bounce domain. - Add both records at your DNS provider for
heymaud.com. - Back in Postmark, click Verify. DKIM usually verifies within an hour; occasionally up to 24.
You don't strictly need a domain-level SPF TXT on heymaud.com itself because Postmark uses its own Return-Path domain for the bounce/SPF path — but if you already publish an SPF record on heymaud.com, leave it valid; don't let it hard-fail (-all) in a way that contradicts the alignment Postmark sets up.
Until the domain shows verified in Postmark, sends from [email protected] are rejected (Postmark API 422, "sender signature not confirmed"). That surfaces to users as the same friendly "couldn't send" message — fail-safe, but the email-change feature is effectively still off. Verify the domain before you tell anyone the feature is live.
3. Set the secrets in Fly¶
fly auth whoami # confirm [email protected] (personal account)
fly secrets set -a maud \
EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend" \
EMAIL_HOST="smtp.postmarkapp.com" \
EMAIL_HOST_USER="<your Postmark Server API Token>" \
EMAIL_HOST_PASSWORD="<the same Server API Token>" \
DEFAULT_FROM_EMAIL="Maud <[email protected]>"
Three things worth calling out:
- Username and password are both the Server API Token. Postmark's SMTP interface authenticates with the server token in both fields. It's not your Postmark login, and it's not two different values. Use the token from step 1.3 verbatim for each.
EMAIL_PORT(587) andEMAIL_USE_TLS(True) are already the correct defaults insettings.py, and they match Postmark's STARTTLS endpoint — so you don't need to set them.- Set
DEFAULT_FROM_EMAILexplicitly (it's in the command above) even thoughsettings.pynow defaults to the same value. The code default only takes effect once that change is deployed — and we got bitten exactly here: prod was still running the oldlifefile.appcode default with no secret set, so Postmark rejected the send with a412("the domain of the 'From' address is 'lifefile.app'"). Setting the secret makes the From correct no matter which code is live, and it must be a domain you verified in step 2. Confirm what the running machine actually resolved withfly ssh console -a maud -C "printenv DEFAULT_FROM_EMAIL"— an empty result means it's falling back to the code default.
The Server API Token is a secret with full send rights on the server — treat it like the inbound credentials. If it leaks, regenerate it in Postmark (server → API Tokens → regenerate) and immediately re-run the fly secrets set above with the new value.
4. Get the account out of Test mode (approval)¶
This is the trap that cost the most time the first time through, because it fails in a way that looks like success. New Postmark accounts start in Test mode — look for a "Test mode" label and a Request approval button in the top bar.
While the account is pending approval:
- Postmark accepts the SMTP handshake —
send_mailreturns1, and the app shows the cheerful "Check … for a confirmation link" — but then refuses to deliver to any recipient whose domain doesn't match theFromdomain. - The dropped message shows in the stream's Activity as a red SMTP API Error,
ErrorCode 412: "While your account is pending approval, all recipient addresses must share the same domain as the 'From' address." - So a confirmation to a real user's
@gmail.com(From@heymaud.com) is accepted and silently binned. No bounce reaches the app — the only evidence is in Postmark Activity. An empty Postmark log plus a "success" message in the app is the signature of this state.
Fix: click Request approval, fill the short form (transactional email — account email-change confirmations; recipients enter their own address at signup; very low volume) and submit. Approval is usually granted within a few hours; having DKIM + Return-Path already verified speeds it up. Once approved the "Test mode" label disappears and delivery to any recipient works with no further changes.
To prove the pipeline before approval lands: Test mode does allow delivery to recipients on the same domain as your From address — e.g. From [email protected] to any @heymaud.com address you can receive at. External domains (gmail.com, etc.) only work post-approval.
5. Smoke test on prod¶
After Fly restarts (a few seconds after fly secrets set):
- Sign in at https://maud.fly.dev/ with a test account.
- Go to Settings → change the account email to an address whose inbox you can read.
- You should see "Check <address> for a confirmation link to finish updating your email." — and the email should arrive within seconds, from
[email protected]. - Click the link while still signed in to the same account. The email updates and you're bounced back to Settings with a success message.
- The change is single-use and expires after 3 days; clicking a used or expired link, or clicking while signed in as a different user, shows a clear error and changes nothing.
If the email never arrives, the web form is a poor instrument — its try/except swallows the real error and just says "couldn't send". Send directly from the prod machine instead, where the result is unambiguous:
from django.core.mail import send_mail
send_mail("test", "body", None, ["[email protected]"]) # None = DEFAULT_FROM_EMAIL
It prints 1 if Postmark accepted the message, or raises the real traceback if not. Then read the per-message status in Postmark → your server → Default Transactional Stream → Activity (Activity is inside each stream in the current UI — there's no top-level Activity link). The common failures:
1returned, Activity shows redSMTP API Error/ErrorCode 412"pending approval" → account still in Test mode; see step 4 (Request approval). This is the "looks successful, delivers nothing" case.412naming the From domain aslifefile.app(notheymaud.com) → the From is wrong; set theDEFAULT_FROM_EMAILsecret (step 3) and confirm withprintenv DEFAULT_FROM_EMAILon the machine.SMTPAuthenticationError/535→EMAIL_HOST_USER/PASSWORDisn't the Server API Token (or has a stray space).422"sender signature not confirmed" → the domain in step 2 isn't verified yet.ConnectionRefused/ timeout → the send never left the box (egress); the Postmark log will be empty.1+ green "Delivered" but nothing in the inbox → genuinely delivered; check all folders / the exact recipient address.
A misleading combination to recognise: a "success" message in the app and an empty Postmark transactional log usually means the only attempt ran on the old console backend (prints to fly logs instead of sending), not that Postmark lost it.
Changing the brand domain later¶
If Maud moves off heymaud.com:
- Add the new domain in Postmark (Sender Signatures → Add Domain) and add its DKIM/Return-Path records; wait for verified.
fly secrets set -a maud DEFAULT_FROM_EMAIL="Maud <no-reply@newdomain>".- Optionally update the
DEFAULT_FROM_EMAILdefault insettings.pyin a follow-up so the codebase reflects reality.
No redeploy logic, no template changes. The old domain keeps working until you remove it from Postmark.
Rollback¶
To stop sending without removing code, point the backend back at the console:
Email-change confirmations stop delivering and the form reverts to its fail-safe "couldn't send" path. Already-completed email changes are unaffected — they're just rows in auth_user. (You can leave EMAIL_HOST* set; the console backend ignores them.)
What it costs¶
Postmark bills transactional and inbound from the same monthly volume. The free tier is 100 emails/month (inbound + outbound combined); the Starter plan is $15/month for 10k. Email-change confirmations are rare (one per deliberate address change), so outbound volume here is negligible next to inbound forwarding.
Where it lives in code¶
maud/apps/households/views.py:account_email_change— builds the signed token, callssend_mail, catches send failures and fails safe.maud/apps/households/views.py:account_email_confirm— validates the token (EMAIL_CHANGE_SALT, 3-day max-age, same-user check) and applies the change.maud/config/settings.py— theEMAIL_*env block (defaults to the console backend;DEFAULT_FROM_EMAILdefaults to[email protected]) and the values read from Fly secrets.- Inbound is the sibling of this page: Email-in (Postmark) setup. Same account, same server, opposite direction. (Its docs still use the
lifefile.appplaceholder for the inbound subdomain — when inbound goes live it should use a subdomain of a domain you own, e.g.inbound.heymaud.com.)