Human-in-the-loop setup. This guide creates the Resend account, verifies the sending domain, and adds the DNS records that let Tendnote's mail reach an inbox. None of it can be done by an agent or by code: it needs an operator with access to the Resend dashboard and a Cloudflare DNS zone for the operator's domain. This runbook assumes Cloudflare DNS; other providers need their own equivalent record-entry procedure.
Tendnote sends one kind of email today - a Household Invitation. It is transactional in the strict sense: a person typed an address, an explicit Owner action created a durable delivery attempt, and the message carries a capability that person needs in order to act. There is no list, no marketing stream, and no tracking.
Provider choice is an adapter detail (see
docs/phase-8/research/transactional-email-provider-reassessment.md).
Everything provider-shaped lives in apps/web/src/lib/email/resend.ts; swapping
providers touches that file and the selection in
apps/web/src/lib/email/transactional.ts, and nothing else.
| Thing | Value | Why this one |
|---|---|---|
| Sending domain | mail.example.com |
A dedicated subdomain, so transactional reputation is earned and lost separately from anything the apex domain ever sends. Replace the reserved example domain with the operator's verified domain. |
| From address | Tendnote <notifications@mail.example.com> |
A real named sender, not noreply@. People answer a message about being invited into someone's home. |
| Reply-To | support@example.com |
The monitored inbox, and the same address every household surface shows. Replace it with an operator-owned support mailbox. |
| App origin | <BETTER_AUTH_URL> (for example, https://tendnote.example) |
Where the invitation link points. Set by the configured canonical origin, never by an inbound Host header. |
The apex domain is not touched. Keep the operator's support mailbox and
transactional sender on the intended domains. Inbound routing and outbound
sending do not collide when the dedicated mail.<operator-domain> subdomain is
used for sending.
- Sign up at resend.com. The free tier covers 3,000 emails/month and 100/day, which is far more than private beta needs.
- Go to Domains -> Add Domain.
- Enter
mail.<operator-domain>. Enter the subdomain, not the apex domain. - Pick the region closest to the Vercel deployment. The region is baked into the DNS values Resend gives you, so decide before adding records.
- Resend shows a table of DNS records. Leave that page open; section 2 is how to translate it into Cloudflare.
Open the Cloudflare dashboard, select the operator's zone, and go to DNS -> Records.
The name field is the trap. Cloudflare names records relative to the zone
apex, and Resend names them relative to the domain you registered with it. Resend
will say send; in the operator's zone that must be entered as send.mail.
Get this wrong and verification sits at "pending" forever with no error.
Every record below is DNS only (grey cloud). Cloudflare does not proxy TXT
or MX, but check the toggle anyway if you add a CNAME variant.
| # | Type | Cloudflare Name | Content | Priority | Purpose |
|---|---|---|---|---|---|
| 1 | MX |
send.mail |
the feedback-smtp.<region>.amazonses.com host Resend shows |
10 |
The bounce return path. Without it Resend cannot see bounces and SPF cannot align. |
| 2 | TXT |
send.mail |
v=spf1 include:amazonses.com ~all |
- | SPF for the return path. |
| 3 | TXT |
resend._domainkey.mail |
the long p=MIGfMA0GCSqGSIb3... value Resend shows |
- | DKIM. The dashboard is the authority here - see the note below before you type anything. |
| 4 | TXT |
_dmarc.mail |
v=DMARC1; p=none; rua=mailto:support@example.com |
- | DMARC. Replace the synthetic mailbox with the operator's monitored address. |
Notes on each:
-
Record 1 and 2 must both exist and must both be on
send.mail. Resend's own instructions call this hostsend.<your-domain>; the two records together are what make SPF align with the envelope sender. -
Record 3 is the one to copy rather than transcribe. As documented today, Resend issues DKIM as a single
TXTrecord namedresend._domainkey, which for a subdomain becomesresend._domainkey.mailin the Cloudflare name field. The value is one long string unique to your domain; Cloudflare accepts it whole and splits it internally, so do not add quotes or line breaks by hand.If the Records tab shows you something else, believe the dashboard, not this table. Resend's own API types allow a DKIM record to arrive as either
TXTorCNAME, so the shape is theirs to change and may differ by region or by how the domain was created. Whatever it shows - one record or several,TXTorCNAME- reproduce every DKIM row's type, name, and value exactly, applying the same name rule as everything else here (drop the operator domain suffix, keep the.mail). Verification will not pass on a record you invented. -
Record 4 is optional for Resend's verification but not optional for deliverability. Gmail and Yahoo have required authenticated mail since February 2024, and a domain with no DMARC record is treated worse than one with
p=none. Because DMARC falls back to the organizational domain, a record on_dmarc.maillets the dedicated sending subdomain carry its own policy without changing anything for the operator's apex domain.
Set TTL to Auto (or 300s) while you are setting up. Raise it once the domain verifies and stays verified.
From any machine, after a minute or two:
dig TXT send.mail.<operator-domain> +short
dig MX send.mail.<operator-domain> +short
dig TXT resend._domainkey.mail.<operator-domain> +short
dig TXT _dmarc.mail.<operator-domain> +shortIf the dashboard gave you CNAME DKIM records instead, query those names with
dig CNAME <name>.<operator-domain> +short rather than the TXT line above.
Each should print the value you entered. No output means the record is missing or the name was entered relative to the wrong domain - re-read the name column above.
Then press Verify DNS Records in Resend. The domain should move to
verified. If it does not, the record names are almost always the cause.
Start at p=none and leave it there for at least a week of real sends. Read the
aggregate reports arriving at the operator's support mailbox, confirm every source is
Tendnote, then tighten:
p=none -> p=quarantine; pct=25 -> p=quarantine -> p=reject
Do not start at p=reject. A misconfigured record with a strict policy silently
destroys mail rather than warning you about it.
- In Resend, go to API Keys -> Create API Key.
- Name it for the deployment it belongs to (
tendnote-production,tendnote-preview). - Set permission to Sending access and restrict it to the
mail.<operator-domain>domain. Tendnote never lists domains, reads emails, or manages contacts, so full access is a standing hazard for no benefit. - Copy the key. Resend shows it once.
One key per deployment. A preview deployment sharing production's key can send as production, which is exactly the mistake that burns a sending domain's reputation.
| Variable | Where | Required | Notes |
|---|---|---|---|
RESEND_API_KEY |
Vercel project env, or apps/web/.env.local |
Yes in production | Its presence is what selects the Resend transport. |
TENDNOTE_EMAIL_FROM |
Same | Yes for real sends | Set to Tendnote <notifications@mail.example.com> with the operator's verified domain; the repository default is a reserved example address. |
TENDNOTE_EMAIL_REPLY_TO |
Same | Yes for real sends | Set to the operator's monitored support mailbox; the repository default is synthetic. |
BETTER_AUTH_URL |
Same | Yes in production | The configured canonical HTTPS origin the invitation link is built from. It is never built from a request header. |
In Vercel: Project -> Settings -> Environment Variables. Add
RESEND_API_KEY to Production. Add a separate key to Preview, or leave
Preview without one - a preview deployment with no key refuses to send rather
than mailing real people from a branch.
apps/web/src/lib/email/transactional.ts decides, from the environment alone:
| Environment | RESEND_API_KEY |
Transport |
|---|---|---|
test |
anything | Operator log. The test runner never sends, whatever is in your shell. |
| any | set, with TENDNOTE_EMAIL_REPLY_TO |
Resend. Both explicit operator values are required; otherwise it refuses. |
| not production | unset | Operator log: the message is written to the server log, link and all. |
| production | unset | Refuses, by name, naming the variable and this document. The attempt is recorded failed and the Owner is told delivery did not happen. |
The production refusal is deliberate. Falling back to the operator log there would write a working household invitation into a hosted log, which is a live capability somewhere the recipient's mailbox is not.
Do this once, against an address you control, before anyone else is invited.
Preview the template locally, without sending anything:
pnpm email:devOpen http://localhost:3001. The sidebar should contain
household-invitation; select it to preview the same React Email component used
by the transactional sender. The preview uses a fixed local fixture and never
calls Resend. Port 3001 keeps the preview beside Tendnote's web app on port
3000.
Locally, without sending anything:
cd apps/web && pnpm devSign in, create a household, and invite an address from
Account -> Household. With no RESEND_API_KEY set, the whole message -
subject, body, and the acceptance link - is written to the next dev terminal.
Paste the link into a browser to walk the recipient's side.
Locally, with a real send:
- Add
RESEND_API_KEY=re_...,TENDNOTE_EMAIL_FROM="Tendnote <notifications@mail.<operator-domain>>", andTENDNOTE_EMAIL_REPLY_TO=support@<operator-domain>toapps/web/.env.local, then restartpnpm dev. - Invite an address you own.
- Check the terminal for errors, then check the inbox. A real-send attempt refuses unless all three operator values are configured.
What to check in the message that arrives:
- It landed in the inbox, not in spam.
- The sender reads
Tendnote, from the operator's verifiedmail.<operator-domain>domain. - Replying to it goes to the operator's configured support mailbox.
- The Join button works and lands on
/join/...at the right origin. - The pasteable link below the button is the same URL.
- Open the raw source (Gmail: Show original) and confirm the
Authentication-Resultsheader showsspf=pass,dkim=pass, anddmarc=pass. - Read it in a dark-mode client. It should be near-black under near-white, not an inverted grey.
- Read the plain-text alternative. It should say everything the HTML says and carry the link.
In Resend: the send appears under Emails with a message id. That id is
also stored on the delivery attempt row in household_invitation_deliveries, so
a support question can be traced from Tendnote's side without opening the
dashboard.
For a deliverability score beyond one inbox, send an invitation to a mail-tester.com address and read its report.
- Warm up gently. A brand new sending domain should not jump to volume. Private beta invitation volume is naturally tiny, which is the ideal warm-up.
- Watch bounces and complaints. Keep bounces under 4% and complaints under 0.1%. Resend suppresses hard bounces automatically.
- Resend's event retention is 30 days. Tendnote's own delivery attempt rows are the durable record; a provider dashboard is not the audit log.
- Webhooks are not wired yet. Tendnote records
sentorfailedat the moment of the call and does not yet consumeemail.delivered,email.bounced, oremail.complained. Wiring them is a separate piece of work; until then, a bounce is visible in Resend but not in Tendnote.
- No open or click tracking. The invitation link is a capability. A click-tracking redirect would hand a working household invitation to a third party's URL shortener. Leave Resend's tracking settings off.
- No
List-Unsubscribe. This is a one-off message to an address a person typed, not a list anyone is on. - No retry inside the adapter. The database claim in
dispatchHouseholdInvitationDeliverydecides an attempt happens exactly once; a retry under it would be a provider call nothing authorised. The attempt id is sent as Resend's idempotency key so an ambiguous network failure cannot become two messages. - No provider error text reaches an Owner. Only the failure class is recorded. What Resend knows about a recipient - suppressed, bounced, unknown - is not the Owner's to read.