Skip to content

Search docs

Jump between documentation pages.

Browse docs

Send email from DaloyJS with AWS SES

Amazon Simple Email Service (SES) is AWS's pay-as-you-go transactional and bulk email service. This guide uses SESv2, the current API, through the AWS SDK for JavaScript v3. Best fit when you already run on AWS (Lambda, ECS, Fargate, EC2) or need low per-message cost. Read the email overview first: it defines the shared EmailSender, emailPlugin(), headerSafe, escapeHtml(), and the email.send / email.suppress jobs used below.

Send through SESv2, then track events
Route handlerSESv2ClientAmazon SESSNS / EventBridge
  1. 01requestRoute handlerSESv2Clientsend(SendEmailCommand)FromEmailAddress, Destination, Content.Simple
  2. 02requestSESv2ClientAmazon SESSigned HTTPS call (AWS SigV4)ses:SendEmail via IAM role or keys
  3. 03responseAmazon SESRoute handlerAcceptedout.MessageId returned as { id }
  4. 04asyncAmazon SESSNS / EventBridgeDelivery, bounce, complaint eventsvia a configuration set (ConfigurationSetName)
The route handler sends through the SESv2Client, which signs the request and calls Amazon SES. SES returns a MessageId synchronously, then publishes delivery and bounce events asynchronously when you attach a configuration set.

1. Provision

  1. In the AWS console, open Amazon SES -> Verified identities and verify your sending domain. Publish the DKIM records SES shows, and set up a custom MAIL FROM domain so SPF aligns with your From: domain for DMARC (see deliverability).
  2. New accounts start in the SES sandbox: you can only send to verified addresses. Request production access from Account dashboard before launching.
  3. Create a configuration set (for example transactional) with an event destination for bounces and complaints. That is what feeds the webhook below.
  4. Give the sending principal only ses:SendEmail, on the named identity and configuration set, and pin the From address with the ses:FromAddress condition key. Prefer an execution role on Lambda or ECS over long-lived access keys.
ses-send-policy.jsonjson
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["ses:SendEmail"],
      "Resource": [
        "arn:aws:ses:eu-west-1:123456789012:identity/mail.acme.example.com",
        "arn:aws:ses:eu-west-1:123456789012:configuration-set/transactional"
      ],
      "Condition": {
        "StringEquals": { "ses:FromAddress": "no-reply@mail.acme.example.com" }
      }
    }
  ]
}

SES also offers ses:Recipients and ses:FromDisplayName condition keys; see Identity and access management in Amazon SES.

2. Install

bash
pnpm add @aws-sdk/client-sesv2

The v3 SDK is modular; install only the SESv2 client. It works with the Lambda adapter.

3. Environment variables

.envdotenv
AWS_REGION=eu-west-1
EMAIL_FROM="Acme <no-reply@mail.acme.example.com>"
CONTACT_INBOX=support@acme.example.com
SES_CONFIGURATION_SET=transactional
SES_EVENTS_TOPIC_ARN=arn:aws:sns:eu-west-1:123456789012:ses-events

# Local development only. In AWS, the execution role supplies credentials.
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...

Add the SES keys to the overview's defineConfig() schema: AWS_REGION: z.string().min(1), SES_CONFIGURATION_SET: z.string().min(1), and SES_EVENTS_TOPIC_ARN: z.string().startsWith("arn:aws:sns:").

4. Sender

The SDK reads credentials from the standard AWS provider chain (env vars, shared config file, IMDS, IRSA on EKS, Lambda role), so the client itself takes no secrets.

src/email/ses.tsts
import { SESv2Client, SendEmailCommand, type SendEmailCommandInput } from "@aws-sdk/client-sesv2";
import type { EmailSender } from "../plugins/email.ts";
import { config } from "../config.ts";

export const sesClient = new SESv2Client({ region: config.AWS_REGION });

export const sender: EmailSender = {
  async send(msg) {
    const input: SendEmailCommandInput = {
      FromEmailAddress: config.EMAIL_FROM,
      Destination: { ToAddresses: [msg.to] },
      ReplyToAddresses: msg.replyTo ? [msg.replyTo] : undefined,
      ConfigurationSetName: config.SES_CONFIGURATION_SET,
      Content: {
        Simple: {
          Subject: { Data: msg.subject, Charset: "UTF-8" },
          Body: {
            ...(msg.text ? { Text: { Data: msg.text, Charset: "UTF-8" } } : {}),
            ...(msg.html ? { Html: { Data: msg.html, Charset: "UTF-8" } } : {}),
          },
          Headers: msg.headers
            ? Object.entries(msg.headers).map(([Name, Value]) => ({ Name, Value }))
            : undefined,
        },
      },
    };
    // SES has no idempotency key: dedupe happens in the job queue.
    const out = await sesClient.send(new SendEmailCommand(input), { abortSignal: msg.signal });
    return { id: out.MessageId ?? "" };
  },
};
src/app.tsts
import { App, secureHeaders } from "@daloyjs/core";
import { emailPlugin } from "./plugins/email.ts";
import { emailJobs } from "./email/jobs.ts";
import { sender, sesClient } from "./email/ses.ts";

export const app = new App();
app.use(secureHeaders());
emailPlugin(app, sender);
emailJobs(app, sender);
app.onClose(() => sesClient.destroy());

5. Use it in a route

A signed-in user asks for their welcome email again. The recipient and content come from your database, not from the request, and the actual send runs in the email.send job:

src/routes/welcome.tsts
import { z } from "zod";
import { App, UnauthorizedError, jobIdempotencyKey } from "@daloyjs/core";
import { escapeHtml } from "../plugins/email.ts";
import { sendGuards } from "../email/guards.ts";

export function welcomeRoutes(app: App) {
  app.post(
    "/me/welcome-email",
    {
      operationId: "resendWelcomeEmail",
      hooks: sendGuards, // auth + per-IP + per-user limits, see the overview
      responses: {
        202: { description: "Queued", body: z.object({ jobId: z.string() }) },
      },
    },
    async ({ state }) => {
      const sub = state.user?.sub;
      if (!sub) throw new UnauthorizedError();
      const user = await loadUser(sub); // server-side lookup: the client never picks `to`
      const day = new Date().toISOString().slice(0, 10);
      const { job } = await app.jobs!.enqueue({
        name: "email.send",
        payload: {
          to: user.email,
          subject: "Welcome to Acme",
          text: `Hi ${user.name}, thanks for signing up.`,
          html: `<p>Hi ${escapeHtml(user.name)}, thanks for signing up.</p>`,
        },
        // At most one resend per user per day, however often the button is clicked.
        idempotencyKey: jobIdempotencyKey({ name: "email.send", key: `welcome-${user.id}-${day}` }),
      });
      return { status: 202, body: { jobId: job.id } };
    },
  );
}

declare function loadUser(id: string): Promise<{ id: string; email: string; name: string }>;

Templates and attachments

SESv2's SendEmailCommand accepts three content variants:

  • Content.Simple: subject, text/HTML body, optional Headers, and (since April 2025) an Attachments array. Each attachment takes FileName, ContentType, ContentDisposition ("ATTACHMENT" or "INLINE"), and the file bytes as RawContent (a Uint8Array; the SDK encodes it). You no longer need to build MIME yourself for a PDF receipt:
ts
Content: {
  Simple: {
    Subject: { Data: "Your invoice", Charset: "UTF-8" },
    Body: { Text: { Data: "Invoice attached.", Charset: "UTF-8" } },
    Attachments: [
      {
        FileName: "invoice-1024.pdf",
        ContentType: "application/pdf",
        ContentDisposition: "ATTACHMENT",
        RawContent: pdfBytes, // Uint8Array
      },
    ],
  },
},
  • Content.Raw.Data: a fully MIME-encoded message, for the rare case Simple cannot express. Build it with Nodemailer's MailComposer (nodemailer/lib/mail-composer) and keep Nodemailer current.
  • Content.Template: render an SES template by TemplateName with a JSON TemplateData payload. Create templates ahead of time with CreateEmailTemplateCommand. Check how your template syntax treats HTML in variables; if you are not sure it escapes, escape user values before they go into TemplateData (see escaping).

Webhooks: bounces and complaints

SES publishes events from the configuration set to an event destination. You have two options:

  • No public endpoint (preferred on AWS): subscribe an SQS queue to the SNS topic (or route an EventBridge event destination to SQS) and consume it from a Lambda or a worker. IAM does the authentication, so there is no signature code to get wrong.
  • An HTTPS endpoint on your API: subscribe the route below to the SNS topic. You must verify the SNS message signature yourself, confirm the subscription, and pin the topic.

Set the topic's SignatureVersion attribute to 2 so SNS signs with SHA256, then verify like this:

src/email/sns.tsts
import { createVerify } from "node:crypto";
import { fetchGuard } from "@daloyjs/core";

export interface SnsEnvelope {
  Type: "Notification" | "SubscriptionConfirmation" | "UnsubscribeConfirmation";
  MessageId: string;
  TopicArn: string;
  Message: string;
  Timestamp: string;
  SignatureVersion: string;
  Signature: string;
  SigningCertURL: string;
  Subject?: string;
  Token?: string;
  SubscribeURL?: string;
}

const AWS_SNS_HOST = /^sns\.[a-z0-9-]+\.amazonaws\.com(\.cn)?$/;
export const snsFetch = fetchGuard(); // SSRF floor for every outbound call below
const certs = new Map<string, string>(); // keyed by an AWS-only URL, so it stays small

/** True only for an https URL on an SNS host, before anything is fetched. */
export function isSnsUrl(raw: string | undefined): raw is string {
  if (!raw) return false;
  try {
    const url = new URL(raw);
    return url.protocol === "https:" && AWS_SNS_HOST.test(url.hostname);
  } catch {
    return false;
  }
}

function stringToSign(m: SnsEnvelope): string {
  const keys =
    m.Type === "Notification"
      ? (["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"] as const)
      : (["Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type"] as const);
  let out = "";
  for (const key of keys) {
    const value = m[key];
    if (value !== undefined) out += `${key}\n${value}\n`;
  }
  return out;
}

export async function verifySnsMessage(m: SnsEnvelope): Promise<boolean> {
  if (m.SignatureVersion !== "2") return false; // refuse SHA1 signatures
  if (!isSnsUrl(m.SigningCertURL)) return false; // check the host BEFORE fetching
  let pem = certs.get(m.SigningCertURL);
  if (!pem) {
    const res = await snsFetch(m.SigningCertURL);
    if (!res.ok) return false;
    pem = await res.text();
    certs.set(m.SigningCertURL, pem);
  }
  return createVerify("RSA-SHA256").update(stringToSign(m), "utf8").verify(pem, m.Signature, "base64");
}
src/routes/ses-webhook.tsts
import { z } from "zod";
import { App, UnauthorizedError, jobIdempotencyKey, readBodyLimited, safeJsonParse } from "@daloyjs/core";
import { isSnsUrl, snsFetch, verifySnsMessage, type SnsEnvelope } from "../email/sns.ts";
import { config } from "../config.ts";

interface SesEvent {
  eventType: string;
  bounce?: { bounceType: string; bouncedRecipients: { emailAddress: string }[] };
  complaint?: { complainedRecipients: { emailAddress: string }[] };
}

export function sesWebhookRoutes(app: App) {
  app.post(
    "/webhooks/ses",
    {
      operationId: "sesEvents",
      // No body schema: SNS posts text/plain and the signature covers exact fields.
      responses: {
        200: { description: "Accepted", body: z.object({ ok: z.literal(true) }) },
        401: { description: "Rejected" },
      },
    },
    async ({ request }) => {
      const raw = new TextDecoder().decode(await readBodyLimited(request, 256 * 1024));
      const msg = safeJsonParse(raw) as SnsEnvelope | undefined;
      if (!msg || typeof msg !== "object" || !(await verifySnsMessage(msg))) {
        throw new UnauthorizedError("invalid SNS signature");
      }
      if (msg.TopicArn !== config.SES_EVENTS_TOPIC_ARN) throw new UnauthorizedError("unexpected topic");

      if (msg.Type === "SubscriptionConfirmation") {
        if (!isSnsUrl(msg.SubscribeURL)) throw new UnauthorizedError("bad SubscribeURL");
        await snsFetch(msg.SubscribeURL); // confirms the subscription
        return { status: 200, body: { ok: true as const } };
      }
      if (msg.Type !== "Notification") return { status: 200, body: { ok: true as const } };

      const event = safeJsonParse(msg.Message) as SesEvent;
      const hard =
        event.eventType === "Bounce" && event.bounce?.bounceType === "Permanent"
          ? event.bounce.bouncedRecipients
          : event.eventType === "Complaint"
            ? (event.complaint?.complainedRecipients ?? [])
            : [];
      for (const [i, r] of hard.entries()) {
        await app.jobs!.enqueue({
          name: "email.suppress",
          payload: { email: r.emailAddress, reason: event.eventType.toLowerCase() },
          // SNS retries reuse MessageId, so redeliveries collapse into one job.
          idempotencyKey: jobIdempotencyKey({ name: "email.suppress", key: `ses-${msg.MessageId}-${i}` }),
        });
      }
      return { status: 200, body: { ok: true as const } };
    },
  );
}

Suppression

  • Turn on the account-level suppression list for both BOUNCE and COMPLAINT reasons (SES console Configuration -> Suppression list, or PutAccountSuppressionAttributesCommand). SES then drops sends to those addresses instead of hurting your reputation.
  • A configuration set can override the account setting, which is useful when marketing and transactional mail need different rules.
  • Add or remove addresses yourself with PutSuppressedDestinationCommand / DeleteSuppressedDestinationCommand (reason "BOUNCE" or "COMPLAINT"), for example when a user fixes a typo in their address.
  • Keep your own undeliverable flag too (the email.suppress job), so the app stops at enqueue time.

Runtimes

  • Node / Bun / Deno / AWS Lambda: works without extra config. On Lambda, omit access keys and let the execution role supply credentials.
  • Cloudflare Workers: the default credential chain expects Node APIs, and we have not verified the full SDK on Workers. The small, documented path is aws4fetch, which signs requests with Web Crypto, against the SESv2 REST endpoint. Set retries: 0: aws4fetch retries by default, and a retried POST can send the email twice.
src/email/ses-workers.tsts
import { AwsClient } from "aws4fetch";
import { env } from "cloudflare:workers";
import type { EmailSender } from "../plugins/email.ts";

const aws = new AwsClient({
  accessKeyId: env.AWS_ACCESS_KEY_ID,
  secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
  service: "ses",
  region: env.AWS_REGION,
  retries: 0, // never auto-retry a send
});

export const sender: EmailSender = {
  async send(msg) {
    const res = await aws.fetch(`https://email.${env.AWS_REGION}.amazonaws.com/v2/email/outbound-emails`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      signal: msg.signal,
      body: JSON.stringify({
        FromEmailAddress: env.EMAIL_FROM,
        Destination: { ToAddresses: [msg.to] },
        ReplyToAddresses: msg.replyTo ? [msg.replyTo] : undefined,
        Content: {
          Simple: {
            Subject: { Data: msg.subject },
            Body: {
              ...(msg.text ? { Text: { Data: msg.text } } : {}),
              ...(msg.html ? { Html: { Data: msg.html } } : {}),
            },
          },
        },
      }),
    });
    if (!res.ok) throw new Error(`SES send failed with status ${res.status}`);
    const out = (await res.json()) as { MessageId?: string };
    return { id: out.MessageId ?? "" };
  },
};

Give that Worker an IAM user whose only permission is the scoped ses:SendEmail policy above, and store its keys as Worker secrets.

Observability

Configuration-set events cover sends, deliveries, bounces, complaints, rejects, and (if you enable tracking) opens and clicks. Publish them to SNS, EventBridge, CloudWatch, or Kinesis Data Firehose. Add ConfigurationSetName to every send so nothing goes untracked.

See also Resend, SendGrid, and the email integrations overview.

Edit this page on GitHub

Last updated