Skip to content

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.pyEMAIL_*). 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_mail raising (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.app is not ours — it's a different, unrelated site. You'll see lifefile.app left around as a placeholder in some older docs and inbound-email notes; ignore it for sending. The DEFAULT_FROM_EMAIL default in settings.py is 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 control fly.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.com later (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 using heymaud.com now 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.dev today). Sending from heymaud.com with a link to maud.fly.dev is 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.

  1. Open https://postmarkapp.com/ → your maud-prod server.
  2. 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.
  3. 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.

  1. In Postmark: Sender SignaturesAdd Domainheymaud.com.
  2. Postmark shows two DNS records:
  3. a DKIM TXT record (selector like <id>._domainkey.heymaud.com), and
  4. a Return-Path CNAME (e.g. pm-bounces.heymaud.compm.mtasv.net), which gives SPF alignment on the bounce domain.
  5. Add both records at your DNS provider for heymaud.com.
  6. 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) and EMAIL_USE_TLS (True) are already the correct defaults in settings.py, and they match Postmark's STARTTLS endpoint — so you don't need to set them.
  • Set DEFAULT_FROM_EMAIL explicitly (it's in the command above) even though settings.py now 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 old lifefile.app code default with no secret set, so Postmark rejected the send with a 412 ("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 with fly 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_mail returns 1, and the app shows the cheerful "Check … for a confirmation link" — but then refuses to deliver to any recipient whose domain doesn't match the From domain.
  • 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):

  1. Sign in at https://maud.fly.dev/ with a test account.
  2. Go to Settings → change the account email to an address whose inbox you can read.
  3. You should see "Check <address> for a confirmation link to finish updating your email." — and the email should arrive within seconds, from [email protected].
  4. 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.
  5. 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:

fly ssh console -a maud
cd /app && python manage.py shell
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:

  • 1 returned, Activity shows red SMTP API Error / ErrorCode 412 "pending approval" → account still in Test mode; see step 4 (Request approval). This is the "looks successful, delivers nothing" case.
  • 412 naming the From domain as lifefile.app (not heymaud.com) → the From is wrong; set the DEFAULT_FROM_EMAIL secret (step 3) and confirm with printenv DEFAULT_FROM_EMAIL on the machine.
  • SMTPAuthenticationError / 535EMAIL_HOST_USER/PASSWORD isn'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:

  1. Add the new domain in Postmark (Sender Signatures → Add Domain) and add its DKIM/Return-Path records; wait for verified.
  2. fly secrets set -a maud DEFAULT_FROM_EMAIL="Maud <no-reply@newdomain>".
  3. Optionally update the DEFAULT_FROM_EMAIL default in settings.py in 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:

fly secrets set -a maud EMAIL_BACKEND="django.core.mail.backends.console.EmailBackend"

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, calls send_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 — the EMAIL_* env block (defaults to the console backend; DEFAULT_FROM_EMAIL defaults 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.app placeholder for the inbound subdomain — when inbound goes live it should use a subdomain of a domain you own, e.g. inbound.heymaud.com.)