Skip to content

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

  1. Sign up at https://postmarkapp.com/ (free tier: 100 inbound emails/month, 10k on the paid Starter tier).
  2. Create a server — name it maud-prod. The server holds a default Inbound stream you'll configure next.
  3. 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.

  1. In your DNS provider, add an MX record for the subdomain you want to use (e.g. inbound.heymaud.com), pointing to inbound.postmarkapp.com with priority 10.
  2. In Postmark, on the Inbound tab of your server, set the Inbound domain field to inbound.heymaud.com and save.
  3. Wait for propagation. dig MX inbound.heymaud.com should return inbound.postmarkapp.com once it's done.

3. Configure the webhook

Postmark POSTs a JSON payload to your webhook for every email that arrives.

  1. On the Inbound tab, set Webhook URL to:
    https://maud.fly.dev/inbound/postmark/
    
  2. Tick Include raw email content in JSON payloadoff. We only need the parsed payload (subject, from, attachments, mailbox hash). Leaving raw off keeps payloads smaller.
  3. 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.
  4. (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-Token header. 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):

  1. Load https://maud.fly.dev/dashboard/. The "Forward by email" tile should show your household's live address with a Copy button.
  2. Forward any email with at least one PDF attachment to that address.
  3. Postmark POSTs the webhook. We persist the raw payload as an InboundEmailPayload row and enqueue a process_inbound_email task; the webhook returns 200 in under ~100ms. Postmark sees a fast success and never retries.
  4. The db_worker co-process picks up the task within seconds, classifies each attachment, and creates Document rows with source='email_in' and reviewed_at=NULL.
  5. 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 stamps reviewed_at and 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 valid forward+<token> — check the household's token on the dashboard tile.
  • fly logs -a maud shows the webhook hits (inbound_postmark: …) and the worker's classifier runs (process_inbound_email: …). Skipped junk attachments log as process_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 filenamesIMG_1234.jpg, IMG_20260101_120000.HEIC, etc. Personal photos forwarded inadvertently.
  • Oversized bare camera imagesimage/jpeg or image/heic attachments 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:

fly secrets unset -a maud INBOUND_EMAIL_DOMAIN

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 an UploadedFile.
  • 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/.