# Send email from DaloyJS with AWS SES

[Amazon Simple Email Service](https://aws.amazon.com/ses/) (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](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/sesv2/). Best fit when you already run on AWS (Lambda, ECS, Fargate, EC2) or need low per-message cost.

**Diagram: Send through SESv2, then track events**

Participants: Route handler, SESv2Client, Amazon SES, SNS / EventBridge

1. **Route handler -> SESv2Client** (request) - send(SendEmailCommand) - FromEmailAddress, Destination, Content.Simple
2. **SESv2Client -> Amazon SES** (request) - Signed HTTPS call (AWS SigV4) - ses:SendEmail via IAM role or keys
3. **Amazon SES -> Route handler** (response) - Accepted - out.MessageId returned as { id }
4. **Amazon SES -> SNS / EventBridge** (async) - Delivery, bounce, complaint events - via 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 either an email address (for development) or your sending domain (for production). Add the SPF, DKIM, and DMARC records SES shows you.
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 an IAM principal that can call `ses:SendEmail`. Prefer an **execution role** attached to your Lambda or container, avoid long-lived access keys.

## 2. Install

```ts
pnpm add @aws-sdk/client-sesv2
```

The v3 SDK is modular, install only the SESv2 client. It works on Node 18+ and is compatible with the [Lambda adapter](/docs/adapters).

## 3. Environment variables

```ts
# .env
AWS_REGION=us-east-1
SES_FROM_ADDRESS="Acme <no-reply@acme.example.com>"

# Local development only - in AWS, prefer the execution role
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
```

## 4. Plugin

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.

```ts
// src/plugins/ses.ts
import {
  SESv2Client,
  SendEmailCommand,
  type SendEmailCommandInput,
} from "@aws-sdk/client-sesv2";
import type { App } from "@daloyjs/core";

const client = new SESv2Client({ region: process.env.AWS_REGION });

export const sesPlugin = {
  name: "ses",
  register(app: App) {
    app.decorate("email", {
      async send({ to, subject, text, html }) {
        const input: SendEmailCommandInput = {
          FromEmailAddress: process.env.SES_FROM_ADDRESS!,
          Destination: { ToAddresses: [to] },
          Content: {
            Simple: {
              Subject: { Data: subject, Charset: "UTF-8" },
              Body: {
                ...(text ? { Text: { Data: text, Charset: "UTF-8" } } : {}),
                ...(html ? { Html: { Data: html, Charset: "UTF-8" } } : {}),
              },
            },
          },
        };
        const out = await client.send(new SendEmailCommand(input));
        return { id: out.MessageId ?? "" };
      },
    });
    app.onClose(() => client.destroy());
  },
};

declare module "@daloyjs/core" {
  interface AppState {
    email: {
      send(msg: {
        to: string;
        subject: string;
        text?: string;
        html?: string;
      }): Promise<{ id: string }>;
    };
  }
}
```

## 5. Use it in a route

```ts
import { z } from "zod";
import { App, secureHeaders, rateLimit } from "@daloyjs/core";
import { sesPlugin } from "./plugins/ses.ts";

const app = new App();
app.use(secureHeaders());
app.use(rateLimit({ windowMs: 60_000, max: 10 }));
app.register(sesPlugin);

app.post(
  "/notify",
  {
    operationId: "sendWelcome",
    request: {
      body: z.object({
        to: z.email(),
        name: z.string().min(1).max(80),
      }),
    },
    responses: {
      202: { description: "Queued", body: z.object({ id: z.string() }) },
    },
  },
  async ({ body, state }) => {
    const { id } = await state.email.send({
      to: body.to,
      subject: `Welcome, ${body.name}!`,
      text: `Hi ${body.name}, thanks for signing up.`,
      html: `<p>Hi ${body.name}, thanks for signing up.</p>`,
    });
    return { status: 202, body: { id } };
  },
);
```

## Templates & attachments

SESv2's `SendEmailCommand` accepts three content variants:

- `Content.Simple`: subject + text/HTML body (used above). Also supports an `Attachments` array with base64 `RawContent`, `FileName`, and `ContentType`.
- `Content.Raw.Data`: a fully MIME-encoded message (use [mailcomposer](https://nodemailer.com/extras/mailcomposer/) or `nodemailer`'s composer if you need rich attachments).
- `Content.Template`: render an SES template by `TemplateName` with a JSON `TemplateData` payload. Create templates ahead of time with `CreateEmailTemplateCommand`.

## 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 SDK can run there but uses a Web Crypto signer. Pin `@aws-sdk/client-sesv2` ≥ 3.700 and pass `credentials` explicitly (the default provider chain expects Node APIs).

## Observability

SES publishes **delivery, bounce, and complaint** events to SNS, EventBridge, or Kinesis Firehose via a *configuration set*. Add `ConfigurationSetName` to `SendEmailCommandInput` to opt in.

See also [Resend](/docs/email/resend), [SendGrid](/docs/email/sendgrid), and the [email integrations overview](/docs/email).

---

Source: https://daloyjs.dev/docs/email/aws-ses