Skip to main content
MigrationVal Town

Migrate Val Town email

Send and receive email through a provider such as Resend.

An email provider sends and receives the mail, and Easel runs your code. The examples use Resend. @valtown.email addresses don't move, so tell anyone who sends to them about the new address.

Send email

Verify a domain with Resend, install resend, and add RESEND_API_KEY and MAIL_FROM to the project's environment variables. Then replace std/email:

lib/mail.ts
import { Resend } from "resend";

// Created on first use, so routes that don't send mail still work
// in an environment without RESEND_API_KEY.
let client: Resend | undefined;
export function getResend() {
  client ??= new Resend(process.env.RESEND_API_KEY);
  return client;
}

export async function sendMail(
  message: { to: string; subject: string; text: string; html?: string; replyTo?: string },
  idempotencyKey: string,
) {
  const from = process.env.MAIL_FROM;
  if (!from) throw new Error("MAIL_FROM is required");

  const { data, error } = await getResend().emails.send({ from, ...message }, { idempotencyKey });
  if (error) throw new Error(`The email provider rejected the message: ${error.message}`);
  return data?.id;
}

Pass an idempotency key that stays the same if the code sends the message again, such as digest-${date}, so a retry doesn't deliver it twice.

Receive email

Resend sends a webhook for each received email. Add a receiving address in Resend, add its signing secret as RESEND_WEBHOOK_SECRET, and add this handler, which verifies the webhook, checks the sender, and calls the val's email handler:

lib/email-webhook.ts
lib/email-webhook.ts
import { getDb } from "./db.js";
import { getResend } from "./mail.js";
import { handleEmail } from "./handle-email.js";

// Resend's results from its own SPF, DKIM, and DMARC checks. The field is
// in the API response but not yet in the SDK's types.
type Authentication = Record<"spf" | "dkim" | "dmarc", string> | null | undefined;

const TRUSTED_SENDERS = ["casey@example.com"];

export async function handleEmailWebhook(request: Request): Promise<Response> {
  const secret = process.env.RESEND_WEBHOOK_SECRET;
  if (!secret) return new Response("Not configured", { status: 503 });

  const resend = getResend();
  let event;
  try {
    event = resend.webhooks.verify({
      payload: await request.text(),
      headers: {
        id: request.headers.get("svix-id") ?? "",
        timestamp: request.headers.get("svix-timestamp") ?? "",
        signature: request.headers.get("svix-signature") ?? "",
      },
      webhookSecret: secret,
    });
  } catch {
    return new Response("Invalid signature", { status: 401 });
  }
  if (event.type !== "email.received") return new Response(null, { status: 204 });

  const emailId = event.data.email_id;
  const seen = await getDb().execute({
    sql: "SELECT 1 FROM processed_emails WHERE email_id = ?",
    args: [emailId],
  });
  if (seen.rows.length > 0) return new Response(null, { status: 204 });

  const { data: email, error } = await resend.emails.receiving.get(emailId);
  // A 5xx makes Resend retry the webhook later.
  if (error || !email) return new Response("Couldn't fetch the email", { status: 502 });

  // Act only on mail that passed DMARC from a sender you trust.
  const auth = (email as { authentication?: Authentication }).authentication;
  const sender = email.from.match(/<([^>]+)>/)?.[1] ?? email.from;
  if (auth?.dmarc !== "pass" || !TRUSTED_SENDERS.includes(sender.toLowerCase())) {
    return new Response(null, { status: 204 });
  }

  const attachments: File[] = [];
  let after: string | undefined;
  for (;;) {
    const page = await resend.emails.receiving.attachments.list({ emailId, limit: 100, after });
    if (page.error || !page.data) return new Response("Couldn't list attachments", { status: 502 });
    for (const item of page.data.data) {
      const file = await fetch(item.download_url);
      if (!file.ok) return new Response("Couldn't download an attachment", { status: 502 });
      attachments.push(
        new File([await file.arrayBuffer()], item.filename ?? "attachment", {
          type: item.content_type,
        }),
      );
    }
    if (!page.data.has_more) break;
    after = page.data.data.at(-1)?.id;
  }

  await handleEmail({
    emailId,
    email: {
      from: email.from,
      to: email.to,
      cc: email.cc ?? undefined,
      bcc: email.bcc ?? undefined,
      subject: email.subject,
      text: email.text ?? undefined,
      html: email.html ?? undefined,
      attachments,
      headers: email.headers ?? {},
    },
  });
  return new Response(null, { status: 204 });
}

Route a URL to it, then point Resend's email.received webhook at that URL on your production domain. Without Hono, export POST = handleEmailWebhook from api/incoming-email.ts instead.

src/index.ts
import { handleEmailWebhook } from "../lib/email-webhook.js";

app.post("/email/incoming", (c) => handleEmailWebhook(c.req.raw));

Resend retries webhooks, so the same email can arrive twice. Have the handler record each email's ID in the same database transaction as its other writes, and use the ID as the idempotency key for replies:

lib/handle-email.ts
lib/handle-email.ts
import { getDb } from "./db.js";
import { sendMail } from "./mail.js";

export type Email = {
  from: string;
  to: string[];
  cc: string | string[] | undefined;
  bcc: string | string[] | undefined;
  subject: string | undefined;
  text: string | undefined;
  html: string | undefined;
  attachments: File[];
  headers: Record<string, string>;
};

export async function handleEmail({ emailId, email }: { emailId: string; email: Email }) {
  const url = email.text?.match(/https?:\/\/\S+/)?.[0];
  if (!url) return;

  // Safe to repeat: Resend sends one email per key within 24 hours.
  await sendMail(
    { to: email.from, subject: `Saved: ${email.subject ?? url}`, text: url },
    `reply-${emailId}`,
  );

  // One transaction: the link and the processed marker are written together,
  // or not at all. A repeat delivery fails on the primary key.
  try {
    await getDb().batch(
      [
        { sql: "INSERT INTO processed_emails (email_id) VALUES (?)", args: [emailId] },
        {
          sql: "INSERT INTO links (id, url, title, created_at) VALUES (?, ?, ?, ?)",
          args: [crypto.randomUUID(), url, email.subject ?? url, Date.now()],
        },
      ],
      "write",
    );
  } catch (error) {
    if (String(error).includes("UNIQUE constraint failed: processed_emails")) return;
    throw error;
  }
}
CREATE TABLE processed_emails (email_id TEXT PRIMARY KEY);

Verify

Send an email to the new address from a trusted sender and check that the handler ran once. Then resend the same webhook event from Resend and check that nothing happens twice.

Back to the feature list.