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 forward+ABC123@inbound.heymaud.com, 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 payload — off. 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. Set the secrets in Fly

fly auth whoami                          # confirm olly.willans@gmail.com
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>"

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.

Failed auth attempts are rate-limited per IP (20/hour → 429) so the basic-auth pair can't be brute-forced, and the webhook returns an identical {"status": "accepted"} body whether or not the forward token resolved, so it can't be used as an oracle for live token values.

Postmark doesn't sign webhooks. There is no signing key in the Postmark dashboard; its docs recommend basic auth plus allowlisting Postmark's IP ranges. An earlier version of this page described a POSTMARK_WEBHOOK_SIGNING_KEY secret — that setting no longer exists, and setting it never worked. A real second factor (IP allowlist, or moving inbound to Cloudflare Email Routing with a signed relay) is tracked in #262.

Payload privacy + retention

Forwarded emails are the most sensitive path in the product, so the stored InboundEmailPayload row is stripped as soon as it reaches a terminal state (assessment M3): TextBody, HtmlBody, and every attachment's base64 Content are dropped, keeping only the admin-trail metadata (sender, subject, attachment names/types/sizes, _stripped: True). Unknown-token payloads are stored pre-stripped since they are never processed. Even stripped rows are deleted outright after INBOUND_PAYLOAD_RETENTION_DAYS (default 90) — the weekly cron runs the sweep, and python manage.py purge_inbound_payloads [--days N] runs it manually.

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 filenames — IMG_1234.jpg, IMG_20260101_120000.HEIC, etc. Personal photos forwarded inadvertently.
  • Oversized bare camera images — image/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/.