Skip to content

Which transactional email provider should this app send through?

Resend, over Django's stock SMTP backend, sending from a subdomain of avrm.link. No new Python dependency, no account approval to wait on, no card, and a free ceiling (3,000/month, 100/day) that a hand-approved two-person-per-Circle app will not reach. Postmark is the runner-up and the one to switch to if deliverability ever disappoints — the switch is four environment variables, because nothing above django.core.mail changes.

Issue #20, child of #18. Date: 2026-09-22. All prices and limits below were fetched from the providers' own pages on that date; they change, so re-check before provisioning.

Sources (all primary)


What this app actually needs to send

From the map (#18): an email-verification message at signup, an invite to the second person in a Circle, a password reset, and the odd resend. Approval is by hand, and a Circle holds two people. That is a handful of messages a week at the optimistic end — order 10–100 a month, not thousands. Every one of them is one-to-one and user-triggered: the canonical definition of transactional mail that every provider here is built for.

Two consequences shape the whole decision:

  1. Volume is irrelevant to cost. Every candidate is free or effectively free at this scale. Pricing only matters as a ceiling shape — what happens if the number is suddenly wrong — not as a monthly bill.
  2. Latency in the request path is the real constraint. There is no task queue, no Celery, no cron worker. A signup view that sends a verification email sends it synchronously, inside the HTTP request, and the user waits for the provider.

Candidates

Resend

Free tier: $0/mo, 3,000 emails/month with a hard 100 emails per day cap, and 3 domains [S1]. Paid starts at $20/mo for 50,000 with no daily cap [S1].

At the ceiling: the API returns HTTP 429 daily_quota_exceeded — "You have exceeded your daily email sending quota" — and separately 429 rate_limit_exceeded for the per-second limit, which is 10 requests/second per team by default [S4]. Sends fail loudly and recover the next day; nothing is silently dropped, and no card is charged automatically.

Domain setup: add the domain in the dashboard, then copy the generated DKIM TXT, SPF (MX or CNAME plus TXT) records into DNS; DMARC is added after initial verification and is treated as a follow-on step rather than a gate [S3]. Verification "will often verify within 15 minutes of adding the DNS records", worst case 72 hours for propagation, with a "Restart verification" button [S3]. Resend explicitly recommends a subdomain (updates.example.com) over the root domain, to isolate sending reputation [S3].

Django integration: plain SMTP. Host smtp.resend.com, username the literal string resend, password the API key, ports 25 / 465 / 587 / 2465 / 2587 [S2]. That is Django's stock django.core.mail.backends.smtp.EmailBackend with four settings and zero new dependencies [S13][S14]. The documented catch: Resend does not expose SMTP server logs, so SMTP-level debugging means contacting support — though sends still appear in the dashboard, and throughput is identical to the API [S2].

Blockers for a hobby project: none found. No card, no approval queue.

Postmark

Free tier: 100 emails/month, $0, and it "never expires or runs out" [S5]. Paid starts at $15/mo for 10,000 [S5]. 100/month is plausible for this app but has no headroom: one noisy week of invites and resends eats it.

At the ceiling: you stop, or you pay $15.

Approval: this is the distinguishing fact. Postmark manually reviews every new account — "We manually review each new account to ensure it won't be used to send emails that can potentially hurt our sending reputation" — completed "in less than 24 hours on weekdays and a little longer on the weekends" [S6]. Until approved, "you won't be able to send to any email address outside the domains you've added to your account and verified", and link tracking is off [S6]. So provisioning cannot be finished in one sitting on a weekend, and a pre-approval smoke test can only reach your own domain or their blackhole address test@blackhole.postmarkapp.com [S6].

Domain setup: DKIM plus a custom Return-Path CNAME (which is what buys SPF alignment) are required; DMARC is recommended, not mandatory [S7].

Django integration: SMTP, same as Resend — no dependency required.

Verdict: the best deliverability reputation of the set and genuinely transactional-only by policy, which is a feature. It loses on the 100/month ceiling and the approval wait, both of which are friction a human has to sit through during provisioning.

Amazon SES

Cost: à la carte $0.10 per 1,000 emails, no mandatory fee; the Essentials plan is $0.16 per 1,000 [S8]. New AWS accounts get up to $200 of Free Tier credits valid for 6 months [S8]. At this app's volume the bill rounds to cents — SES is the cheapest option by a wide margin and it is not close.

The blocker: every new account starts in the sandbox, where you can send only to verified addresses or the mailbox simulator, capped at 200 messages per 24 hours and 1 message per second [S9]. Sandbox is per AWS Region. Getting out means filling in a production-access request — mail type, website URL, contact addresses, an acknowledgement — and waiting: "The AWS Support team provides an initial response to your request within 24 hours", possibly longer if they ask follow-up questions [S9]. A verification email to a stranger's address is exactly what the sandbox forbids, so SES is unusable for this feature until that request clears.

Django integration: SMTP works, but SES SMTP credentials are a separate artifact from AWS access keys, are region-scoped, and must be generated per region [S10]. That is one more kind of secret and one more thing to get wrong.

Verdict: wrong shape for a hobby project. It trades a dashboard for IAM, regions, a sandbox exit interview, and a credit-card-backed AWS account, to save roughly nothing on a bill that is already zero elsewhere.

Mailgun

Free tier: 100 emails/day, no credit card, 1 custom domain, and 1 day of log retention [S11] plus the free-plan help article. Paid starts at $15/mo for 10,000 [S11].

The catch: every new account gets a sandbox domain restricted to at most 5 authorized recipients — "Sending messages to lists with up to 5 authorized participants" — and you cannot send to arbitrary addresses from it [S12]. A free account without card details on file is effectively confined to authorized recipients until a custom domain is verified, and an unverified custom domain is capped at 300 emails/day [S12].

Domain setup: the heaviest of the set — SPF TXT, DKIM TXT, a tracking CNAME, and two MX records (mxa/mxb.mailgun.org) [S12].

Verdict: strictly worse than Resend on every axis that matters here — same daily cap, more DNS records, one domain instead of three, one day of logs, and a sandbox concept to reason around.

Named and not pursued

  • SendGrid — now sold as Twilio SendGrid; the standalone pricing URL 301-redirects into Twilio's site and the free tier has been repeatedly restructured. A provider whose pricing page will not resolve cleanly is not one to build a signup flow on today.
  • Brevo, MailerSend, Mailjet, SparkPost, Scaleway TEM — all real options and all supported by Anymail [S15]. None of them beat Resend's combination of no card, no approval, three domains and 3,000/month, so none earned a fetch.

The two integration decisions that follow

SMTP backend, not an SDK

Django ships an SMTP backend as the default EMAIL_BACKEND (django.core.mail.backends.smtp.EmailBackend), configured by EMAIL_HOST, EMAIL_PORT, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, EMAIL_USE_TLS and EMAIL_TIMEOUT [S13][S14]. Every provider above speaks SMTP. So the provider costs zero dependencies, and the app's code calls django.core.mail and knows nothing about who delivers.

django-anymail [S15] is the alternative: one package, a provider-specific backend, and access to provider extras (templates, tags, tracking webhooks, per-message metadata). It supports all 14 relevant ESPs including Resend and Postmark, which makes it the cheap escape hatch later — but there is nothing to escape to yet. Adding it now buys features this app does not send. Start on SMTP; reach for Anymail the first time a provider-specific feature is genuinely wanted (bounce webhooks are the likely trigger).

The request path, with no task queue

send_mail() is synchronous and blocking [S13]. With no queue in this project, a signup POST holds the uvicorn worker open for the full SMTP round-trip to smtp.resend.com. Two things follow, and the provisioning ticket should carry both:

  1. EMAIL_TIMEOUT must be set. Its default is None, which means socket.getdefaulttimeout(), which is itself None — no timeout at all [S13][S14]. An unresponsive SMTP server would hang a request indefinitely. Set it to something small (10 seconds is a reasonable opening bid).
  2. A failed send must not be swallowed the way _broadcast() swallows SSE errors. An SSE broadcast that fails costs a refresh; a verification email that fails costs the user their account. The right shape is: surface the failure to the user with a resend affordance, rather than fail_silently=True. That is a design note for the flow, not for this ticket.

Worth noting for later: an async-capable send would avoid blocking the ASGI worker, but Django's SMTP backend is synchronous and there is no queue — so the honest answer today is "it blocks, keep the timeout short, and accept it at this volume". If email ever moves off the request path, that is its own ticket, and the provider choice does not change it.

How the secret reaches the box

Today, config reaches production as environment variables in a single .env file: deploy/calendar.service has EnvironmentFile=/opt/calendar/.env, settings.py reads os.environ.get(...) after load_dotenv(), and .env.example documents the keys (DJANGO_SECRET_KEY, VAPID_PRIVATE_KEY, …) [S16]. deploy/post-receive never touches .env — it checks out, syncs, migrates, builds, collects, restarts [S16]. So the API key is hand-placed on the VPS once, exactly like the VAPID private key already is, and a systemctl restart calendar picks it up. No new deployment machinery is needed.

Recommendation

Use Resend, via Django's SMTP backend, sending from a subdomain of the production domain.

The reasons, in order of weight:

  1. Nothing blocks a human from finishing provisioning in one sitting. No card, no manual-approval queue (Postmark), no sandbox-exit request (SES), no 5-authorized-recipient pen (Mailgun). The only wait is DNS propagation, typically ~15 minutes [S3].
  2. The free ceiling is the right shape. 3,000/month with a 100/day cap [S1] is 30× the headroom of Postmark's free tier [S5] at an app volume that should sit in the low tens. Hitting the cap returns a visible 429 [S4], not a surprise invoice.
  3. It adds no dependency and no lock-in. SMTP only [S2]; the app calls django.core.mail and the provider is four environment variables. Switching to Postmark later is a .env edit and a DNS change — which is precisely why choosing the lower-friction provider first costs nothing if it disappoints.
  4. Its domain story matches the deployment. Three domains on the free plan [S1] means the production host and a dev/staging sender can coexist, and Resend's own advice — send from a subdomain, keep the root's reputation clean [S3] — is a free win on a domain that currently sends no mail at all.

The case against, stated fairly: Postmark has the stronger deliverability reputation and is stricter about keeping transactional streams clean, and its no-SMTP-logs problem does not exist [S2][S6]. If verification emails start landing in spam, switch — the cost of being wrong here is one evening, not a rewrite.

What provisioning (#28) has to do

  1. Create a Resend account (no card) and generate a sending API key.
  2. Pick the sending subdomain — something like mail.avrm.link, given production is sundown.avrm.link [S16] — add it in Resend, and publish the generated DKIM/SPF records in DNS. Do not proxy the CNAME records through Cloudflare's orange cloud; Resend states that prevents verification [S3]. Add DMARC after the domain verifies.
  3. Put RESEND_API_KEY (or the SMTP settings directly) into /opt/calendar/.env by hand, and add the keys to .env.example so the shape is documented [S16].
  4. Add to settings.py: EMAIL_HOST=smtp.resend.com, EMAIL_PORT=587, EMAIL_HOST_USER=resend, EMAIL_HOST_PASSWORD=<api key>, EMAIL_USE_TLS=True, EMAIL_TIMEOUT (small — the default is no timeout [S14]), and DEFAULT_FROM_EMAIL pointing at the verified subdomain. All read from the environment, matching the existing os.environ.get convention [S16].
  5. Keep the console backend for local dev and the locmem backend for tests [S13] — neither pytest nor the Playwright E2E run should touch the network or burn the daily quota.
  6. Verify end to end with one real send to a real inbox, and check SPF/DKIM/DMARC pass in the received message's headers.