ehsan.blog
~/blog/how-to-send-email-from-a-serverless-function-with-resend — zsh
cat how-to-send-email-from-a-serverless-function-with-resend.md

How to send email from a serverless function with Resend

·4 min read

When someone fills in my contact form, I want the message to land in my inbox. There’s no mail server to run for that — I use Resend, a transactional email API, from inside my Vercel serverless function. This post walks through the whole send, straight from api/contact.ts. It assumes the request body has already been validated with the Zod schema shared between client and server and passed the in-memory rate limiter.

Step 1: keep secrets in environment variables

The Resend API key is a secret — it must never be hard-coded or shipped to the browser. I read it, and the addresses, from environment variables:

ts
// api/contact.ts
const apiKey = process.env.RESEND_API_KEY
const to = process.env.CONTACT_TO_EMAIL
const from = process.env.CONTACT_FROM_EMAIL ?? "onboarding@resend.dev"

if (!apiKey || !to) {
  console.error(
    "Contact form is misconfigured: missing RESEND_API_KEY or CONTACT_TO_EMAIL",
  )
  return res
    .status(500)
    .json({ ok: false, error: "Server is not configured to send mail." })
}

process.env is how a Node serverless function reads env vars (you set these in your Vercel project settings). The from address falls back to Resend’s shared sandbox sender, onboarding@resend.dev, which is handy before you’ve verified your own domain. If the key or the recipient is missing, I fail fast with a 500 instead of trying to send and crashing.

Step 2: create the client and send

Resend’s SDK is a class you instantiate with the API key, then call emails.send:

ts
// api/contact.ts
const resend = new Resend(apiKey)
const { error } = await resend.emails.send({
  from: `Portfolio Contact <${from}>`,
  to: [to],
  replyTo: email,
  subject: `New portfolio message from ${name}`,
  text: `From: ${name} <${email}>\n\n${message}`,
  html: `
    <div style="font-family: system-ui, sans-serif; line-height: 1.6;">
      <h2 style="margin:0 0 12px;">New portfolio message</h2>
      <p><strong>Name:</strong> ${escapeHtml(name)}</p>
      <p><strong>Email:</strong> ${escapeHtml(email)}</p>
      <p style="white-space: pre-wrap;"><strong>Message:</strong><br/>${escapeHtml(
        message,
      )}</p>
    </div>`,
})

The interesting fields:

  • to: [to] — Resend takes an array of recipients.
  • replyTo: email — this is the trick that makes the form useful. The email arrives from my portfolio sender, but hitting “Reply” addresses the actual person who wrote in.
  • text and html — I provide both a plain-text and an HTML version; clients pick whichever they can render.

Step 3: escape the user’s input

The name, email, and message come from a stranger on the internet, and I’m dropping them into an HTML email. Injecting raw user text into HTML is an injection risk, so I escape it first:

ts
// api/contact.ts
function escapeHtml(input: string): string {
  return input
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;")
}

This converts characters that have special meaning in HTML (<, >, &, quotes) into their harmless entity form, so a message containing <script> is displayed as text rather than executed. The plain-text text field doesn’t need this — text isn’t parsed as markup.

Step 4: handle failures

emails.send returns an object with an error property rather than throwing on a rejected send, so I check it and also wrap everything in a try/catch for unexpected crashes:

ts
// api/contact.ts
if (error) {
  console.error("Resend error:", error)
  return res.status(502).json({
    ok: false,
    error: "Failed to send message. Please email me directly.",
  })
}

return res.status(200).json({ ok: true })

A 502 signals “the upstream mail service failed,” while the surrounding try/catch returns a 500 for anything truly unexpected. On success I return the tidy { ok: true } the client is looking for.

What to remember

  • Read the Resend API key from process.env, never hard-code it; fail fast if it’s missing.
  • Instantiate with new Resend(apiKey) and call resend.emails.send(...).
  • Set replyTo to the sender’s email so replies reach the real person.
  • Escape any user-supplied text before putting it in HTML.
  • Check the returned error and pick sensible status codes (502 upstream, 500 unexpected).

Related reading: mock this send in a test so no real mail goes out — see unit-testing a form and serverless handler with Vitest. The same Resend client also powers the notification step in auto-generating and publishing blog posts with a GitHub Action.

ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.