1---
2title: Sending & managing email
3description: Send email with SES — verify identities, send from a Lambda with the SendEmail binding, manage contact lists, tenants, dedicated IP pools, account settings, and deliverability insights.
4---
5
6**SES sending** starts with a verified **identity** — a domain or
7an email address you own — and a runtime **binding** that lets a
8Lambda send through it. Around that core, SES v2 adds the pieces
9you need to run email at scale: authorization policies, contact
10lists, dedicated IP pools, per-tenant isolation, multi-region
11endpoints, account-wide settings, and deliverability insights.
12
13The sending surface:
14
15- An **[`EmailIdentity`](/providers/aws/ses/emailidentity)** is a
16 verified domain or address, and
17 **[`SendEmail`](/providers/aws/ses/sendemail)** is the runtime
18 binding that sends through it.
19- An
20 **[`EmailIdentityPolicy`](/providers/aws/ses/emailidentitypolicy)**
21 authorizes other accounts to send as your identity.
22- A **[`ContactList`](/providers/aws/ses/contactlist)** and its
23 **[`Contact`](/providers/aws/ses/contact)** entries model a
24 managed audience with subscription topics.
25- A
26 **[`DedicatedIpPool`](/providers/aws/ses/dedicatedippool)**
27 isolates sending reputation onto dedicated IPs.
28- A **[`Tenant`](/providers/aws/ses/tenant)** groups identities,
29 configuration sets, and templates for a customer or business
30 unit.
31- A
32 **[`MultiRegionEndpoint`](/providers/aws/ses/multiregionendpoint)**
33 splits sending across regions.
34- **[`AccountSettings`](/providers/aws/ses/accountsettings)**
35 manages account-wide sending status, the suppression list, and
36 Virtual Deliverability Manager.
37
38:::note
39New SES accounts start in the **sandbox**: you can only send to
40verified identities or the mailbox simulator
41(`success@simulator.amazonses.com`), and volume is capped. Request
42production access from the SES console to lift both limits.
43:::
44
45## Create the Stack
46
47The resource snippets below run inside a Stack's `Effect.gen`
48body:
49
50```typescript
51// alchemy.run.ts
52import * as Alchemy from "alchemy";
53import * as AWS from "alchemy/AWS";
54import * as SES from "alchemy/AWS/SES";
55import * as Effect from "effect/Effect";
56
57export default Alchemy.Stack(
58 "Mailer",
59 { providers: AWS.providers(), state: AWS.state() },
60 Effect.gen(function* () {
61 // resources go here
62 return {};
63 }),
64);
65```
66
67## Verify a sending identity
68
69An identity is a domain or a single address. A domain identity
70generates Easy DKIM tokens you publish as CNAME records:
71
72```typescript
73const identity = yield* SES.EmailIdentity("Sender", {
74 emailIdentity: "mail.example.com",
75});
76// publish identity.dkimTokens as CNAME records to finish verifying
77```
78
79Each token in `identity.dkimTokens` is published as
80`{token}._domainkey.mail.example.com CNAME {token}.dkim.amazonses.com`.
81For an address identity (`hello@example.com` instead of a domain),
82SES emails a verification link and there are no DKIM records to
83publish. The identity is usable once `verificationStatus` reaches
84`SUCCESS`.
85
86## Send email from a Lambda
87
88`SES.SendEmail(identity)` binds sending to that identity and
89returns a callable. Provide its implementation with
90`SES.SendEmailHttp`, which mints the least-privilege
91`ses:SendEmail` policy scoped to the identity:
92
93```typescript
94// src/mailer.ts
95import * as AWS from "alchemy/AWS";
96import * as SES from "alchemy/AWS/SES";
97import * as Effect from "effect/Effect";
98import * as HttpServerResponse from "effect/http/HttpServerResponse";
99
100export default class Mailer extends AWS.Lambda.Function<Mailer>()(
101 "Mailer",
102 { main: import.meta.url },
103 Effect.gen(function* () {
104 const identity = yield* SES.EmailIdentity("Sender", {
105 emailIdentity: "mail.example.com",
106 });
107 const sendEmail = yield* SES.SendEmail(identity);
108
109 return {
110 fetch: Effect.gen(function* () {
111 yield* sendEmail({
112 FromEmailAddress: "hello@mail.example.com",
113 Destination: { ToAddresses: ["customer@example.com"] },
114 Content: {
115 Simple: {
116 Subject: { Data: "Welcome!" },
117 Body: { Text: { Data: "Hello from SES." } },
118 },
119 },
120 });
121 return yield* HttpServerResponse.json({ ok: true });
122 }).pipe(Effect.orDie),
123 };
124 }).pipe(Effect.provide(SES.SendEmailHttp)),
125) {}
126```
127
128For a domain identity, pass `FromEmailAddress` explicitly (any
129address at the domain); for an address identity it defaults to the
130identity itself. The callable also accepts `Content.Raw` for a
131pre-built MIME message and `Content.Template` for templated sends.
132To route through a configuration set — for open/click tracking or
133event publishing — bind it too: `SES.SendEmail(identity, configSet)`.
134
135## Authorize another account to send
136
137An `EmailIdentityPolicy` attaches a sending-authorization policy to
138an identity, letting another AWS account or IAM principal send as
139it:
140
141```typescript
142const policy = yield* SES.EmailIdentityPolicy("AllowPartner", {
143 emailIdentity: identity.emailIdentity,
144 policy: {
145 Version: "2012-10-17",
146 Statement: [
147 {
148 Effect: "Allow",
149 Principal: { AWS: "arn:aws:iam::111122223333:root" },
150 Action: ["ses:SendEmail"],
151 Resource: identity.identityArn,
152 },
153 ],
154 },
155});
156```
157
158Alchemy serializes this typed IAM policy at the SES API boundary and
159compares normalized document content when detecting drift.
160
161## Build a contact list
162
163A `ContactList` is a managed audience with subscription topics —
164the backbone of SES's list-management and one-click unsubscribe
165handling:
166
167```typescript
168const list = yield* SES.ContactList("Newsletter", {
169 description: "Weekly product newsletter",
170 topics: [
171 {
172 TopicName: "product-updates",
173 DisplayName: "Product Updates",
174 DefaultSubscriptionStatus: "OPT_IN",
175 },
176 {
177 TopicName: "promotions",
178 DisplayName: "Promotions",
179 DefaultSubscriptionStatus: "OPT_OUT",
180 },
181 ],
182});
183```
184
185`updateContactList` replaces the topic set wholesale, so the full
186desired list of topics is sent on every change. Deleting the list
187deletes all of its contacts.
188
189## Add contacts
190
191A `Contact` is a single address on a list, with its own per-topic
192subscription preferences:
193
194```typescript
195const contact = yield* SES.Contact("Subscriber", {
196 contactListName: list.contactListName,
197 emailAddress: "reader@example.com",
198 topicPreferences: [
199 { TopicName: "product-updates", SubscriptionStatus: "OPT_IN" },
200 { TopicName: "promotions", SubscriptionStatus: "OPT_OUT" },
201 ],
202});
203```
204
205The `(list, emailAddress)` pair is the stable key — changing either
206replaces the contact. Set `unsubscribeAll: true` to opt the contact
207out of every topic at once.
208
209## Brand the verification email
210
211A `CustomVerificationEmailTemplate` is the branded email SES sends
212when you verify a new address identity — replacing the plain
213default message:
214
215```typescript
216const template = yield* SES.CustomVerificationEmailTemplate("Verify", {
217 fromEmailAddress: "verify@mail.example.com",
218 templateSubject: "Please confirm your email",
219 templateContent:
220 "<html><body>Click the link to verify your address.</body></html>",
221 successRedirectionURL: "https://example.com/verified",
222 failureRedirectionURL: "https://example.com/verify-failed",
223});
224```
225
226Creating and managing the template works on any account. To
227actually *send* one, bind `SES.SendCustomVerificationEmail` — an
228account-level binding (the template has no ARN to scope to) whose
229implementation is `SES.SendCustomVerificationEmailHttp`:
230
231```typescript
232// Construction — inside the Lambda's Effect.gen body
233const sendVerification = yield* SES.SendCustomVerificationEmail();
234
235// runtime
236const { MessageId } = yield* sendVerification({
237 EmailAddress: "new-user@example.com",
238 TemplateName: yield* template.templateName,
239});
240```
241
242Sending a custom verification email requires production access —
243in the sandbox the call fails with the typed `BadRequestException`.
244
245## Isolate reputation with a dedicated IP pool
246
247A `DedicatedIpPool` groups dedicated IPs so different kinds of mail
248(marketing vs. transactional) build reputation independently:
249
250```typescript
251const pool = yield* SES.DedicatedIpPool("Transactional", {
252 scalingMode: "MANAGED",
253});
254```
255
256:::caution
257A dedicated IP pool provisions dedicated IP capacity and **starts
258billing immediately** — `MANAGED` pools bill for managed dedicated
259IP usage as soon as they exist, and `STANDARD` pools bill per
260dedicated IP you add. Only create pools you intend to pay for.
261:::
262
263`STANDARD` → `MANAGED` is applied in place; `MANAGED` → `STANDARD`
264has no API and replaces the pool.
265
266## Group sending into tenants
267
268A `Tenant` isolates a customer or business unit's sending — its own
269reputation metrics, sending status, and optional tenant-scoped
270suppression list — inside one SES account:
271
272```typescript
273const tenant = yield* SES.Tenant("CustomerA", {
274 suppression: { reasons: ["BOUNCE", "COMPLAINT"], scope: "TENANT" },
275});
276```
277
278Attach identities, configuration sets, or templates to the tenant
279with a `TenantResourceAssociation`:
280
281```typescript
282const link = yield* SES.TenantResourceAssociation("SenderLink", {
283 tenantName: tenant.tenantName,
284 resourceArn: identity.identityArn,
285});
286```
287
288A single resource can belong to multiple tenants. Deleting the
289tenant removes its associations but leaves the underlying resources
290in place.
291
292## Route sending across regions
293
294A `MultiRegionEndpoint` (global endpoint) splits sending traffic
295across a primary region — where the endpoint is created — and one
296or more secondary regions:
297
298```typescript
299const endpoint = yield* SES.MultiRegionEndpoint("Global", {
300 regions: ["eu-west-1"],
301});
302```
303
304:::note
305Provisioning is asynchronous: creation returns immediately with
306status `CREATING`, and the endpoint only becomes usable once it
307reaches `READY`. This resource does **not** wait for `READY` — poll
308`getMultiRegionEndpoint` yourself if you need to block on readiness.
309:::
310
311There is no update API, so changing the name or routes replaces the
312endpoint.
313
314## Manage account-wide settings
315
316`AccountSettings` is an account/region singleton for account-wide
317sending status, the suppression list, and Virtual Deliverability
318Manager (VDM). Only the aspects you specify are managed — omit the
319rest to leave them untouched:
320
321```typescript
322const settings = yield* SES.AccountSettings("Account", {
323 vdm: {
324 enabled: "ENABLED",
325 dashboardEngagementMetrics: "ENABLED",
326 },
327 suppression: { reasons: ["BOUNCE", "COMPLAINT"] },
328});
329```
330
331:::caution
332`sendingEnabled: false` **stops all sending for the entire
333account**, not just this stack's mail. Only manage it when you
334intend to pause account-wide sending.
335:::
336
337Deleting this resource is a no-op: these are account-global toggles
338with no single safe default, so Alchemy leaves them exactly as they
339are. Change the props and re-deploy to adjust them.
340
341## Monitor deliverability
342
343Enabling VDM (above) unlocks a set of read-only deliverability
344bindings you can call from a Lambda. Look up a single message's
345per-recipient event timeline with `GetMessageInsights`:
346
347```typescript
348// Construction — account-level binding, no resource argument
349const getInsights = yield* SES.GetMessageInsights();
350
351// runtime — MessageId returned by a prior SendEmail
352const { Insights } = yield* getInsights({ MessageId: messageId });
353```
354
355Fetch aggregated metric time-series — sends, deliveries, bounces,
356complaints, opens, clicks — with `BatchGetMetricData`:
357
358```typescript
359const getMetrics = yield* SES.BatchGetMetricData();
360
361const { Results } = yield* getMetrics({
362 Queries: [
363 {
364 Id: "sends",
365 Namespace: "VDM",
366 Metric: "SEND",
367 StartDate: new Date(Date.now() - 7 * 24 * 3600 * 1000),
368 EndDate: new Date(),
369 },
370 ],
371});
372```
373
374Provide each with its `*Http` layer (`SES.GetMessageInsightsHttp`,
375`SES.BatchGetMetricDataHttp`). Two more account-level bindings round
376out the set: `GetBlacklistReports` reports which anti-spam
377blacklists your dedicated IPs appear on, and
378`GetDomainStatisticsReport` returns inbox-placement statistics for a
379domain — the latter requires the SES deliverability dashboard
380subscription.
381
382Some of the SES surface isn't modeled as resources yet: the
383deliverability dashboard *subscription* itself, and the dedicated-IP
384data plane (assigning specific IPs to a pool and warming them). Use
385the console or the AWS SDK for those.
386
387## Where next
388
389- [Lambda](/aws/compute/lambda) — the runtime that sends.
390- [Email receiving](/aws/email/receiving) — the inbound side: SES
391 receipt rules into S3, SNS, and Lambda.
392- [CloudWatch](/aws/observability/cloudwatch) — alarm on bounce and
393 complaint metrics.
394
395Reference:
396
397- [EmailIdentity API reference](/providers/aws/ses/emailidentity)
398- [SendEmail API reference](/providers/aws/ses/sendemail)
399- [EmailIdentityPolicy API reference](/providers/aws/ses/emailidentitypolicy)
400- [ContactList API reference](/providers/aws/ses/contactlist) ·
401 [Contact API reference](/providers/aws/ses/contact)
402- [CustomVerificationEmailTemplate API reference](/providers/aws/ses/customverificationemailtemplate)
403- [DedicatedIpPool API reference](/providers/aws/ses/dedicatedippool)
404- [Tenant API reference](/providers/aws/ses/tenant) ·
405 [TenantResourceAssociation API reference](/providers/aws/ses/tenantresourceassociation)
406- [MultiRegionEndpoint API reference](/providers/aws/ses/multiregionendpoint)
407- [AccountSettings API reference](/providers/aws/ses/accountsettings)