Email Deliverability¶
This runbook covers the DNS records required to authenticate outbound email from Redhound and explains how to verify that transactional emails land in the inbox.
Outbound mail is sent via Resend from the address
no-reply@send.redhound.io (the _DEFAULT_FROM_EMAIL constant in
backend/auth/email.py). The sending domain is therefore send.redhound.io
(a subdomain of redhound.io). All DNS records below are set on this subdomain
unless otherwise stated.
DNS records¶
SPF¶
Add a TXT record to send.redhound.io that authorises Resend's mail servers to
send on your behalf.
Resend routes outbound mail through Amazon SES infrastructure, so the
include target is amazonses.com:
Important: Copy the exact SPF record shown in the Resend dashboard (Domains → your domain → DNS Records). The
includetarget shown above is the canonical form at the time of writing, but Resend may update it. The~allsoftfail is sufficient; use-all(hardfail) once DMARC is inp=rejectand you are confident no other paths send from this domain.
DKIM¶
Resend provides between one and three CNAME records that prove Redhound authorised the mail. Add every record Resend shows for the domain.
Example shape (Resend shows the exact names and values in the dashboard):
Name: resend._domainkey.send.redhound.io
Type: CNAME
Value: <provided by Resend dashboard>
Name: <selector2>._domainkey.send.redhound.io
Type: CNAME
Value: <provided by Resend dashboard>
DKIM signatures are applied automatically by Resend once these records resolve. Without them, mail may be classified as unauthenticated by receiving servers.
DMARC¶
DMARC ties SPF and DKIM together and instructs receivers what to do with
unauthenticated mail. Add a TXT record to _dmarc.send.redhound.io (or
_dmarc.redhound.io if you want a root-domain policy that covers all
subdomains):
Name: _dmarc.send.redhound.io
Type: TXT
Value: v=DMARC1; p=quarantine; rua=mailto:dmarc@redhound.io; fo=1
TTL: 3600
Policy progression — do not jump straight to p=reject:
p=none— monitoring only; no mail is blocked. Start here while you gather aggregate reports viarua.p=quarantine— unauthenticated mail goes to spam. Move here once aggregate reports show SPF and DKIM consistently pass.p=reject— unauthenticated mail is refused outright. Move here only after weeks of cleanp=quarantinereports with no legitimate failures.
The rua address (dmarc@redhound.io) must be a mailbox you monitor.
Aggregate reports arrive daily from major receivers (Gmail, Outlook, Yahoo) and
show which sources are passing or failing authentication.
fo=1 requests a failure report whenever any authentication mechanism fails
(SPF or DKIM), giving early warning of misconfiguration.
Verification checklist¶
Complete this checklist after adding the DNS records above (allow up to 48 hours for propagation, though typically under an hour):
- Resend dashboard — Domains → your domain shows status Verified for all three record types (SPF, DKIM, DMARC).
- Send a test verification email to a Gmail account, an Outlook account, and a Proton Mail account via the sign-up or invite flow.
- Inbox placement — confirm the test emails arrive in the inbox (not spam) for all three providers.
- Inspect raw headers in at least one provider (Gmail: ⋮ → Show original). Confirm all three lines are present and pass:
- Send a test password-reset email and repeat the header inspection.
- One-click unsubscribe endpoint —
POST /api/v1/auth/unsubscribeand the matchingGET(for browser clicks) are live, signature-verified withREDHOUND_AUTH_SECRET, and idempotently write recipients toemail_suppressionswithreason="unsubscribed". Today no email type carries theList-Unsubscribeheader (every current send is transactional and RFC 8058 / Gmail bulk-sender guidelines both exempt transactional mail). When a marketing or notification sender is added, override_BaseEmailSender._unsubscribe_urlon that subclass to callbuild_unsubscribe_url(to)and the endpoint handles the rest.
Related: bounce and complaint handling¶
Resend delivers bounce and complaint events to the webhook at
POST /api/v1/webhooks/resend. The shared secret is RESEND_WEBHOOK_SECRET in
.env. The handler writes addresses that hard-bounce or mark mail as spam to the
email_suppressions table via EmailSuppressionRepository, which is consulted
by _send_guarded before every send. This suppression list is the runtime
complement to the DNS-level authentication above — together they protect sender
reputation and ensure compliance with Gmail and Yahoo bulk-sender requirements
(effective 2024).
Unsubscribe endpoint¶
POST /api/v1/auth/unsubscribe?token=... (and the matching GET for browser
clicks) is the RFC 8058 one-click endpoint. The token is a JWT signed with
REDHOUND_AUTH_SECRET, binds a single recipient address, and is intentionally
non-expiring — users may click months-old mail. The handler decodes the
token and writes the recipient to email_suppressions with
reason="unsubscribed". The GET variant additionally renders a
self-contained confirmation page (the recipient address is HTML-escaped
before rendering).
reason matters at send time. _send_guarded consults
EmailSuppressionRepository.get_reason and treats the values differently:
bounced/complained— hard reasons. Every send is skipped; the ESP would penalise us for trying again.unsubscribed— opt-out from marketing. Transactional mail (password resets, verification, security notices, all current sends) still goes through. This avoids the lock-out risk where one accidental click — or a mail-client preview prefetch — silently disables account recovery.
The base sender does not attach List-Unsubscribe headers, since every
current send is transactional. When a future marketing/notification sender
is added, override _unsubscribe_url on that subclass to return
build_unsubscribe_url(to). Both transports already emit the header pair
when the URL is non-None.