Getting started

OpenSES turns your Amazon SES account into a product: a dashboard, a send API, an event log, a suppression list and webhooks. Everything below works identically on OpenSES Cloud and on a self-hosted install.

Quickstart

Four steps. On Cloud, start at step two.

terminal
# 1. self-host (skip on Cloud): deploy into your AWS account
git clone https://github.com/openses/openses && cd openses && npx openses init && npx openses deploy

# 2. connect AWS in the setup wizard (role, keys or instance profile)
# 3. add a domain, publish the 3 DKIM CNAMEs + MAIL FROM records
# 4. create an API key and send
curl -X POST https://mail.acme.com/v1/emails \
  -H "Authorization: Bearer os_live_…" \
  -H "Idempotency-Key: welcome-u_42" \
  -d '{"from":"Acme <hi@acme.com>","to":"jane@example.com","subject":"Welcome","html":"<p>Hi Jane</p>"}'

Connect AWS

Three methods, chosen per workspace. All three can enable several SES regions at once.

MethodBest forWhat is stored
Cross-account IAM roleCloudRole ARN and External ID. No secrets.
Lambda execution roleSelf-host, same AWS accountNothing. The deployed stack already has the permissions.
IAM access keysA second AWS account on self-hostKey pair, AES-256-GCM encrypted with your ENCRYPTION_KEY.

After connecting, OpenSES lists your existing SES identities and imports them as domains with their current DKIM and MAIL FROM status, reads your sandbox state and quotas, and creates one configuration set and one SNS topic per region, both named openses-<workspace>-<region>. It never modifies identities, configuration sets or topics it did not create.

IAM permissions

The published policy. The CloudFormation template uses exactly this.

openses-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    { "Effect": "Allow", "Resource": "*", "Action": [
      "ses:SendEmail", "ses:SendRawEmail", "ses:SendBulkEmail",
      "ses:GetAccount", "ses:PutAccountDetails", "ses:PutAccountSuppressionAttributes",
      "ses:ListEmailIdentities", "ses:GetEmailIdentity", "ses:CreateEmailIdentity",
      "ses:DeleteEmailIdentity", "ses:PutEmailIdentity*",
      "ses:*ConfigurationSet*", "ses:*SuppressedDestination*", "ses:ListSuppressedDestinations",
      "cloudwatch:GetMetricData"
    ] },
    { "Effect": "Allow", "Resource": "arn:aws:sns:*:*:openses-*", "Action": [
      "sns:CreateTopic", "sns:DeleteTopic", "sns:Subscribe", "sns:Unsubscribe",
      "sns:SetTopicAttributes", "sns:GetTopicAttributes"
    ] }
  ]
}

Domains and DKIM

Adding a domain calls CreateEmailIdentity with Easy DKIM, which gives you three CNAME records and keeps the private keys inside SES. OpenSES also sets a custom MAIL FROM subdomain (send.yourdomain.com by default) so SPF aligns, and suggests a DMARC record. The DNS checker polls through DNS-over-HTTPS and the domain flips to verified the moment SES confirms it. If you use Cloudflare, one OAuth click publishes all the records.

Send an email

typescript
import { OpenSES } from "openses";
const openses = new OpenSES(process.env.OPENSES_API_KEY);

await openses.emails.send({
  from: "Acme <billing@acme.com>",
  to: ["jane@example.com"],
  subject: "Your invoice #1042",
  html: "<p>…</p>",            // or text, or react, or template + variables
  replyTo: "support@acme.com",
  tags: { plan: "pro", invoice: "1042" },
  scheduledAt: "2026-11-06T09:00:00Z", // optional
  idempotencyKey: "inv-1042",
});

Requests are validated, checked against the suppression list, rate-limited to your SES quota, and handed to SES with SendEmail (or SendRawEmail for attachments). The response carries the OpenSES id and, once accepted, the SES message id. Idempotency keys are honoured for 24 hours.

Events and webhooks

SES publishes send, delivery, bounce, complaint, reject, delivery_delay, rendering_failure, open, click, subscription to the SNS topic OpenSES created. OpenSES verifies the SNS signature, records the event on the message, updates the suppression list on hard bounces and complaints, and fans out to your webhooks with an HMAC-SHA256 signature in X-OpenSES-Signature. Failed deliveries retry with exponential backoff for 24 hours and can be replayed from the dashboard.

webhook payload
{
  "type": "email.bounced",
  "created_at": "2026-11-05T09:41:03Z",
  "data": {
    "email_id": "em_01JC…", "ses_message_id": "0100019…",
    "to": "jane@example.com", "bounce_type": "Permanent",
    "tags": { "plan": "pro" }
  }
}

Inbox and receiving

Add an MX record for the apex or a subdomain (mail.acme.com is the default suggestion) pointing at inbound-smtp.<region>.amazonaws.com. OpenSES creates a receipt rule in your account that stores each message in a bucket you own and publishes an SNS notification; OpenSES fetches the object, parses it, threads it and emits email.received. Receiving is available in the SES regions that support inbound.

  • GET /v1/inbox/threads
  • GET /v1/inbox/threads/:id
  • POST /v1/inbox/threads/:id/reply
  • POST /v1/inbox/threads/:id/forward
  • GET /v1/inbox/rules
  • POST /v1/inbox/rules

Contacts and sequences

Contacts carry an email, properties you define, and a consent state per topic. Sequences are ordered steps with delays; enroll a contact and OpenSES schedules each step through your SES, stops the sequence when the contact replies (the reply lands in the inbox), and skips anyone unsubscribed, bounced or suppressed. Broadcasts send one message to a segment of contacts using SES bulk sending.

typescript
await openses.contacts.upsert({ email: "jane@example.com", properties: { plan: "pro" } });
await openses.sequences.enroll("onboarding", { email: "jane@example.com" });
// steps go out at the delays you set; replies stop the sequence

MCP server

Every instance serves an MCP endpoint at /mcp, authenticated with an API key (or OAuth for hosted agents). Tools: emails.send, emails.search, emails.get, inbox.threads, inbox.read, inbox.reply, inbox.forward, contacts.upsert, contacts.search, sequences.enroll, sequences.stop, domains.status, account.health, suppressions.add. Scopes on the key decide which tools appear, and every call is written to the audit log.

claude_desktop_config.json
{
  "mcpServers": {
    "openses": { "url": "https://mail.acme.com/mcp", "headers": { "Authorization": "Bearer os_live_…" } }
  }
}

Self-hosting

OpenSES deploys into your own AWS account with SST: one Lambda (API, MCP, event and inbound handlers, cron), an S3 bucket, one SNS topic per region, an EventBridge rule and a CloudFront URL. You need Node 20+, AWS credentials able to create the stack, and a Postgres connection string.npx openses init asks for these and writes .env:

.env
AWS_REGION=eu-central-1
DATABASE_URL=postgres://…@ep-….neon.tech/openses?sslmode=require
DOMAIN=mail.acme.com                 # optional; the CloudFront URL works without
ENCRYPTION_KEY=base64:…          # generated; encrypts stored AWS keys and API keys at rest
AUTH_SECRET=…                    # generated

Then npx openses deploy. The first visit creates the admin user, and the Lambda's own IAM role is already connected to SES in that account. Update with git pull && npx openses deploy; migrations run during the deploy. Any Postgres with a public TLS endpoint works (Neon, Supabase, public RDS, your own); a private RDS needs the VPC option in sst.config.ts. To host the dashboard on Cloudflare instead of CloudFront, run wrangler deploy from deploy/cloudflare with API_URL set. Local development is npx openses dev, which runs the Lambda live so SNS can reach it, with no tunnel.

API reference

REST under /v1, bearer API keys with scopes, JSON in and out, errors as { error: { code, message } }. An OpenAPI 3.1 spec is served from every instance at /openapi.json.

  • POST /v1/emails
  • POST /v1/emails/batch
  • GET /v1/emails/:id
  • GET /v1/emails
  • DELETE /v1/emails/:id (cancel scheduled)
  • GET /v1/domains
  • POST /v1/domains
  • POST /v1/domains/:id/verify
  • GET /v1/suppressions
  • POST /v1/suppressions
  • DELETE /v1/suppressions/:email
  • GET /v1/webhooks
  • POST /v1/webhooks
  • GET /v1/webhooks/:id/deliveries
  • GET /v1/templates
  • POST /v1/templates
  • GET /v1/account (quota, sandbox, reputation)
  • GET /v1/contacts
  • POST /v1/contacts
  • POST /v1/sequences/:id/enroll
  • POST /v1/broadcasts

SDKs and CLI

Official SDKs for TypeScript (npm i openses) and Python (pip install openses), generated from the OpenAPI spec so they never lag the API. The CLI (npm i -g openses) installs, upgrades, checks DNS and SNS health, and imports suppression lists.

Migrate from another provider

The request shape maps one-to-one from Resend and closely from Postmark and SendGrid: rename the import, swap the key, keep the payload. Export your suppression list from the old provider and run openses suppressions import file.csv; it lands in both OpenSES and the SES account-level list. Domains can stay verified at both providers during the switch; DKIM selectors do not collide.

Something missing? Open an issue on GitHub or ask in Discord. See also Self-host and Cloud.