Skip to content

Sending & managing email

SES sends email for you. Before it sends from an address, you prove you own it: the address, or the whole domain, becomes a verified identity. A runtime then sends through that identity with a binding, and SES handles delivery, bounces and complaints.

An identity is a domain like mail.example.com or a single address. A domain identity is verified with DKIM: SES gives you three keys to publish as DNS records, and signs every message with them, so receiving servers can check the mail really came from your domain.

Every message you send builds or spends your reputation: how often your mail bounces, is marked as spam, or is opened. Receiving servers use it to decide whether your mail reaches the inbox. Everything after the first send, from contact lists to dedicated IPs, exists to protect it.

The resource snippets below run inside a Stack’s Effect.gen body:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as AWS from "@alchemy.run/aws";
import * as SES from "@alchemy.run/aws/SES";
import * as Effect from "effect/Effect";
export default Alchemy.Stack(
"Mailer",
{ providers: AWS.providers(), state: AWS.state() },
Effect.gen(function* () {
// resources go here
return {};
}),
);

An identity is a domain or a single address. A domain identity generates Easy DKIM tokens you publish as CNAME records:

const identity = yield* SES.EmailIdentity("Sender", {
emailIdentity: "mail.example.com",
});
// publish identity.dkimTokens as CNAME records to finish verifying

Each token in identity.dkimTokens is published as {token}._domainkey.mail.example.com CNAME {token}.dkim.amazonses.com. For an address identity (hello@example.com instead of a domain), SES emails a verification link and there are no DKIM records to publish. The identity is usable once verificationStatus reaches SUCCESS.

SES.SendEmail(identity) binds sending to that identity and returns a callable. Provide its implementation with SES.SendEmailHttp, which mints the least-privilege ses:SendEmail policy scoped to the identity:

src/mailer.ts
import * as AWS from "@alchemy.run/aws";
import * as SES from "@alchemy.run/aws/SES";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/http/HttpServerResponse";
export default class Mailer extends AWS.Lambda.Function<Mailer>()(
"Mailer",
{ main: import.meta.url },
Effect.gen(function* () {
const identity = yield* SES.EmailIdentity("Sender", {
emailIdentity: "mail.example.com",
});
const sendEmail = yield* SES.SendEmail(identity);
return {
fetch: Effect.gen(function* () {
yield* sendEmail({
FromEmailAddress: "hello@mail.example.com",
Destination: { ToAddresses: ["customer@example.com"] },
Content: {
Simple: {
Subject: { Data: "Welcome!" },
Body: { Text: { Data: "Hello from SES." } },
},
},
});
return yield* HttpServerResponse.json({ ok: true });
}).pipe(Effect.orDie),
};
}).pipe(Effect.provide(SES.SendEmailHttp)),
) {}

For a domain identity, pass FromEmailAddress explicitly (any address at the domain); for an address identity it defaults to the identity itself. The callable also accepts Content.Raw for a pre-built MIME message and Content.Template for templated sends. To route through a configuration set — for open/click tracking or event publishing — bind it too: SES.SendEmail(identity, configSet).

An EmailIdentityPolicy attaches a sending-authorization policy to an identity, letting another AWS account or IAM principal send as it:

const policy = yield* SES.EmailIdentityPolicy("AllowPartner", {
emailIdentity: identity.emailIdentity,
policy: {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: { AWS: "arn:aws:iam::111122223333:root" },
Action: ["ses:SendEmail"],
Resource: identity.identityArn,
},
],
},
});

Alchemy serializes this typed IAM policy at the SES API boundary and compares normalized document content when detecting drift.

A ContactList is a managed audience with subscription topics — the backbone of SES’s list-management and one-click unsubscribe handling:

const list = yield* SES.ContactList("Newsletter", {
description: "Weekly product newsletter",
topics: [
{
TopicName: "product-updates",
DisplayName: "Product Updates",
DefaultSubscriptionStatus: "OPT_IN",
},
{
TopicName: "promotions",
DisplayName: "Promotions",
DefaultSubscriptionStatus: "OPT_OUT",
},
],
});

updateContactList replaces the topic set wholesale, so the full desired list of topics is sent on every change. Deleting the list deletes all of its contacts.

A Contact is a single address on a list, with its own per-topic subscription preferences:

const contact = yield* SES.Contact("Subscriber", {
contactListName: list.contactListName,
emailAddress: "reader@example.com",
topicPreferences: [
{ TopicName: "product-updates", SubscriptionStatus: "OPT_IN" },
{ TopicName: "promotions", SubscriptionStatus: "OPT_OUT" },
],
});

The (list, emailAddress) pair is the stable key — changing either replaces the contact. Set unsubscribeAll: true to opt the contact out of every topic at once.

A CustomVerificationEmailTemplate is the branded email SES sends when you verify a new address identity — replacing the plain default message:

const template = yield* SES.CustomVerificationEmailTemplate("Verify", {
fromEmailAddress: "verify@mail.example.com",
templateSubject: "Please confirm your email",
templateContent:
"<html><body>Click the link to verify your address.</body></html>",
successRedirectionURL: "https://example.com/verified",
failureRedirectionURL: "https://example.com/verify-failed",
});

Creating and managing the template works on any account. To actually send one, bind SES.SendCustomVerificationEmail — an account-level binding (the template has no ARN to scope to) whose implementation is SES.SendCustomVerificationEmailHttp:

// Construction — inside the Lambda's Effect.gen body
const sendVerification = yield* SES.SendCustomVerificationEmail();
// runtime
const { MessageId } = yield* sendVerification({
EmailAddress: "new-user@example.com",
TemplateName: yield* template.templateName,
});

Sending a custom verification email requires production access — in the sandbox the call fails with the typed BadRequestException.

Isolate reputation with a dedicated IP pool

Section titled “Isolate reputation with a dedicated IP pool”

A DedicatedIpPool groups dedicated IPs so different kinds of mail (marketing vs. transactional) build reputation independently:

const pool = yield* SES.DedicatedIpPool("Transactional", {
scalingMode: "MANAGED",
});

STANDARD → MANAGED is applied in place; MANAGED → STANDARD has no API and replaces the pool.

A Tenant isolates a customer or business unit’s sending — its own reputation metrics, sending status, and optional tenant-scoped suppression list — inside one SES account:

const tenant = yield* SES.Tenant("CustomerA", {
suppression: { reasons: ["BOUNCE", "COMPLAINT"], scope: "TENANT" },
});

Attach identities, configuration sets, or templates to the tenant with a TenantResourceAssociation:

const link = yield* SES.TenantResourceAssociation("SenderLink", {
tenantName: tenant.tenantName,
resourceArn: identity.identityArn,
});

A single resource can belong to multiple tenants. Deleting the tenant removes its associations but leaves the underlying resources in place.

A MultiRegionEndpoint (global endpoint) splits sending traffic across a primary region — where the endpoint is created — and one or more secondary regions:

const endpoint = yield* SES.MultiRegionEndpoint("Global", {
regions: ["eu-west-1"],
});

There is no update API, so changing the name or routes replaces the endpoint.

AccountSettings is an account/region singleton for account-wide sending status, the suppression list, and Virtual Deliverability Manager (VDM). Only the aspects you specify are managed — omit the rest to leave them untouched:

const settings = yield* SES.AccountSettings("Account", {
vdm: {
enabled: "ENABLED",
dashboardEngagementMetrics: "ENABLED",
},
suppression: { reasons: ["BOUNCE", "COMPLAINT"] },
});

Deleting this resource is a no-op: these are account-global toggles with no single safe default, so Alchemy leaves them exactly as they are. Change the props and re-deploy to adjust them.

Enabling VDM (above) unlocks a set of read-only deliverability bindings you can call from a Lambda. Look up a single message’s per-recipient event timeline with GetMessageInsights:

// Construction — account-level binding, no resource argument
const getInsights = yield* SES.GetMessageInsights();
// runtime — MessageId returned by a prior SendEmail
const { Insights } = yield* getInsights({ MessageId: messageId });

Fetch aggregated metric time-series — sends, deliveries, bounces, complaints, opens, clicks — with BatchGetMetricData:

const getMetrics = yield* SES.BatchGetMetricData();
const { Results } = yield* getMetrics({
Queries: [
{
Id: "sends",
Namespace: "VDM",
Metric: "SEND",
StartDate: new Date(Date.now() - 7 * 24 * 3600 * 1000),
EndDate: new Date(),
},
],
});

Provide each with its *Http layer (SES.GetMessageInsightsHttp, SES.BatchGetMetricDataHttp). Two more account-level bindings round out the set: GetBlacklistReports reports which anti-spam blacklists your dedicated IPs appear on, and GetDomainStatisticsReport returns inbox-placement statistics for a domain — the latter requires the SES deliverability dashboard subscription.

Some of the SES surface isn’t modeled as resources yet: the deliverability dashboard subscription itself, and the dedicated-IP data plane (assigning specific IPs to a pool and warming them). Use the console or the AWS SDK for those.

  • Lambda — the runtime that sends.
  • Email receiving — the inbound side: SES receipt rules into S3, SNS, and Lambda.
  • CloudWatch — alarm on bounce and complaint metrics.

Reference: