| 1 | --- |
| 2 | title: Sending & managing email |
| 3 | description: 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 |
| 7 | an email address you own — and a runtime **binding** that lets a |
| 8 | Lambda send through it. Around that core, SES v2 adds the pieces |
| 9 | you need to run email at scale: authorization policies, contact |
| 10 | lists, dedicated IP pools, per-tenant isolation, multi-region |
| 11 | endpoints, account-wide settings, and deliverability insights. |
| 12 | |
| 13 | The 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 |
| 39 | New SES accounts start in the **sandbox**: you can only send to |
| 40 | verified identities or the mailbox simulator |
| 41 | (`success@simulator.amazonses.com`), and volume is capped. Request |
| 42 | production access from the SES console to lift both limits. |
| 43 | ::: |
| 44 | |
| 45 | ## Create the Stack |
| 46 | |
| 47 | The resource snippets below run inside a Stack's `Effect.gen` |
| 48 | body: |
| 49 | |
| 50 | ```typescript |
| 51 | // alchemy.run.ts |
| 52 | import * as Alchemy from "alchemy"; |
| 53 | import * as AWS from "alchemy/AWS"; |
| 54 | import * as SES from "alchemy/AWS/SES"; |
| 55 | import * as Effect from "effect/Effect"; |
| 56 | |
| 57 | export 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 | |
| 69 | An identity is a domain or a single address. A domain identity |
| 70 | generates Easy DKIM tokens you publish as CNAME records: |
| 71 | |
| 72 | ```typescript |
| 73 | const identity = yield* SES.EmailIdentity("Sender", { |
| 74 | emailIdentity: "mail.example.com", |
| 75 | }); |
| 76 | // publish identity.dkimTokens as CNAME records to finish verifying |
| 77 | ``` |
| 78 | |
| 79 | Each token in `identity.dkimTokens` is published as |
| 80 | `{token}._domainkey.mail.example.com CNAME {token}.dkim.amazonses.com`. |
| 81 | For an address identity (`hello@example.com` instead of a domain), |
| 82 | SES emails a verification link and there are no DKIM records to |
| 83 | publish. 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 |
| 89 | returns 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 |
| 95 | import * as AWS from "alchemy/AWS"; |
| 96 | import * as SES from "alchemy/AWS/SES"; |
| 97 | import * as Effect from "effect/Effect"; |
| 98 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 99 | |
| 100 | export 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 | |
| 128 | For a domain identity, pass `FromEmailAddress` explicitly (any |
| 129 | address at the domain); for an address identity it defaults to the |
| 130 | identity itself. The callable also accepts `Content.Raw` for a |
| 131 | pre-built MIME message and `Content.Template` for templated sends. |
| 132 | To route through a configuration set — for open/click tracking or |
| 133 | event publishing — bind it too: `SES.SendEmail(identity, configSet)`. |
| 134 | |
| 135 | ## Authorize another account to send |
| 136 | |
| 137 | An `EmailIdentityPolicy` attaches a sending-authorization policy to |
| 138 | an identity, letting another AWS account or IAM principal send as |
| 139 | it: |
| 140 | |
| 141 | ```typescript |
| 142 | const 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 | |
| 158 | Alchemy serializes this typed IAM policy at the SES API boundary and |
| 159 | compares normalized document content when detecting drift. |
| 160 | |
| 161 | ## Build a contact list |
| 162 | |
| 163 | A `ContactList` is a managed audience with subscription topics — |
| 164 | the backbone of SES's list-management and one-click unsubscribe |
| 165 | handling: |
| 166 | |
| 167 | ```typescript |
| 168 | const 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 |
| 186 | desired list of topics is sent on every change. Deleting the list |
| 187 | deletes all of its contacts. |
| 188 | |
| 189 | ## Add contacts |
| 190 | |
| 191 | A `Contact` is a single address on a list, with its own per-topic |
| 192 | subscription preferences: |
| 193 | |
| 194 | ```typescript |
| 195 | const 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 | |
| 205 | The `(list, emailAddress)` pair is the stable key — changing either |
| 206 | replaces the contact. Set `unsubscribeAll: true` to opt the contact |
| 207 | out of every topic at once. |
| 208 | |
| 209 | ## Brand the verification email |
| 210 | |
| 211 | A `CustomVerificationEmailTemplate` is the branded email SES sends |
| 212 | when you verify a new address identity — replacing the plain |
| 213 | default message: |
| 214 | |
| 215 | ```typescript |
| 216 | const 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 | |
| 226 | Creating and managing the template works on any account. To |
| 227 | actually *send* one, bind `SES.SendCustomVerificationEmail` — an |
| 228 | account-level binding (the template has no ARN to scope to) whose |
| 229 | implementation is `SES.SendCustomVerificationEmailHttp`: |
| 230 | |
| 231 | ```typescript |
| 232 | // Construction — inside the Lambda's Effect.gen body |
| 233 | const sendVerification = yield* SES.SendCustomVerificationEmail(); |
| 234 | |
| 235 | // runtime |
| 236 | const { MessageId } = yield* sendVerification({ |
| 237 | EmailAddress: "new-user@example.com", |
| 238 | TemplateName: yield* template.templateName, |
| 239 | }); |
| 240 | ``` |
| 241 | |
| 242 | Sending a custom verification email requires production access — |
| 243 | in the sandbox the call fails with the typed `BadRequestException`. |
| 244 | |
| 245 | ## Isolate reputation with a dedicated IP pool |
| 246 | |
| 247 | A `DedicatedIpPool` groups dedicated IPs so different kinds of mail |
| 248 | (marketing vs. transactional) build reputation independently: |
| 249 | |
| 250 | ```typescript |
| 251 | const pool = yield* SES.DedicatedIpPool("Transactional", { |
| 252 | scalingMode: "MANAGED", |
| 253 | }); |
| 254 | ``` |
| 255 | |
| 256 | :::caution |
| 257 | A dedicated IP pool provisions dedicated IP capacity and **starts |
| 258 | billing immediately** — `MANAGED` pools bill for managed dedicated |
| 259 | IP usage as soon as they exist, and `STANDARD` pools bill per |
| 260 | dedicated IP you add. Only create pools you intend to pay for. |
| 261 | ::: |
| 262 | |
| 263 | `STANDARD` → `MANAGED` is applied in place; `MANAGED` → `STANDARD` |
| 264 | has no API and replaces the pool. |
| 265 | |
| 266 | ## Group sending into tenants |
| 267 | |
| 268 | A `Tenant` isolates a customer or business unit's sending — its own |
| 269 | reputation metrics, sending status, and optional tenant-scoped |
| 270 | suppression list — inside one SES account: |
| 271 | |
| 272 | ```typescript |
| 273 | const tenant = yield* SES.Tenant("CustomerA", { |
| 274 | suppression: { reasons: ["BOUNCE", "COMPLAINT"], scope: "TENANT" }, |
| 275 | }); |
| 276 | ``` |
| 277 | |
| 278 | Attach identities, configuration sets, or templates to the tenant |
| 279 | with a `TenantResourceAssociation`: |
| 280 | |
| 281 | ```typescript |
| 282 | const link = yield* SES.TenantResourceAssociation("SenderLink", { |
| 283 | tenantName: tenant.tenantName, |
| 284 | resourceArn: identity.identityArn, |
| 285 | }); |
| 286 | ``` |
| 287 | |
| 288 | A single resource can belong to multiple tenants. Deleting the |
| 289 | tenant removes its associations but leaves the underlying resources |
| 290 | in place. |
| 291 | |
| 292 | ## Route sending across regions |
| 293 | |
| 294 | A `MultiRegionEndpoint` (global endpoint) splits sending traffic |
| 295 | across a primary region — where the endpoint is created — and one |
| 296 | or more secondary regions: |
| 297 | |
| 298 | ```typescript |
| 299 | const endpoint = yield* SES.MultiRegionEndpoint("Global", { |
| 300 | regions: ["eu-west-1"], |
| 301 | }); |
| 302 | ``` |
| 303 | |
| 304 | :::note |
| 305 | Provisioning is asynchronous: creation returns immediately with |
| 306 | status `CREATING`, and the endpoint only becomes usable once it |
| 307 | reaches `READY`. This resource does **not** wait for `READY` — poll |
| 308 | `getMultiRegionEndpoint` yourself if you need to block on readiness. |
| 309 | ::: |
| 310 | |
| 311 | There is no update API, so changing the name or routes replaces the |
| 312 | endpoint. |
| 313 | |
| 314 | ## Manage account-wide settings |
| 315 | |
| 316 | `AccountSettings` is an account/region singleton for account-wide |
| 317 | sending status, the suppression list, and Virtual Deliverability |
| 318 | Manager (VDM). Only the aspects you specify are managed — omit the |
| 319 | rest to leave them untouched: |
| 320 | |
| 321 | ```typescript |
| 322 | const 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 |
| 333 | account**, not just this stack's mail. Only manage it when you |
| 334 | intend to pause account-wide sending. |
| 335 | ::: |
| 336 | |
| 337 | Deleting this resource is a no-op: these are account-global toggles |
| 338 | with no single safe default, so Alchemy leaves them exactly as they |
| 339 | are. Change the props and re-deploy to adjust them. |
| 340 | |
| 341 | ## Monitor deliverability |
| 342 | |
| 343 | Enabling VDM (above) unlocks a set of read-only deliverability |
| 344 | bindings you can call from a Lambda. Look up a single message's |
| 345 | per-recipient event timeline with `GetMessageInsights`: |
| 346 | |
| 347 | ```typescript |
| 348 | // Construction — account-level binding, no resource argument |
| 349 | const getInsights = yield* SES.GetMessageInsights(); |
| 350 | |
| 351 | // runtime — MessageId returned by a prior SendEmail |
| 352 | const { Insights } = yield* getInsights({ MessageId: messageId }); |
| 353 | ``` |
| 354 | |
| 355 | Fetch aggregated metric time-series — sends, deliveries, bounces, |
| 356 | complaints, opens, clicks — with `BatchGetMetricData`: |
| 357 | |
| 358 | ```typescript |
| 359 | const getMetrics = yield* SES.BatchGetMetricData(); |
| 360 | |
| 361 | const { 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 | |
| 374 | Provide each with its `*Http` layer (`SES.GetMessageInsightsHttp`, |
| 375 | `SES.BatchGetMetricDataHttp`). Two more account-level bindings round |
| 376 | out the set: `GetBlacklistReports` reports which anti-spam |
| 377 | blacklists your dedicated IPs appear on, and |
| 378 | `GetDomainStatisticsReport` returns inbox-placement statistics for a |
| 379 | domain — the latter requires the SES deliverability dashboard |
| 380 | subscription. |
| 381 | |
| 382 | Some of the SES surface isn't modeled as resources yet: the |
| 383 | deliverability dashboard *subscription* itself, and the dedicated-IP |
| 384 | data plane (assigning specific IPs to a pool and warming them). Use |
| 385 | the 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 | |
| 395 | Reference: |
| 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) |