Skip to main content
Site logo
Back to blog

14 min read

Sending an email is the easy part

How my contact form sends through Resend, answers every visitor in their own language from a signed webhook, retries safely, and tells me when that reply bounces.

  • Engineering
  • Backend

The contact form on this site started life on Formspark: a form action pointing at someone else's endpoint, and an email in my inbox. It worked, and I had no idea what happened between the click and the inbox. When I moved it to Resend, the send itself took about ten lines. Everything interesting came after that: a webhook that answers every visitor with an auto-reply in their own language, a signature check in front of it, retries I can lean on instead of fear, and a notice to me when a visitor mistyped their address and will never hear back.

This post is the whole feature, end to end, and what Resend is doing under the hood at each step.

The flow, end to end

  1. The visitor submits the form. A Next.js server action rate limits, checks a honeypot field and a Cloudflare Turnstile token, validates the fields with Zod, and checks that the email's domain can receive mail at all.

  2. The action sends one email, to me, rendered from a published Resend template. The visitor's address rides along as the Reply-To header, and the email carries a few tags.

  3. Resend delivers it and fires an email.delivered webhook at /api/send-email-back.

  4. That route verifies the signature, checks the tags, fetches the original email back from the API to recover the visitor's address, and sends the auto-reply.

  5. If the auto-reply bounces or gets marked as spam, the same route receives email.bounced or email.complained and sends me a notice.

The visitor sees none of steps 3 to 5. They see a success message, and a few seconds later an email in their inbox.

The Resend Emails list showing the contact notification and the visitor auto-reply, both with the Delivered status; recipient addresses are blurred.

Why the DX holds up

A few things about Resend made this pleasant to build, and they show up all over the code.

The Node SDK does not throw on API errors. Every call returns { data, error }, so every call is a branch you have to write. That sounds like ceremony until you notice each branch maps cleanly onto an outcome worth recording. In this codebase, every one of them increments a labeled counter.

The webhook payload is a typed discriminated union, WebhookEventPayload, keyed on type. Narrowing to exactly the events I care about is a one line type guard, and one more check on event.type gets you event.data.bounce:

function isUndeliverableEmailEvent(
  event: WebhookEventPayload
): event is Extract<WebhookEventPayload, { type: 'email.bounced' | 'email.complained' }> {
  return event.type === 'email.bounced' || event.type === 'email.complained'
}

Signature verification ships inside the SDK as resend.webhooks.verify(), so there is no second dependency to install and keep in sync. Idempotency is a second argument to send(), not a header you have to remember the name of. Templates have stable aliases, so the code references portfolio-contact and the copy can change in the dashboard without a deploy. And there are magic test addresses, delivered@resend.dev, bounced@resend.dev and complained@resend.dev, that simulate each outcome without touching a real mailbox or your sender reputation.

The docs also publish an llms.txt index and there is a hosted MCP server, which matters more than it used to. More on that below.

Sending: templates, tags and Reply-To

This is the send, trimmed of logging:

const contactEmail = {
  from: CONTACT_FROM_EMAIL,
  to: CONTACT_INBOX_TO,
  replyTo: email,
  subject: `Portfolio contact from ${name}`,
  tags: [
    { name: 'source', value: 'contact_form' },
    { name: 'channel', value: 'portfolio' },
    { name: 'locale', value: locale },
  ],
  template: {
    id: CONTACT_TEMPLATE_ID, // 'portfolio-contact'
    variables: buildContactTemplateVariables(notification),
  },
}

const response = await resend.emails.send(contactEmail)
if (response.error) {
  // provider_error: tell the visitor, keep their input
}

Every field here is doing a job for a later step.

Tags are key and value pairs that Resend stores with the email and sends back on every webhook event about it. They are the routing primitive for this whole feature. source tells the webhook which emails are contact notifications, and locale carries the language the visitor browsed the site in, so the auto-reply can answer in it. Without tags I would need a database just to remember which email was which.

Reply-To is where the visitor's address lives. It lets me hit reply in my own client, and it is the only field the webhook can read it back from later.

The subject has a fixed shape on purpose. The auto-reply greets people by first name, and the subject is where the webhook recovers it from. It is set in code rather than left to the template's default, so a dashboard edit cannot break the parsing.

The template is a published Resend template referenced by alias. Resend substitutes triple mustache variables like {{{name}}} verbatim, with no escaping on its side. That means escaping is my job: buildContactTemplateVariables HTML escapes everything the visitor typed before it leaves the server. It also means every visitor supplied value goes out twice, once escaped for the HTML part and once raw for the plain text part, because O&#39;Brien is correct in one and wrong in the other. One more thing the docs will save you from: EMAIL is a reserved variable name, which is why mine is called senderEmail.

Detail page of the contact notification in Resend: the Reply-To header, the tags source contact_form, channel portfolio and locale en, and Sent and Delivered events; addresses and the email id are blurred.The portfolio-contact template in the Resend editor with the Variables panel open: localeLabel, messageHtml, messageText, name, nameText, preheader, replyUrl, senderEmail, senderEmailText and submittedAt, next to the rendered layout.

Before the send: can this domain receive mail?

Zod proves an address is well formed. It does not prove anyone can receive mail there, and every hard bounce off my domain costs sender reputation. So before sending, the action asks DNS:

async function acceptsMail(domain: string) {
  try {
    const mxRecords = await resolveMx(domain)
    if (mxRecords.length > 0) return true
  } catch (error) {
    if (!meansNoRecords(error)) return true // resolver trouble: fail open
  }

  // RFC 5321 implicit MX: no MX record, but mail still goes to the A record
  try {
    const addresses = await resolve4(domain)
    return addresses.length > 0
  } catch (error) {
    return !meansNoRecords(error)
  }
}

Two details matter. Only ENOTFOUND, ENODATA and NOTFOUND count as "no records". A SERVFAIL or a timeout is the resolver's problem, not the visitor's, so the check fails open: a DNS hiccup must never cost a real message. And a domain with no MX record still accepts mail at its A record, per RFC 5321, which is how plenty of small custom domains are configured. Results are cached in memory, an hour for a positive answer and five minutes for a negative one, since an unreachable domain may be one DNS propagation away from working.

This catches joao@gmail.con. It cannot catch jaoo@gmail.com, a real domain with a mailbox that does not exist, and that one only shows up later, as a webhook. It also lets through a typo domain somebody has registered, like gnail.com, whose DNS answers just fine. For that one the form relies on its client side "did you mean" hint, which suggests gmail.com when the visitor leaves the field.

The webhook: what arrives and how it is checked

The endpoint is registered in the Resend dashboard and subscribed to three events: email.delivered, email.bounced and email.complained.

The Resend webhook endpoint for /api/send-email-back, listening for email.delivered, email.bounced and email.complained, with the signing secret masked and a delivery log of successful 200 responses.

Resend delivers webhooks through Svix, and every request carries three headers: svix-id, svix-timestamp and svix-signature. The signature is an HMAC over the id, the timestamp and the raw body, keyed with the endpoint's signing secret. Because the timestamp is part of what is signed, and the verifier rejects timestamps too far from the current time, a captured request cannot be replayed later.

The raw body part is the classic trap. The signature is computed over the exact bytes Resend sent, so the handler reads request.text(), never request.json(). Parse and re-serialize the JSON and the bytes change, and the signature no longer matches:

const payload = await request.text()

try {
  event = resend.webhooks.verify({
    payload,
    webhookSecret,
    headers: { id: svixId, timestamp: svixTimestamp, signature: svixSignature },
  })
} catch {
  return NextResponse.json({ error: 'Invalid webhook signature' }, { status: 401 })
}

Missing headers get a 400 before verification is even attempted. A bad signature gets a 401. Nothing else in the handler runs for either.

Why delivered, and not sent

Resend emits email.sent when the API accepts an email and email.delivered when the recipient's mail server accepts it. The auto-reply says "I received your message". Triggering on delivered makes that sentence literally true: the reply only goes out once my copy has actually reached my mail server.

Routing on tags

Once verified, the event is routed by type, then by the source tag. Bounces and complaints go to their own branch. Anything that is not a delivery gets a 200 and is ignored. A delivery whose source is not contact_form, such as the auto-reply's own delivery event, also gets a 200 and is ignored. That check is what stops the auto-reply from triggering an auto-reply to itself.

Recovering the visitor from the original email

Webhook payloads carry metadata, not the full email. The delivery event has the email's id, but not its Reply-To. So the handler fetches the original email back from the API:

const original = await resend.emails.get(event.data.email_id)
const visitorEmail = original.data?.reply_to?.[0]

const visitorName =
  original.data?.subject
    ?.replace(/^Portfolio contact from /, '')
    .replace(/[<>]/g, '')
    .trim()
    .split(/\s+/)[0]
    ?.slice(0, 100) || 'there'

The name comes out of the subject line: strip the fixed prefix, keep the first word, cap the length. The angle brackets are stripped because the auto-reply template renders {{{name}}} verbatim, and the name is text a stranger typed into a form.

Choosing the reply

The locale tag picks the language, and a fallback chain picks the body:

const templateId =
  (visitorLocale === 'pt' ? process.env.RESEND_AUTO_REPLY_TEMPLATE_ID_PT : undefined) ??
  process.env.RESEND_AUTO_REPLY_TEMPLATE_ID

await resend.emails.send(
  templateId
    ? { ...base, template: { id: templateId, variables: { name: visitorName } } }
    : { ...base, subject, html }, // inline copy in the visitor's language
  { idempotencyKey: `contact-form-auto-reply-${event.data.email_id}` }
)

A Portuguese visitor gets the Portuguese template if one is configured, then the default template, then inline HTML written in Portuguese. The SDK's types make template and html mutually exclusive, so a send can never accidentally carry both. The inline copy is not a placeholder: it is what keeps the auto-reply working if a template id is unset or a template is deleted by mistake.

The auto-reply as a visitor receives it in Gmail, in English: "Hi Joao," followed by a short confirmation that the message landed in the inbox, signed João Victor.

Retries, and why they are safe

A webhook handler's status code is a contract. A 2xx tells Resend "done". Anything else tells Resend "try again", on this schedule after each failure: 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and 10 hours more. If the endpoint keeps failing, Resend emails the team and eventually disables it.

My handler uses that contract on purpose. If fetching the original email fails, or the auto-reply send fails, it returns a 502, and I get a free retry with backoff instead of writing my own queue.

Retries are only safe if repeating the work is harmless, and this is where the idempotency key comes in. The auto-reply is keyed on the original email's id. If a send succeeds but the response is lost on the way back and Resend retries, the second send with the same key returns the original response instead of emailing the visitor twice. Resend keeps keys for 24 hours, and the longest gap between two consecutive retries is 10 hours, so the key always outlives the gap between an attempt that sent and the next one.

The bounce notice runs the opposite policy. If the notice to me fails, the handler logs it and still returns a 200. A retry would redeliver the event and count the bounce twice in my metrics, and a missing notice is recoverable from the logs. A double counted one quietly corrupts the numbers.

The webhook delivery log in Resend: an email.bounced event answered with HTTP 200 and the body received true, notified true, one attempt, with the Replay button in view.

When the reply bounces

Back to jaoo@gmail.com. It passes the DNS check, because gmail.com is real. The visitor sees the same success message as everyone else. My copy delivers fine, because it went to me. The only email ever addressed to the mistyped mailbox is the auto-reply, which makes its bounce the only proof that something went wrong.

So email.bounced and email.complained on the auto-reply are treated as one class of event: an address the form accepted was wrong, and that visitor is waiting on an answer that will never arrive. The handler sends me a notice with the recipient, the provider's bounce message and the original email id, keyed for idempotency on the event type and the email id. The notice is tagged with a third source value that the bounce branch does not listen to, so a notice that itself bounces cannot trigger another notice.

Resend also adds hard bounced and complaining addresses to a suppression list automatically. A later send to a suppressed address is not attempted at all; Resend fires email.suppressed instead. That protects the domain's reputation even if my own code got this wrong.

The whole branch is testable without a real bounce: send to bounced@resend.dev or complained@resend.dev and the real events arrive at the real endpoint. The test send has to carry a source tag the branch listens for, such as source: contact_form_auto_reply, because a bounce from any source other than the contact form is ignored on purpose.

Resend detail page for a test email to bounced@resend.dev tagged source contact_form_auto_reply: the Bounced status, the reason "Recipient not found", and the Sent and Bounced events.

Every branch is a number

Because the SDK returns errors instead of throwing, every outcome is an explicit branch, and every branch increments contact_auto_reply_total with a stage label: success, invalid_signature, ignored_non_contact_source, failed_fetch_original_email, bounced, complained, and so on. Every Resend call is wrapped in a tracing span. On a quiet portfolio this is overkill in the best way: when an auto-reply does not arrive, the answer is a query, not a guess.

Building the templates through the Resend MCP server

Resend hosts an MCP server at https://mcp.resend.com/mcp. You connect a client to it, log in through OAuth in the browser, and the agent gets tools for the full API: emails, domains, templates, webhooks. No API key ends up in the agent's config.

I built the email templates for this feature from Claude Code through that server rather than in the dashboard. Being able to say "create this template, declare these variables, publish it" in the same session where the sending code is being written removes a lot of tab switching, and the agent can read the template's declared variables straight from the API instead of me copying them into the code by hand.

It was not frictionless, and the rough edge is real. Templates composed through the visual editor format (compose-template) render their HTML asynchronously on Resend's side. Until that render lands, publish-template fails with a 422 "Template HTML is required". I saw it take anywhere from two minutes to over half an hour. Two ways around it: open the template in the dashboard editor, which forces the render, or skip the visual format and create the template with a raw html field, which publishes synchronously. portfolio-contact was built the second way. The tradeoff is that a raw HTML template is not visually editable in the dashboard afterwards.

Even the bots get confetti

When the server action returns success, the form mounts a small client component that fires two confetti cannons from the edges of the screen for three seconds, in the site's own colors read live from its CSS custom properties. canvas-confetti is imported dynamically inside that success branch, so visitors who never submit the form never download it, and it stands down entirely under prefers-reduced-motion.

The honeypot branch of the server action returns the exact same success response as a real send, so a bot never learns it was caught. Which means the bot gets the confetti too. If a bot ever fills in a field called website, it is thrown a three second celebration, and then it waits for an auto-reply that never comes.

References