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)¶
- [S1] Resend pricing — https://resend.com/pricing
- [S2] Resend SMTP docs — https://resend.com/docs/send-with-smtp
- [S3] Resend domain setup — https://resend.com/docs/add-a-domain and https://resend.com/docs/dashboard/domains/introduction
- [S4] Resend API reference and errors — https://resend.com/docs/api-reference/introduction, https://resend.com/docs/api-reference/errors
- [S5] Postmark pricing — https://postmarkapp.com/pricing
- [S6] Postmark approval process — https://postmarkapp.com/support/article/1084-how-does-the-account-approval-process-work
- [S7] Postmark domain verification — https://postmarkapp.com/support/article/1046-how-do-i-verify-a-domain
- [S8] Amazon SES pricing — https://aws.amazon.com/ses/pricing/
- [S9] SES sandbox and production access — https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html
- [S10] SES SMTP interface — https://docs.aws.amazon.com/ses/latest/dg/send-email-smtp.html
- [S11] Mailgun pricing — https://www.mailgun.com/pricing/
- [S12] Mailgun domain verification and sandbox — https://documentation.mailgun.com/docs/mailgun/user-manual/domains/, https://documentation.mailgun.com/docs/mailgun/user-manual/domains/domains-sandbox
- [S13] Django email topic guide — https://docs.djangoproject.com/en/5.1/topics/email/
- [S14] Django settings reference — https://docs.djangoproject.com/en/5.1/ref/settings/
- [S15] django-anymail docs — https://anymail.dev/en/stable/
- [S16] This repo:
deploy/calendar.service,deploy/post-receive,.env.example,shared_calendar/settings.py
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:
- 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.
- 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:
EMAIL_TIMEOUTmust be set. Its default isNone, which meanssocket.getdefaulttimeout(), which is itselfNone— 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).- 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 thanfail_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:
- 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].
- 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.
- It adds no dependency and no lock-in. SMTP only [S2]; the app calls
django.core.mailand the provider is four environment variables. Switching to Postmark later is a.envedit and a DNS change — which is precisely why choosing the lower-friction provider first costs nothing if it disappoints. - 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¶
- Create a Resend account (no card) and generate a sending API key.
- Pick the sending subdomain — something like
mail.avrm.link, given production issundown.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. - Put
RESEND_API_KEY(or the SMTP settings directly) into/opt/calendar/.envby hand, and add the keys to.env.exampleso the shape is documented [S16]. - 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]), andDEFAULT_FROM_EMAILpointing at the verified subdomain. All read from the environment, matching the existingos.environ.getconvention [S16]. - 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.
- Verify end to end with one real send to a real inbox, and check SPF/DKIM/DMARC pass in the received message's headers.