Email integrations
DaloyJS doesn't ship its own email transport. You write one small EmailSender per provider, decorate the root app with it (handlers read it as ctx.state.email), and call it from route handlers or, better, from a background job. This page holds everything the eight provider guides share: the sender contract, the rules that stop your send route from becoming an open relay, HTML escaping, queueing, webhooks for bounces and complaints, deliverability, and testing. The provider pages only cover what is different.
Supported providers
- AWS SES (SESv2): pay-as-you-go sending at AWS scale via
@aws-sdk/client-sesv2. Best fit when you already run on AWS or need the cheapest per-message price. - SendGrid: Twilio's established sender via
@sendgrid/mail. Transactional plus marketing in one account. - Resend: email API via the
resendSDK. Fetch-based, React Email templates, native idempotency keys. - Postmark: transactional-first delivery via the
postmarkSDK, with separate message streams for transactional and broadcast mail. - Mailgun: Sinch-backed sender via
mailgun.js. Validation, routing, and US/EU regions. - Mailtrap: sandbox plus production sending via the
mailtrapSDK. Captures mail in a test inbox until you explicitly switch to production. - Cloudflare Email Service: send from a Worker through the
send_emailbinding (no API key), with recipient-locked bindings, delivery events on Cloudflare Queues, and inbound mail through Email Routing. Email Sending is in public beta. - Azure Communication Services: ACS Email via
@azure/communication-emailwith managed identity and Event Grid delivery reports, on the Azure Functions adapter. Microsoft is retiring ACS Email on September 30, 2028, so prefer another provider for new projects.
Runtime and feature matrix
Every provider here sends over an HTTPS API (Cloudflare also offers a Workers binding). Some SDKs depend on Node-only pieces (axios with Node http/https agents, the AWS default credential chain), so on Cloudflare Workers you either use a fetch-based SDK or call the REST API through resilientFetch.
| Provider | Node / Bun / Deno | Cloudflare Workers | AWS Lambda | Webhook signing | Native idempotency key |
|---|---|---|---|---|---|
| AWS SES (SESv2) | Yes | Sign the SESv2 REST API with aws4fetch | Yes (IAM role) | SNS message signature (RSA, SignatureVersion 2 is SHA256), or EventBridge to SQS with no public endpoint | Not documented |
| SendGrid | Yes | REST via resilientFetch (SDK is built on axios) | Yes | ECDSA Signed Event Webhook over timestamp + raw body | Not documented |
| Resend | Yes | Yes (SDK uses fetch) | Yes | Svix-style HMAC-SHA256 (resend.webhooks.verify) | Yes (idempotencyKey) |
| Postmark | Yes | SDK v5 is fetch-based and takes a custom fetch; REST via resilientFetch is the conservative path | Yes | None: Basic Auth in the webhook URL, optional IP allowlist | Not documented |
| Mailgun | Yes | Yes with useFetch: true (v12.1+) | Yes | HMAC-SHA256 of timestamp + token | Not documented |
| Mailtrap | Yes | REST via resilientFetch (SDK uses axios with Node agents) | Yes | HMAC-SHA256 of the raw body (Mailtrap-Signature) | Not documented |
| Cloudflare Email Service | REST API with a scoped API token | Yes (native send_email binding, no key) | REST API with a scoped API token | No webhook: delivery events arrive on a Cloudflare Queue through an event subscription, so there is no public endpoint | Not documented |
| Azure Communication Services | Node: yes; Bun and Deno not verified | Not verified | Yes (service principal or connection string) | Unsigned Event Grid events: validation handshake plus a Microsoft Entra bearer token (or a secret header), or the Functions Event Grid trigger with no route of yours | Not documented (operationId only names the operation) |
"Not documented" means the provider's public send API has no idempotency key we could find. Rely on the job queue's idempotency key instead and never blindly retry a send that may have reached the provider.
Common pattern
Every guide follows the same steps: install the SDK, build a sender: EmailSender, call emailPlugin(app, sender) on the root app, then send from a validated route or a job handler. The plugin is tiny so you can swap providers without touching business logic:
- 01step 1Install the SDKpnpm add <provider-sdk>
- 02step 2Decorate the root appemailPlugin(app, sender)
- 03step 3Send from a route or jobstate.email.send(msg)
app.decorate() is not linked to AppState, so an inline object literal passed straight to it gets untyped method parameters under strict. That is why every provider page declares const sender: EmailSender = { ... } first and hands it to emailPlugin().
The shared input schema rejects CR, LF, and NUL in every header-bound field. Provider pages import headerSafe from here for subjects, names, and From display names:
Load the sender address and other settings once, at boot, with defineConfig() so a missing key fails the deploy instead of the first send:
Don't build an open relay
An endpoint that sends whatever the client asks, to whoever the client names, is an open relay. Spammers find it within days, your provider suspends the account, and your domain reputation takes the hit. Two rules cover almost every app:
- Contact and feedback forms send to you, not to the visitor. The recipient is a fixed internal address from config. The visitor's address goes only into
replyTo, so hitting Reply in your inbox reaches them. - User-triggered mail (invites, receipts, password resets) comes from an authenticated user, and the server picks the recipient and the content. The client sends an id (an order id, a team id plus an invitee address), the server checks the caller owns it, looks up the data, and builds the message from a template. The client never supplies
to,subject, or body text that lands in someone else's inbox unescaped.
Escape every interpolated value
Anything a user typed (a name, a message, a team name in an invite) becomes HTML the moment you put it in html. Without escaping, <a href="https://evil.example">Reset your password</a> in a display name turns your trusted domain into a phishing kit. Use the escapeHtml() helper above for every interpolation, or render with a template engine that escapes by default (React Email, Handlebars {{ }}, Postmark's Mustachio, Mailgun and SendGrid stored templates). Plain-text bodies need no escaping, but header-bound fields still go through headerSafe.
Rate limiting
Put rateLimit() on the routes that send mail, through the route's hooks, rather than one global app.use(rateLimit(...)) that also throttles your health check. Stack a second limiter keyed by the authenticated user or by the recipient, so one account cannot mail-bomb an inbox from many IPs:
A per-recipient limiter works the same way. rateLimit() runs in beforeHandle, after body validation, so a keyGenerator can read the validated invitee address and key on invite-to: plus the lowercased address. Nobody can then flood one inbox through many accounts. The Mailgun invite route shows it.
For anything multi-instance, back the limiter with the Redis store. Public forms also deserve bot friction: botGuard() blocks empty and abusive User-Agents, and a CAPTCHA such as Cloudflare Turnstile (verify the token server-side with the provider's siteverify endpoint before you call send) stops scripted submissions that a rate limit alone lets through slowly.
Send off the request path
A provider call takes tens to hundreds of milliseconds and sometimes times out. Hand it to the background job queue so the HTTP response does not wait, retries happen with backoff, and a duplicate request collapses into one job through its idempotency key:
Then enqueue from the route with a key that describes the business event, so a double-click or a client retry never sends twice:
- The worker loop belongs on a long-lived process. On AWS Lambda, enqueue into SQS and let a Lambda consumer send. On Cloudflare Workers, use Cloudflare Queues with a consumer Worker (example). On Azure Functions, use a Storage queue and a queue trigger (example). See where jobs run.
- Delivery is at least once. The job's idempotency key stops duplicate enqueues; the provider's own key (Resend) or your
isUndeliverable/ "already sent" check stops a duplicate run. - For client-facing retries on the HTTP route itself, add the
idempotency()middleware.
Calling a REST API with fetch
When an SDK does not run on your runtime, call the provider's REST endpoint with a guarded, time-bounded fetch:
resilientFetch() retries only GET, HEAD, OPTIONS, PUT, and DELETE by default. A send is a POST, so a timeout is not retried, which is what you want: the provider may already have accepted the message, and a blind retry sends it twice. Let the job queue retry the whole job instead, and only add POST to retryableMethods for a provider that honours an idempotency key you pass. fetchGuard() keeps the SSRF floor underneath; on Node you can drop allowHosts and let it resolve DNS normally, and on Workers you can pass a resolve function instead.
Secrets on every runtime
- Keep API keys in environment variables or the platform's secret store. Never commit them. On Lambda prefer the execution role over keys.
- On Cloudflare Workers,
process.envis populated only with thenodejs_compatflag, and thenodejs_compat_populate_process_envbehaviour it relies on is on by default for compatibility dates on or after 2025-04-01. Otherwise read bindings withimport { env } from "cloudflare:workers"and pass them todefineConfig({ schema, source: { kind: "env", env } }). - Use the narrowest key each provider offers (send-only, scoped to one domain), and a separate key per environment so a leaked staging key cannot send production mail.
Bounces, complaints, and suppression
Every provider reports delivery events (bounces, spam complaints, unsubscribes) to a webhook you host (Cloudflare publishes them to a Queue instead, and ACS through Event Grid). Process them, or you keep mailing dead and hostile addresses until the provider throttles you. Each provider page has a "Webhooks: bounces and complaints" section with a real route; they all follow the same shape:
- 01readBounded raw bodyno body schema; readBodyLimited(request, n)
- 02verifySignature or credentialson the exact bytes, before parsing
- 03dedupeProvider event idjobIdempotencyKey({ name, key: eventId })
- 04ack2xx fastenqueue, do not process inline
- 05workerMark undeliverableemail.suppress job
- Declare the route without a body schema and read the bytes with
readBodyLimited(request, 1_048_576). Signatures cover the exact bytes; parsing and re-serializing JSON breaks them. - Verify before you trust anything.
verifyWebhookSignature()from core covers the HMAC schemes (Mailgun, Mailtrap, and a manual Resend check). SendGrid signs with ECDSA and SES with RSA certificates, so those pages usenode:crypto. Postmark has no signatures at all and relies on Basic Auth. Event Grid (ACS) sends a Microsoft Entra bearer token thatjwk()verifies. - Parse with
safeJsonParse()(prototype-pollution safe) only after verification. - Enqueue one
email.suppressjob per hard bounce or complaint, keyed by the provider's event id, and return 2xx. Duplicate deliveries of the same event collapse into the existing job. - Keep your own "undeliverable" flag even though every provider also keeps a suppression list. Your flag lets you stop at enqueue time, show the user "we can't reach this address", and switch providers without losing history.
For mail you send to webhooks of your own customers, see webhook delivery instead.
Deliverability
- Authenticate and align. Publish SPF and DKIM for the sending domain, then DMARC. DMARC passes only when the visible
From:domain aligns with the SPF (Return-Path) or DKIM domain, so set up the provider's custom Return-Path / MAIL FROM domain (SES "custom MAIL FROM domain", Postmark Return-Path CNAME, SendGrid domain authentication, Mailgun's sending domain) instead of leaving it on the provider's shared domain. - Move DMARC beyond
p=none. Start withp=noneand arua=report address, read the reports until only your senders pass, then step up top=quarantineand finallyp=reject.p=noneonly monitors; it does not stop anyone spoofing your domain. - Bulk sender rules. Since February 2024, Gmail requires senders of 5,000+ messages a day to Gmail accounts to have SPF, DKIM, and a DMARC policy (at least
p=none) with alignment, one-click unsubscribe on marketing mail, and a spam rate below 0.3% in Postmaster Tools. Yahoo announced matching rules at the same time. Microsoft began enforcing similar SPF, DKIM, and DMARC requirements for domains sending over 5,000 messages a day to Outlook.com, Hotmail.com, and Live.com in May 2025. Check each mailbox provider's sender guidelines for the current thresholds. - One-click unsubscribe (RFC 8058). Marketing and other subscribed mail needs both headers. Build them in your code (never from request input) and pass them through
EmailMessage.headers. The URL must accept an unauthenticatedPOSTwith the bodyList-Unsubscribe=One-Click, so put a signed, single-purpose token in it rather than relying on a session:
- Separate transactional from marketing. Send password resets and receipts from one subdomain and stream (
mail.acme.example.com, Postmark's transactional stream, a dedicated SendGrid subuser or IP pool) and newsletters from another (news.acme.example.com, a broadcast stream). A bad campaign then cannot drag your login emails into spam. Some providers add unsubscribe headers automatically on their marketing products; check before you add a second pair.
Testing
Unit-test routes and jobs with a fake sender that records messages, and use the in-process test client to drive the route:
For end-to-end checks of real MIME output locally, run Mailpit (docker run -p 8025:8025 -p 1025:1025 axllent/mailpit) and point a Nodemailer-based sender at its SMTP port. Open http://localhost:8025 to see every captured message:
Cloudflare Workers cannot open outbound TCP connections on port 25, so SMTP-based senders are for Node, Bun, and Deno; on Workers use an HTTPS provider API. Mailtrap's sandbox is a hosted alternative to Mailpit for staging.
Never leak provider errors
Provider error messages can contain your account details, the recipient list, or internal ids. Log them through the request logger (which redacts tokens and credentials) and return a generic problem+json error to the client:
Inside a job handler, just rethrow: the worker logs it and retries with backoff. Throw JobFatalError for permanent failures such as an inactive recipient, so the job is not retried.
Security checklist
- Keep API keys in environment variables or a secret store, validated at boot with
defineConfig(). Use IAM roles on Lambda and platform-managed secrets on Vercel, Cloudflare, Fly, and Render. - Verify your sending domain with SPF, DKIM, and DMARC before going live, and plan the move past
p=none(deliverability). - Don't build an open relay. Contact forms send to a fixed internal address with the visitor only in
replyTo. User-triggered mail requires authentication, and the server looks up the recipient and content (details). - Escape every interpolated value in HTML bodies with
escapeHtml()or an auto-escaping template engine (details). - Validate inputs and reject CRLF in header fields. Treat
to,subject, display names,replyTo, and any custom header value as untrusted. Use DaloyJS validation withz.email()for addresses and theheaderSafeschema for every other header-bound string. This is the classic SMTP / email header injection vector (Snyk's write-up Avoiding SMTP Injection documents library-level CVEs that were fixed upstream, but the application-level rule still applies whenever you forward user input into mail headers). - Prefer HTTPS provider APIs over raw SMTP libraries. Every provider documented here sends over an HTTPS API or a platform binding, so the CRLF-in-SMTP-command class of bug (
smtp-client,smtp-channel,aiosmtplibsource_address) doesn't reach the wire. If you must use a raw SMTP client, validate every field, including hostname and source address. - Rate-limit each send route through its own
hooks, add a per-user or per-recipient limiter, and put bot friction on public forms (details). - Verify every provider webhook on the raw body before trusting it, dedupe on the event id, and suppress hard bounces and complaints (details).
- Never echo provider errors to clients (details).