Email-in — going live¶
Maud accepts inbound email via Postmark Inbound. Each household gets a unique forward+<token>@<your-domain> address; anyone who forwards an email to that address sees its attachments classified and filed automatically.
The webhook code (apps/emailscan/inbound.py) is wired up and deployed. Until the four secrets below are set, the dashboard tile shows "Coming soon" and the webhook returns 401 to anything that hits it. Setting all four flips it live — no redeploy needed.
Plan ~30 minutes end-to-end. The DNS step has propagation lag (usually under an hour, occasionally up to 24).
1. Postmark account + Inbound stream¶
- Sign up at https://postmarkapp.com/ (free tier: 100 inbound emails/month, 10k on the paid Starter tier).
- Create a server — name it
maud-prod. The server holds a default Inbound stream you'll configure next. - Open the server, switch to the Inbound tab, and note the auto-generated address. It looks like
<hash>@inbound.postmarkapp.com. You can use this directly for testing, but in production you'll want a real domain (next step).
2. Point a real domain at Postmark¶
You want addresses like [email protected], not forward+ABC123@<hash>@inbound.postmarkapp.com.
- In your DNS provider, add an MX record for the subdomain you want to use (e.g.
inbound.heymaud.com), pointing toinbound.postmarkapp.comwith priority10. - In Postmark, on the Inbound tab of your server, set the Inbound domain field to
inbound.heymaud.comand save. - Wait for propagation.
dig MX inbound.heymaud.comshould returninbound.postmarkapp.comonce it's done.
3. Configure the webhook¶
Postmark POSTs a JSON payload to your webhook for every email that arrives.
- On the Inbound tab, set Webhook URL to:
- Tick Include raw email content in JSON payload — off. We only need the parsed payload (subject, from, attachments, mailbox hash). Leaving raw off keeps payloads smaller.
- Under Basic auth (further down the same page), set a username and password. Use a long random password — you'll set the same pair as Fly secrets in the next step. Save.
- (Recommended, defence-in-depth) Note the stream's Signing key on the same page — Postmark signs every webhook POST with an HMAC-SHA256 of the raw body, sent in the
X-Postmark-Webhook-Tokenheader. You'll set it as a Fly secret too; once set, the webhook requires a valid signature in addition to basic auth.
4. Set the secrets in Fly¶
fly auth whoami # confirm [email protected]
fly secrets set -a maud \
INBOUND_EMAIL_DOMAIN="inbound.heymaud.com" \
POSTMARK_INBOUND_USER="<the username from step 3>" \
POSTMARK_INBOUND_PASS="<the password from step 3>" \
POSTMARK_WEBHOOK_SIGNING_KEY="<the signing key from step 3.4>"
Setting INBOUND_EMAIL_DOMAIN is what flips the dashboard tile from "Coming soon" to a live forward+<token>@inbound.heymaud.com address with a Copy button. Each household sees its own token.
The webhook verifies basic auth on every POST — POSTMARK_INBOUND_USER and POSTMARK_INBOUND_PASS are mandatory. If either is unset, the webhook returns 401 by design (we'd rather refuse than open the endpoint up). Both halves of the credential comparison run through hmac.compare_digest so a timing side-channel can't be used to leak the configured password byte-by-byte.
POSTMARK_WEBHOOK_SIGNING_KEY is optional but recommended: when set, the webhook also recomputes the HMAC-SHA256 of the raw request body and compares it (constant-time) against the X-Postmark-Webhook-Token header, returning 401 on mismatch. When unset the signature check is skipped — basic auth remains the gate — so enabling it can't break prod before the secret is configured.
Rotating the signing key: generate a new key in the Postmark dashboard (Inbound stream → Webhook → Signing key → regenerate), then immediately fly secrets set -a maud POSTMARK_WEBHOOK_SIGNING_KEY="<new key>". There's a brief window between the two steps where webhooks 401; Postmark retries failed deliveries so nothing is lost. To disable the check (e.g. while debugging), fly secrets unset POSTMARK_WEBHOOK_SIGNING_KEY -a maud.
5. Smoke test¶
After Fly restarts (a few seconds after fly secrets set):
- Load https://maud.fly.dev/dashboard/. The "Forward by email" tile should show your household's live address with a Copy button.
- Forward any email with at least one PDF attachment to that address.
- Postmark POSTs the webhook. We persist the raw payload as an
InboundEmailPayloadrow and enqueue aprocess_inbound_emailtask; the webhook returns 200 in under ~100ms. Postmark sees a fast success and never retries. - The
db_workerco-process picks up the task within seconds, classifies each attachment, and createsDocumentrows withsource='email_in'andreviewed_at=NULL. - The new docs land on the Email-in tab inside
/triage/(next to the Gmail findings tab). The Triage nav badge increments. Confirm each one with the Confirm ✓ button — that stampsreviewed_atand drops the doc off the tab. Anything filed in the wrong place: hit Edit to open the existing per-doc edit screen.
If nothing appears:
- Postmark's Activity tab shows incoming emails and webhook delivery status. A 401 means the basic-auth pair is mismatched between Postmark and Fly. A 200 with
"status": "unknown_token"means the recipient address didn't include a validforward+<token>— check the household's token on the dashboard tile. fly logs -a maudshows the webhook hits (inbound_postmark: …) and the worker's classifier runs (process_inbound_email: …). Skipped junk attachments log asprocess_inbound_email: skipping junk attachment 'image001.png' (image/png, 12345 bytes).python manage.py shell -c "from apps.emailscan.models import InboundEmailPayload; print(list(InboundEmailPayload.all_tenants.order_by('-received_at')[:10].values('pk', 'status', 'documents_created', 'skipped_count', 'error_message')))"shows the most recent payloads and their per-row outcomes.
Attachment filtering¶
Not every attachment on a forwarded email reaches the classifier. Three classes of obvious non-document attachments are rejected before any base64 decode or Claude API call, mirroring the same gate the Gmail scan path uses (apps/emailscan/heuristic.py):
- Outlook signature images — filenames matching
image\d{2,4}\.(jpe?g|png|gif|bmp)(e.g.image001.png,image002.jpg). Corporate email signatures attach these inline; they're noise. - Phone camera filenames —
IMG_1234.jpg,IMG_20260101_120000.HEIC, etc. Personal photos forwarded inadvertently. - Oversized bare camera images —
image/jpegorimage/heicattachments larger than ~1.5 MB. Whatever the filename, those bytes are a phone photo, not a document scan.
On top of those, any attachment larger than settings.MAX_DOCUMENT_BYTES (25 MB — the same per-document cap enforced on browser uploads) is skipped with a skipped_count bump rather than filed. This stops a sequence of huge sends filling the Fly volume.
This filter exists because the webhook handler is currently synchronous — each attachment that does reach the classifier costs ~3–10 s of wall-clock time. Postmark's webhook timeout is ~10 s; a forwarded Outlook email with five signature images plus one real PDF would otherwise stall the webhook past the timeout and Postmark would mark delivery as failed (see GH #85). Skipped attachments are counted in the response body's skipped field and logged.
The filter now runs inside the process_inbound_email task (post-Slice-2 of #85) rather than the webhook handler itself, but the predicates are the same and live in apps/emailscan/heuristic.py.
What it costs¶
- Postmark free tier: 100 inbound emails / month. After that, $15/month for 10k emails on the Starter plan.
- The Anthropic classifier cost is the same as for uploaded documents (one prompt per attachment, cached). Junk attachments (signatures, camera photos) are filtered before they reach the classifier, so a typical Outlook forward with a signature block costs the same as the one real PDF inside.
Rollback¶
To take email-in offline without removing the code:
The dashboard tile reverts to "Coming soon" and the webhook 401s any further POSTs (since the basic-auth secrets are still required). Existing documents that arrived via email-in are unaffected — they live in the same documents.Document table as uploads.
Where it lives in code¶
maud/apps/emailscan/inbound.py— the Postmark webhook view, basic-auth check, payload parsing, attachment loop.maud/apps/documents/services.py:create_document_from_email— classify + save path. Mirrors the upload path but takes raw bytes instead of anUploadedFile.maud/apps/households/models.py:Household.forward_token— per-household token, set by migration on household creation.maud/templates/dashboard.html— the tile that shows the live address.maud/config/urls.py— webhook is mounted at/inbound/postmark/.