# @wraps.dev/email

Beautiful email SDK for AWS SES with React.email support.

## Features

- Resend-like developer experience but calls your SES directly (BYOC model)
- Full TypeScript support with comprehensive types
- React.email integration for beautiful templates
- Automatic AWS credential chain resolution
- Template management (create, update, delete, list)
- Bulk email sending (up to 50 recipients)
- Signed reply threading for agent-style inbound (conversation id survives any email client)
- Zero vendor lock-in - just a thin wrapper around AWS SES
- **Dual CJS + ESM builds** - works with any bundler or Node.js

## Installation

```bash
pnpm add @wraps.dev/email
```

## Quick Start

```typescript
import { WrapsEmail } from '@wraps.dev/email';

const email = new WrapsEmail({ region: 'us-east-1' });

await email.send({
  from: 'you@company.com',
  to: 'user@example.com',
  subject: 'Welcome!',
  html: '<h1>Hello World</h1>',
});
```

## Module Format Support

This package supports both CommonJS and ES Modules:

**ESM (modern):**
```typescript
import { WrapsEmail } from '@wraps.dev/email';
```

**CommonJS (Node.js):**
```javascript
const { WrapsEmail } = require('@wraps.dev/email');
```

## Cloudflare Workers / Edge

The `@wraps.dev/email/workers` subpath is a zero-Node-APIs build (~5 KiB) that runs
on Cloudflare Workers, Deno Deploy, and any other `workerd`-based runtime. It uses
`aws4fetch` (Web Crypto) to sign requests and the SESv2 REST API (JSON payloads, no
`DOMParser`).

```typescript
import { SESError, ValidationError, WrapsEmail } from '@wraps.dev/email/workers';

const email = new WrapsEmail({
  region: env.AWS_REGION,          // required — no credential chain at the edge
  credentials: {
    accessKeyId: env.AWS_ACCESS_KEY_ID,
    secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
  },
});

const result = await email.send({
  from: 'hello@example.com',
  to: 'user@example.com',
  subject: 'Hello from the edge!',
  html: '<h1>Hi there</h1>',
});
```

### Required at the edge

Both `region` and `credentials` are required — there is no AWS credential chain in a
Worker. Store them as [Wrangler secrets](https://developers.cloudflare.com/workers/configuration/secrets/):

```sh
wrangler secret put AWS_ACCESS_KEY_ID
wrangler secret put AWS_SECRET_ACCESS_KEY
```

### Supported fields

`from`, `to`, `cc`, `bcc`, `replyTo`, `subject`, `html`, `text`, `tags`,
`configurationSetName`.

When `html` is provided without `text`, plain text is auto-generated (same as the
Node entry).

### Not supported at the edge

| Feature | Why | Alternative |
|---|---|---|
| `react` | Requires `react-dom/server` (Node built-ins) | Render to HTML before calling `send()` |
| `attachments` | MIME serialisation requires `Buffer` | Use the Node entry or pre-encode |
| Templates / inbox / events | Depend on `@aws-sdk/*` clients | Use the Node entry |
| Reply threading | Requires AWS SSM | Use the Node entry |

### Security note

Scope the IAM key to `ses:SendEmail` only. Store credentials as Wrangler secrets
(never in `wrangler.toml` source). Rotate them periodically. For high-volume use,
consider enqueueing emails via a Cloudflare Queue rather than blocking the request
so transient SES errors don't surface to end users.

## Before your first send

Three things decide whether your first send works. All three produce AWS errors
that name the wrong cause if you don't know them up front.

**1. Region.** SES identities are per-region: a domain verified in `eu-west-1`
does not exist in `us-east-1`. The SDK resolves the region in this order —

1. `region` passed to the constructor
2. `AWS_REGION`
3. `AWS_DEFAULT_REGION`
4. The active profile's `region` (`~/.aws/config`), or EC2 instance metadata
5. `us-east-1`, as a last resort

— so `export AWS_REGION=eu-west-1` is honored, and only reaching step 5 gives you
`us-east-1`. Send to the region your identity actually lives in.

**2. A verified sender.** The `from` address (or its domain) must be a verified
SES identity **in that region**. `npx wraps email init` sets one up.

**3. The SES sandbox.** Every new AWS account starts sandboxed and can only send
**to** verified recipients. You do not need production access to prove your setup
works — send to the AWS mailbox simulator, which AWS pre-verifies:

```typescript
await email.send({
  from: 'you@yourdomain.com',
  to: 'success@simulator.amazonses.com', // deliverable from a sandboxed account
  subject: 'Pipeline check',
  html: '<p>It works.</p>',
});
```

Requesting production access to send to anyone else is an AWS support review;
no SDK can do it for you.

Both a region mismatch and a sandbox block surface as the same AWS text —
*"Email address is not verified"*. `send()` throws a `SandboxError` for that case
whose message names the region actually used and separates the two causes, so you
don't re-verify an identity that is already verified somewhere else.

To check where you stand:

```bash
aws sesv2 get-account --region <region>            # productionAccessEnabled: false means sandbox
aws sesv2 list-email-identities --region <region>  # what is verified there
npx wraps email status --region <region>
```

## Authentication

Wraps Email uses the AWS credential chain in the following order:

1. Pre-configured `client` passed to the constructor
2. `roleArn` (OIDC federation on Vercel, GitHub Actions, EKS)
3. Explicit credentials passed to the constructor
4. Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`)
5. Shared credentials file (`~/.aws/credentials`), AWS SSO, IAM role (EC2, ECS, Lambda)

When none of them produce credentials, `send()` throws a `CredentialsError`
listing every option — it does not leak the AWS SDK's raw
`Could not load credentials from any providers`.

### With explicit credentials

```typescript
const email = new WrapsEmail({
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
    sessionToken: process.env.AWS_SESSION_TOKEN, // optional
  },
  region: 'us-west-2',
});
```

### Using environment variables

```bash
export AWS_ACCESS_KEY_ID=your_access_key
export AWS_SECRET_ACCESS_KEY=your_secret_key
export AWS_REGION=eu-west-1
```

```typescript
const email = new WrapsEmail(); // Credentials and region auto-detected
```

`AWS_REGION` (or `AWS_DEFAULT_REGION`, or your profile's region) is used as-is.
Pass `region` to the constructor only when you want to override it.

## Usage Examples

### Send simple email

```typescript
const result = await email.send({
  from: 'you@company.com',
  to: 'user@example.com',
  subject: 'Welcome!',
  html: '<h1>Hello World</h1>',
  text: 'Hello World', // optional
});

console.log('Message ID:', result.messageId);
```

### Send to multiple recipients

```typescript
await email.send({
  from: 'you@company.com',
  to: ['user1@example.com', 'user2@example.com'],
  cc: ['manager@company.com'],
  bcc: ['archive@company.com'],
  subject: 'Team Update',
  html: '<p>Important announcement</p>',
});
```

### React.email Support

```typescript
import { EmailTemplate } from './emails/Welcome';

await email.send({
  from: 'you@company.com',
  to: 'user@example.com',
  subject: 'Welcome to our platform',
  react: <EmailTemplate name="John" orderId="12345" />,
});
```

### Send with attachments

Send emails with file attachments (PDFs, images, documents, etc.). The SDK automatically handles MIME encoding and uses AWS SES SendRawEmail under the hood.

```typescript
// Single attachment
const result = await email.send({
  from: 'you@company.com',
  to: 'user@example.com',
  subject: 'Your invoice',
  html: '<p>Please find your invoice attached.</p>',
  attachments: [
    {
      filename: 'invoice.pdf',
      content: Buffer.from('...'), // Buffer or base64 string
      contentType: 'application/pdf', // Optional - auto-detected from filename
    },
  ],
});

// Multiple attachments
await email.send({
  from: 'you@company.com',
  to: 'user@example.com',
  subject: 'Monthly Report',
  html: '<h1>Monthly Report</h1><p>Reports attached</p>',
  attachments: [
    {
      filename: 'report.pdf',
      content: pdfBuffer,
      contentType: 'application/pdf',
    },
    {
      filename: 'chart.png',
      content: imageBuffer,
      contentType: 'image/png',
    },
    {
      filename: 'data.csv',
      content: csvBuffer,
      contentType: 'text/csv',
    },
  ],
});

// Attachment with base64 string
await email.send({
  from: 'you@company.com',
  to: 'user@example.com',
  subject: 'Document',
  html: '<p>Document attached</p>',
  attachments: [
    {
      filename: 'document.pdf',
      content: 'JVBERi0xLjQKJeLjz9MK...', // base64 string
      contentType: 'application/pdf',
    },
  ],
});
```

**Supported attachment features:**
- Automatic MIME type detection from file extension
- Base64 encoding handled automatically
- Up to 100 attachments per email
- Maximum message size: 10 MB (AWS SES limit)
- Works with both HTML and plain text emails
- Compatible with React.email components

### Send with tags (for SES tracking)

```typescript
await email.send({
  from: 'you@company.com',
  to: 'user@example.com',
  subject: 'Newsletter',
  html: '<p>Content</p>',
  tags: {
    campaign: 'newsletter-2025-01',
    type: 'marketing',
  },
});
```

## Reply threading

When an agent or user replies to a message you sent, you need to know which conversation the reply belongs to — without trusting the `From:` address and without parsing `In-Reply-To` headers clients love to drop. Reply threading mints a signed `Reply-To` address per send (e.g. `t_eyJ...@r.mail.yourapp.com`). The Wraps-deployed inbound Lambda verifies the signature, extracts the conversation id, and publishes it on the `email.received` event so your handler can look up state in O(1).

**Prerequisite:** reply threading ships as part of the Wraps CLI inbound stack. Run `wraps email reply init --domain yourapp.com` once per sending domain — it provisions the signing secret in SSM, the `r.mail.{domain}` MX record, and the inbound Lambda that verifies tokens. See the [Reply threading guide](https://wraps.dev/docs/guides/reply-threading) for the full CLI flow.

### Configure the client

```typescript
import { WrapsEmail } from '@wraps.dev/email';

const email = new WrapsEmail({
  region: 'us-east-1',
  replyThreading: {
    // Defaults shown — all fields optional
    parameterPrefix: '/wraps/email/reply-secret/', // SSM prefix written by the CLI
    ttlSeconds: 90 * 86_400,                       // 90 days; 0 = infinite
    cacheTtlMs: 5 * 60 * 1000,                     // per-domain secret cache
    // replyDomain: 'r.mail.yourapp.com',          // defaults to r.mail.{fromDomain}
  },
});
```

One `WrapsEmail` instance handles any number of sending domains — the per-domain signing secret is fetched from SSM on first use and cached for `cacheTtlMs`.

### Send a threaded message

```typescript
const conversationId = email.replyThreading!.newConversation();

const result = await email.send({
  from: 'agent@yourapp.com',
  to: 'user@example.com',
  subject: 'Re: your support request',
  html: '<p>Hey — following up on your ticket.</p>',
  conversationId,
});

// result.conversationId === conversationId
// result.sendId is a fresh 11-char id for this specific send
await saveThread({ conversationId: result.conversationId, sendId: result.sendId });
```

The SDK generates a signed `Reply-To` address and overrides `ReplyToAddresses`. Passing both `replyTo` and `conversationId` throws `ValidationError` — pick one.

To continue an existing conversation, reuse the id you stored from a prior `send()`:

```typescript
await email.send({
  from: 'agent@yourapp.com',
  to: 'user@example.com',
  subject: 'Re: your support request',
  html: '<p>Quick follow-up.</p>',
  conversationId: existingThread.conversationId,
});
```

### ID format

Both `conversationId` and `sendId` must be 11-character base64url strings (8 raw bytes). UUIDs and other formats will throw `ValidationError`. Generate them with the SDK:

```typescript
import { generateConversationId, generateSendId } from '@wraps.dev/email';

const conversationId = generateConversationId(); // e.g. "a7F_2kQbNxR"
const sendId = generateSendId();
```

Or use the client helper: `email.replyThreading!.newConversation()`.

### Handle incoming replies

The inbound Lambda (deployed by `wraps email inbound init`) verifies the token and emits an `email.received` event on EventBridge with a `replyToken` block:

```typescript
// EventBridge target (Lambda, SQS consumer, etc.)
export async function handler(event: { detail: EmailReceivedDetail }) {
  const { replyToken, from, subject, text } = event.detail;

  if (replyToken?.status !== 'valid') {
    // One of: 'invalid-signature' | 'expired' | 'unsupported-version'
    //       | 'malformed' | 'unknown-domain' | undefined (no token present)
    await routeToFallbackInbox(event.detail);
    return;
  }

  await appendReplyToThread({
    conversationId: replyToken.conversationId,
    inReplyToSendId: replyToken.sendId,
    from: from.address,
    body: text,
  });
}
```

See the [event shape reference](https://wraps.dev/docs/infrastructure/events) for the full `email.received` payload.

### Rotating the signing secret

Rotate with the CLI whenever you need to — the previous secret stays valid during the rotation window so in-flight replies keep verifying:

```bash
wraps email reply rotate --domain yourapp.com
```

SDK instances pick up the new secret within `cacheTtlMs` (default 5 minutes). No redeploy needed.

### Honest limits

- **Verified ≠ sender identity.** A valid token proves the reply came back to an address you minted — it does not prove who sent it. Verify `From:` (SPF/DKIM/DMARC, or an explicit allow-list) before taking sensitive actions.
- **Default TTL is 90 days.** Tokens older than that verify as `expired`. Pass `replyTtlSeconds: 0` on `send()` for infinite-lifetime tokens, or override per-domain with `replyThreading.ttlSeconds`.
- **No replay defense in v1.** The same signed address will verify repeatedly until it expires. If you need single-use semantics, track `sendId` in your own store and reject duplicates.
- **One secret per domain.** Multiple sending domains mean multiple SSM parameters (all under `parameterPrefix`) and multiple CLI `reply init` runs.

## Template Management

SES templates allow you to store reusable email designs with variables in your AWS account.

### Create a template

```typescript
await email.templates.create({
  name: 'welcome-email',
  subject: 'Welcome to {{companyName}}, {{name}}!',
  html: `
    <h1>Welcome {{name}}!</h1>
    <p>Click to confirm: <a href="{{confirmUrl}}">Confirm Account</a></p>
  `,
  text: 'Welcome {{name}}! Click to confirm: {{confirmUrl}}',
});
```

### Create template from React.email component

```typescript
await email.templates.createFromReact({
  name: 'welcome-email-v2',
  subject: 'Welcome to {{companyName}}, {{name}}!',
  react: <WelcomeEmailTemplate />,
  // React component should use {{variable}} syntax for SES placeholders
});
```

### Send using a template

```typescript
const result = await email.sendTemplate({
  from: 'you@company.com',
  to: 'user@example.com',
  template: 'welcome-email',
  templateData: {
    name: 'John',
    companyName: 'Acme Corp',
    confirmUrl: 'https://app.com/confirm/abc123',
  },
});
```

### Bulk send with template (up to 50 recipients)

```typescript
const results = await email.sendBulkTemplate({
  from: 'you@company.com',
  template: 'weekly-digest',
  destinations: [
    {
      to: 'user1@example.com',
      templateData: { name: 'Alice', unreadCount: 5 },
    },
    {
      to: 'user2@example.com',
      templateData: { name: 'Bob', unreadCount: 12 },
    },
  ],
});
```

### Update a template

```typescript
await email.templates.update({
  name: 'welcome-email',
  subject: 'Welcome aboard, {{name}}!',
  html: '<h1>Welcome {{name}}!</h1>...',
});
```

### Get template details

```typescript
const template = await email.templates.get('welcome-email');
console.log(template.name, template.subject);
```

### List all templates

```typescript
const templates = await email.templates.list();
templates.forEach(t => console.log(t.name, t.createdTimestamp));
```

### Delete a template

```typescript
await email.templates.delete('welcome-email');
```

## Batch Sending

`sendBatch()` sends up to 100 recipients with unique subject/html/text per entry, inline — no pre-created SES template required (unlike `sendBulkTemplate()`). Partial failures are reported in the result, not thrown.

```typescript
const result = await email.sendBatch({
  from: 'hello@example.com',
  entries: [
    { to: 'alice@example.com', subject: 'Hi Alice', html: '<p>Hello Alice!</p>' },
    { to: 'bob@example.com', subject: 'Hi Bob', html: '<p>Hello Bob!</p>' },
  ],
});

console.log(result.successCount, result.failureCount);
for (const entry of result.results) {
  if (entry.status === 'failure') console.error(entry.index, entry.error);
}
```

Full reference: https://wraps.dev/docs/sdk-reference#send-batch

## Suppression List

Check, add, remove, and list entries on the SES account-level suppression list. Always available (no config flag required).

```typescript
const suppressed = await email.suppression.get('user@example.com');
if (!suppressed) {
  await email.suppression.add('user@example.com', 'BOUNCE'); // or 'COMPLAINT'
}
await email.suppression.remove('user@example.com');
const { entries, nextToken } = await email.suppression.list();
```

Full reference: https://wraps.dev/docs/sdk-reference#suppression

## Inbox

Read inbound emails stored in S3 by the Wraps-deployed inbound Lambda. `email.inbox` is `WrapsInbox | null` — non-null only when `inboxBucketName` is configured.

```typescript
const { emails } = await email.inbox!.list();
const full = await email.inbox!.get(emails[0].emailId);
const attachmentUrl = await email.inbox!.getAttachment(full.emailId, 'att_1');
await email.inbox!.reply(full.emailId, {
  from: 'support@example.com',
  html: '<p>Thanks for reaching out!</p>',
});
await email.inbox!.delete(full.emailId);
```

Full reference: https://wraps.dev/docs/sdk-reference#inbox

## Event History

Query per-message delivery status from DynamoDB. `email.events` is `WrapsEmailEvents | null` — non-null only when `historyTableName` is configured.

```typescript
const status = await email.events!.get(result.messageId); // sent/delivered/opened/clicked/bounced/complained/suppressed
const { emails } = await email.events!.list({ accountId: 'acct_123' });
```

Full reference: https://wraps.dev/docs/sdk-reference#events

## Error Handling

```typescript
import { WrapsEmailError, ValidationError, SESError } from '@wraps.dev/email';

try {
  await email.send({ ... });
} catch (error) {
  if (error instanceof ValidationError) {
    // Invalid email address, missing required fields, etc.
    console.error('Validation error:', error.message);
    console.error('Field:', error.field);
  } else if (error instanceof SESError) {
    // AWS SES error (rate limit, unverified sender, etc.)
    console.error('SES error:', error.message);
    console.error('Code:', error.code); // 'MessageRejected', 'Throttling', etc.
    console.error('Request ID:', error.requestId);
    console.error('Retryable:', error.retryable);
  } else {
    // Other errors (network, auth, etc.)
    console.error('Unknown error:', error);
  }
}
```

`WrapsEmailError` is the catch-all base class every other email error extends —
useful for a single `instanceof` check that covers all of them. That includes
credential failures: an unresolvable credential chain throws `CredentialsError`,
not the AWS SDK's own error type.

```typescript
import { CredentialsError, SandboxError } from '@wraps.dev/email';

try {
  await email.send({ ... });
} catch (error) {
  if (error instanceof CredentialsError) {
    // Nothing was sent — the credential chain came up empty. The message lists
    // every way to supply credentials (SSO, access keys, env vars, AWS_PROFILE,
    // or passing them to the constructor).
    console.error(error.message);
  } else if (error instanceof SandboxError) {
    // SES rejected the send: the identity is not verified in the region used.
    // Either the region is wrong or the account is sandboxed — error.message
    // walks through both, and error.region names the region actually used.
    console.error(error.region, error.message);
  }
}
```

`SandboxError` extends `SESError`, so existing `instanceof SESError` handling
keeps working unchanged.

`sendBatch()` never throws on send failure — partial *and* total failures come
back as per-entry outcomes in its resolved `SendBatchResult`, including
chunk-level SES errors. Only input validation throws. Always inspect the result:

```typescript
const result = await email.sendBatch({ from, entries });
if (result.failureCount > 0) {
  for (const entry of result.results) {
    if (entry.status === 'failure') console.error(entry.index, entry.error);
  }
}
```

## Configuration Options

```typescript
interface WrapsEmailConfig {
  // Pre-configured SES client for advanced authentication scenarios.
  // Takes precedence over region, credentials, roleArn, and endpoint.
  client?: SESClient;

  // Pre-configured S3 client for inbox operations.
  // Takes precedence over region/credentials for inbox.
  s3Client?: S3Client;

  // S3 bucket name for inbound email storage.
  // When provided, enables the inbox API (email.inbox).
  inboxBucketName?: string;

  // AWS region for SES. When omitted, resolved from AWS_REGION, then
  // AWS_DEFAULT_REGION, then the active profile, then us-east-1 as a last
  // resort. Ignored if `client` is provided.
  region?: string;

  // AWS credentials — static or a credential provider (e.g. Vercel OIDC).
  // Falls back to the AWS credential chain if omitted. Ignored if `client` is provided.
  credentials?:
    | { accessKeyId: string; secretAccessKey: string; sessionToken?: string }
    | AwsCredentialIdentityProvider;

  // IAM role ARN to assume via STS AssumeRole (OIDC federation, cross-account
  // access). Ignored if `client` is provided.
  roleArn?: string;

  // Role session name for AssumeRole (defaults to 'wraps-email-session').
  // Only used when roleArn is provided. Ignored if `client` is provided.
  roleSessionName?: string;

  // Custom SES endpoint, e.g. for LocalStack. Ignored if `client` is provided.
  endpoint?: string;

  // DynamoDB table name for email event history.
  // When provided, enables the events API (email.events).
  historyTableName?: string;

  // Pre-configured DynamoDB DocumentClient for events.
  // Takes precedence over region/credentials for events.
  dynamodbClient?: DynamoDBDocumentClient;

  // Pre-configured SES v2 client for the suppression list.
  // Takes precedence over region/credentials for suppression.
  sesv2Client?: SESv2Client;

  // Enable signed reply-to threading (see "Reply threading" above).
  // When set, send()/sendTemplate()/etc. accept `conversationId` to mint a
  // signed reply-to address that the inbound Lambda verifies.
  replyThreading?: ReplyThreadingConfig;
}
```

OIDC / cross-account setups: use `roleArn` (see `packages/email/src/types.ts` for the
full `ReplyThreadingConfig` shape used by `replyThreading`).

## Testing with LocalStack

```typescript
const email = new WrapsEmail({
  region: 'us-east-1',
  endpoint: 'http://localhost:4566',
});
```

## API Reference

### `WrapsEmail`

Main client class for sending emails via AWS SES.

#### Methods

- `send(params: SendEmailParams): Promise<SendEmailResult>` - Send an email
- `sendBatch(params: SendBatchParams): Promise<SendBatchResult>` - Send up to 100 recipients with unique content each (no pre-created template required)
- `sendTemplate(params: SendTemplateParams): Promise<SendEmailResult>` - Send using SES template
- `sendBulkTemplate(params: SendBulkTemplateParams): Promise<SendBulkTemplateResult>` - Bulk send with template
- `templates.create(params: CreateTemplateParams): Promise<void>` - Create SES template
- `templates.createFromReact(params: CreateTemplateFromReactParams): Promise<void>` - Create template from React
- `templates.update(params: UpdateTemplateParams): Promise<void>` - Update template
- `templates.get(name: string): Promise<Template>` - Get template details
- `templates.list(): Promise<TemplateMetadata[]>` - List all templates
- `templates.delete(name: string): Promise<void>` - Delete template
- `suppression.get(email: string): Promise<SuppressionEntry | null>` - Check if an email is suppressed
- `suppression.add(email: string, reason: SuppressionReason): Promise<void>` - Add an email to the suppression list
- `suppression.remove(email: string): Promise<void>` - Remove an email from the suppression list
- `suppression.list(options?: SuppressionListOptions): Promise<SuppressionListResult>` - List suppressed emails
- `inbox.list(options?: InboxListOptions): Promise<InboxListResult>` - List inbound emails (when `inboxBucketName` is configured)
- `inbox.get(emailId: string): Promise<InboxEmail>` - Get a parsed inbound email
- `inbox.getAttachment(emailId, attachmentId, options?): Promise<string>` - Presigned URL for an inbound attachment
- `inbox.getRaw(emailId: string): Promise<string>` - Presigned URL for the raw MIME email
- `inbox.delete(emailId: string): Promise<void>` - Delete an inbound email and its files
- `inbox.forward(emailId, options): Promise<SendEmailResult>` - Forward an inbound email
- `inbox.reply(emailId, options): Promise<SendEmailResult>` - Reply to an inbound email
- `events.get(messageId: string): Promise<EmailStatus | null>` - Get all events for a sent email (when `historyTableName` is configured)
- `events.list(options: EmailListOptions): Promise<EmailListResult>` - List emails with events for an account
- `destroy(): void` - Close SES client and clean up resources

## Requirements

- Node.js 20+ (LTS)
- AWS SES configured in your AWS account
- Verified sender email addresses in SES

## License

MIT

## Links

- [GitHub Repository](https://github.com/wraps-team/wraps-js)
- [AWS SES Documentation](https://docs.aws.amazon.com/ses/)
- [React Email](https://react.email)
