# @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.

## Authentication

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

1. Explicit credentials passed to constructor
2. Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`)
3. Shared credentials file (`~/.aws/credentials`)
4. IAM role (EC2, ECS, Lambda)

### 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=us-east-1
```

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

## 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');
```

## 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);
  }
}
```

## Configuration Options

```typescript
interface WrapsEmailConfig {
  region?: string; // AWS region (defaults to us-east-1)
  credentials?: {
    accessKeyId: string;
    secretAccessKey: string;
    sessionToken?: string;
  };
  endpoint?: string; // Custom SES endpoint (for testing with LocalStack)
}
```

## 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
- `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
- `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)
