Skip to content

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:

Name:   send.redhound.io
Type:   TXT
Value:  v=spf1 include:amazonses.com ~all
TTL:    3600

Important: Copy the exact SPF record shown in the Resend dashboard (Domains → your domain → DNS Records). The include target shown above is the canonical form at the time of writing, but Resend may update it. The ~all softfail is sufficient; use -all (hardfail) once DMARC is in p=reject and 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:

  1. p=none — monitoring only; no mail is blocked. Start here while you gather aggregate reports via rua.
  2. p=quarantine — unauthenticated mail goes to spam. Move here once aggregate reports show SPF and DKIM consistently pass.
  3. p=reject — unauthenticated mail is refused outright. Move here only after weeks of clean p=quarantine reports 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 dashboardDomains → 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:
    Authentication-Results: ...;
      spf=pass ...
      dkim=pass ...
      dmarc=pass ...
    
  • Send a test password-reset email and repeat the header inspection.
  • One-click unsubscribe endpointPOST /api/v1/auth/unsubscribe and the matching GET (for browser clicks) are live, signature-verified with REDHOUND_AUTH_SECRET, and idempotently write recipients to email_suppressions with reason="unsubscribed". Today no email type carries the List-Unsubscribe header (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_url on that subclass to call build_unsubscribe_url(to) and the endpoint handles the rest.

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.