What you'll learn
By Part 8 your services publish events, and by Part 4 they run on a platform that can reach the internet. Sooner or later somebody asks the obvious question: can it email me when that happens?
That question is smaller than it sounds and bigger than it looks. Sending one email from
Lambda is six lines of boto3. Building something that still
works at notification number four hundred β where product managers add a rule without a
deploy, a bad address doesn't silently swallow the other nine recipients, a replayed event
doesn't email everyone twice, and your domain doesn't end up in a spam folder β is a
system. This part builds that system:
- Prepare AWS β verify an identity, get the DNS right (SPF, DKIM, DMARC, MAIL FROM), leave the sandbox, and write an IAM policy that can send exactly one From address and nothing else.
- Design β the four moving parts of any notification system, and two real shapes we run in production: a typed registry in code, and configurable rules in a database.
- Build β the SES channel, event triggers, subject lines, templates that never throw, recipient resolution, and idempotency.
- Operate β why you must never retry a send, how bounces and complaints get your domain blocked, what to log, and how to test against production without emailing a customer.
The examples are two real systems: CargoNaut's notification service and JunctionNet's admin-api notification rules. They solve the same problem from opposite ends, which turns out to be the most useful thing about them.
SES is a transport, not a notification system
Start here, because getting this backwards is the single most expensive mistake in this area. Amazon SES accepts a message and tries to deliver it. That is the entire product. It does not know who should be notified, what the message should say, when to suppress a duplicate, or whether a human ever wanted this email. Those are your problems, and they are the whole job.
The failure mode is a codebase where ses.send_email(...) appears
in eleven places β in the document splitter, in the invoice importer, in a scheduled job.
Each call site grows its own recipient list, its own subject format, its own idea of what
happens when SES throws. Then someone asks to turn off one of them for one customer, and
the answer is a deploy.
Draw the line in exactly one place:
the code that did the work the notification system
ββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββββ
publishes a fact: decides: does anyone care?
DocumentSplitReadyForReview who? β recipients
ShipmentDelayed what? β subject + body
InvoiceImportFailed how? β channel (email β SES)
again? β idempotency
knows nothing about email knows nothing about documents
The test. If you can delete every notification in the system and the business logic still compiles and passes its tests, the line is in the right place. If deleting notifications breaks the document pipeline, the pipeline is sending email β and that will hurt later.
Prepare the AWS account: identity, region, and the sandbox
Before any code, SES needs three things to be true. All three are account-and-region scoped, and all three bite people who skip them.
1. A verified identity β a domain, not an address
SES will only send from an identity you have proven you control. You can verify a single
email address (noreply@junctionnet.ai) or a whole domain
(junctionnet.ai). Verify the domain. An address
identity means a new ticket every time someone wants
alerts@ or billing@; a domain
identity means every address under it works immediately, and it is the only form that
supports DKIM signing and a custom MAIL FROM.
2. The right region
SES identities do not travel. An identity verified in
us-east-1 does not exist in eu-west-1,
and a Lambda in the wrong region gets
MessageRejected: Email address is not verified β a message that
sends people hunting for a typo in an address that is perfectly fine. Pin the region
explicitly rather than inheriting whatever the Lambda happens to run in:
# src/env_vars.py β the region SES lives in, not the region the code runs in
NOTIFICATION_SENDER = os.getenv('NOTIFICATION_SENDER', 'JunctionNet <notifications@junctionnet.ai>')
NOTIFICATION_SES_REGION = os.getenv('NOTIFICATION_SES_REGION', 'us-east-1')
3. Production access β you start in a sandbox
Every new SES account is in the sandbox, and the sandbox is deliberately useless for production: you may only send to verified addresses, at a low daily cap and a low rate. This is the single most common "it works on my machine" in SES β your own address is verified, so your test lands, and the customer's does not.
Request production access per account and per region, early. It is a short form asking what you send, to whom, and how you handle bounces and unsubscribes. Answer it properly β "transactional notifications to authenticated users of our own product, recipients come from our user table, bounces and complaints feed the suppression list" is approved quickly. Vague answers get follow-up questions, and the review is not instant.
Sandbox per environment, too. Your dev account is almost certainly still in the sandbox, and that is fine β arguably correct. But it means a notification that works in prod will silently fail in dev for any recipient you haven't verified. Log the SES error body, not just "send failed", or you will debug this twice.
Deliverability: SPF, DKIM, DMARC and a custom MAIL FROM
This section is DNS, it is boring, and it is the difference between "our notifications work" and "our notifications work for Gmail users but Outlook eats them". Receiving mail servers decide whether to trust you. These four records are how you tell them.
| Record | What it proves | If you skip it |
|---|---|---|
| Domain verification | You control the domain. | SES refuses to send at all. |
| DKIM | The message was signed by you and wasn't altered in transit. | Unsigned mail. Heavily penalised; required by Gmail and Yahoo for bulk senders. |
| SPF | The sending server is allowed to send for your domain. | Fails alignment; lands you in spam. |
| Custom MAIL FROM | The bounce address is on your domain, not amazonses.com. | SPF aligns to Amazon's domain instead of yours, so DMARC can't pass on SPF. |
| DMARC | What receivers should do when SPF and DKIM disagree. | No policy, no reports, no idea who is spoofing you. |
All of it belongs in Terraform, next to the Route 53 zone that already exists:
# --- identity ------------------------------------------------------------
resource "aws_ses_domain_identity" "main" {
domain = var.ses_domain_name
}
resource "aws_route53_record" "amazonses_verification" {
zone_id = data.aws_route53_zone.main.zone_id
name = "_amazonses.${var.ses_domain_name}"
type = "TXT"
ttl = 600
records = [aws_ses_domain_identity.main.verification_token]
}
# --- DKIM: three CNAMEs, one per token. Do not hand-write these. ---------
resource "aws_ses_domain_dkim" "main" {
domain = aws_ses_domain_identity.main.domain
}
resource "aws_route53_record" "dkim" {
count = 3
zone_id = data.aws_route53_zone.main.zone_id
name = "${aws_ses_domain_dkim.main.dkim_tokens[count.index]}._domainkey.${var.ses_domain_name}"
type = "CNAME"
ttl = 600
records = ["${aws_ses_domain_dkim.main.dkim_tokens[count.index]}.dkim.amazonses.com"]
}
Then the custom MAIL FROM β a subdomain that owns your bounces β plus the SPF and DMARC records that make the whole set hang together:
resource "aws_ses_domain_mail_from" "main" {
domain = aws_ses_domain_identity.main.domain # the DOMAIN identity
mail_from_domain = "mail.${var.ses_domain_name}"
}
# MX for the MAIL FROM subdomain β must point at SES in YOUR region
resource "aws_route53_record" "mail_from_mx" {
zone_id = data.aws_route53_zone.main.zone_id
name = aws_ses_domain_mail_from.main.mail_from_domain
type = "MX"
ttl = 300
records = ["10 feedback-smtp.${var.aws_region}.amazonses.com"]
}
# SPF for the MAIL FROM subdomain
resource "aws_route53_record" "mail_from_spf" {
zone_id = data.aws_route53_zone.main.zone_id
name = aws_ses_domain_mail_from.main.mail_from_domain
type = "TXT"
ttl = 300
records = ["v=spf1 include:amazonses.com ~all"]
}
# DMARC β start at p=none and read the reports before tightening
resource "aws_route53_record" "dmarc" {
zone_id = data.aws_route53_zone.main.zone_id
name = "_dmarc.${var.ses_domain_name}"
type = "TXT"
ttl = 600
records = ["v=DMARC1; p=none; rua=mailto:dmarc@${var.ses_domain_name}"]
}
The MAIL FROM MX record is where this goes wrong. The value must be 10 feedback-smtp.<region>.amazonses.com β a host Amazon runs. Pointing it at feedback-smtp.yourdomain.com looks plausible, passes terraform apply, and quietly leaves the MAIL FROM domain in a permanent pending state, at which point SES falls back to amazonses.com and your SPF alignment is gone. Also note the resource takes the domain identity, not an email identity β a MAIL FROM on noreply@example.com is not a domain and will never verify.
Verify it from outside AWS. The console saying "verified" only covers the identity. Send one real message to a Gmail account, open Show original, and confirm three lines say PASS: SPF, DKIM, and DMARC. That takes a minute and is the only check that reflects what a receiving server actually does.
Terraform's newer aws_sesv2_* resources (aws_sesv2_email_identity, aws_sesv2_configuration_set) cover the same ground with less wiring and are the better choice for a new module. The aws_ses_* resources above are shown because that is what most existing platform repos β including ours β are already built on, and the DNS records are identical either way.
IAM: the smallest policy that can send
ses:SendEmail on "Resource": "*" is
the default thing people write, and it grants your Lambda the ability to send mail as
any verified identity in the account β including
invoices@, including your CEO's address if it was ever verified
for a test. A notification function needs one From address.
{
"Effect": "Allow",
"Action": ["ses:SendEmail", "ses:SendRawEmail"],
"Resource": [
"arn:aws:ses:us-east-1:123456789012:identity/junctionnet.ai",
"arn:aws:ses:us-east-1:123456789012:configuration-set/notifications"
],
"Condition": {
"StringEquals": {
"ses:FromAddress": "notifications@junctionnet.ai"
}
}
}
Three things are doing work there, and each one closes a real door:
| Element | What it stops |
|---|---|
| Identity ARN as the resource | Sending as a different verified domain in the same account. |
| Configuration-set ARN in the resource list | Nothing β but omit it and every send that names a config set fails with AccessDenied, which reads like a credentials problem and isn't. |
ses:FromAddress condition | A bug (or an injected value) sending as ceo@junctionnet.ai from a domain identity that legitimately covers it. |
No keys, ever. The function assumes its execution role and boto3 picks up short-lived credentials from the environment β the same principle as the OIDC section in Part 4. There is no SES API key to store. If you find yourself putting SMTP credentials in Parameter Store, you have chosen the SMTP interface by accident; use the API.
The shape of a notification system
Every notification system that survives contact with a product manager has the same four parts. Name them separately and you can change one without touching the others:
TRIGGER what happened an event on the bus
β (or an HTTP call, for "send now")
βΌ
RULE should we notify? match event β enabled rule/type
β org-scoped, enable/disable at runtime
βΌ
AUDIENCE who? resolve to addresses AT SEND TIME
β by role / user / team β not stored emails
βΌ
MESSAGE what does it say? subject + body, rendered from the event
β
βΌ
CHANNEL how does it leave? email β SES. (SMS, Slack, in-app later)
The ordering matters. Each stage can answer "no" and stop cheaply: no rule matched, no recipients resolved, channel unavailable. A notification that stops at stage two costs one indexed query, not an SES call.
Two real designs: a typed registry vs. configurable rules
There are two honest answers to "where do notifications live", and we run both. They are not a beginner version and an advanced version β they optimise for different people.
Design A β a typed registry in code (CargoNaut)
Each notification is a declared type: a key, a subject template, a body template, default channels, and a scope that controls who is even allowed to subscribe. Adding a notification is a code change and a deploy; changing who receives it is a database row and no deploy.
# src/domain/notification_types.py β the in-code contract
@dataclass(frozen=True)
class NotificationType:
key: str
description: str
subject_template: str
body_template: str
default_channels: tuple[str, ...] = ("email",)
scope: str = SCOPE_INTERNAL # internal | org
NOTIFICATION_TYPES: dict[str, NotificationType] = {
"document_split_ready": NotificationType(
key="document_split_ready",
description="A document reached Split Review and is waiting for a human.",
subject_template="Document ready for Split Review β {document_name}",
body_template=(
"A document has reached the Split Review queue.\n\n"
"Document: {document_name}\n"
"Document ID: {document_id}\n\n"
"Review it here:\n{link}\n"
),
scope=SCOPE_ORG,
),
}
The payoff is that the set of notifications is reviewable. It shows up in a pull request,
it has tests, and a template referencing {document_name} sits
three lines from the handler that supplies it.
Design B β configurable rules in the database (JunctionNet)
A rule is a row: a name, a subject, a body template, a JSON list of triggers, a JSON list
of recipients, and an enabled flag. Admins create and edit them
in the portal. Nothing ships.
# alembic β notification_rules
sa.Column('organization_name', sa.String(100), nullable=True) # null = global
sa.Column('subject', sa.String(500), nullable=False)
sa.Column('body_template', sa.Text(), nullable=False)
sa.Column('triggers', JSONB, server_default='[]') # [{source, event}]
sa.Column('recipient_usernames', JSONB, server_default='[]')
sa.Column('enabled', sa.Boolean(), server_default=sa.true())
# GIN index β the dispatcher runs a `triggers @> [...]` containment
# match on EVERY event, so this index is not optional.
op.create_index('idx_notification_rules_triggers', 'notification_rules',
['triggers'], postgresql_using='gin')
Pick by who needs to change things, and how often:
| A β typed registry | B β configurable rules | |
|---|---|---|
| Add a notification | Code + deploy | A form in the portal |
| Reviewable in a PR | Yes | No |
| Templates tested in CI | Yes | Not really |
| Per-customer variation | Awkward | Natural β the rule is org-scoped |
| Typical trigger | A dedicated EventBridge rule per type | One catch-all subscriber over the whole bus |
| Blast radius of a bad template | Caught before merge | Live, immediately |
| Best for | Notifications the product owns | Notifications customers and ops own |
You will eventually want both, and that's fine: a typed registry for the notifications that are part of the product's contract, plus a rules table for the long tail of "email me when X happens in my org". They share the renderer, the recipient resolver, and the channel. Only the "should we notify?" stage differs.
The channel seam β why SES sits behind an interface
This is the one abstraction worth building on day one, and it is small enough to fit on a screen. A channel is one delivery medium. The engine renders once and hands the result to each channel:
# src/application/channels/base.py
class NotificationChannel(ABC):
"""One delivery medium. Implementations are registered by `name`."""
name: str = ""
@abstractmethod
def deliver(
self, *, recipients: list[str], subject: str,
body_text: str, body_html: str | None = None,
) -> dict:
"""Deliver. Returns provider metadata (message id).
Raises ChannelDeliveryError on failure.
"""
That interface is why this article can be honest about something: CargoNaut's notification service delivers through Resend, not SES. When the same design was ported into JunctionNet's admin-api β where Resend isn't available and SES is already part of the platform β the change was one class. Producers, events, templates, recipient resolution, the API payload and the tests were untouched. The docstring in the ported service still records it in one line: "Adapted from CargoNaut's dispatch_service (Resend β SES; supabase β pgsql)."
That is the actual argument for the seam. Not "we might switch providers one day" β nobody believes that when they're writing it. It's that email is one channel of several. Slack, SMS, in-app and webhooks all arrive eventually, and each one is a new implementation behind deliver() rather than an if channel == ... growing through the engine.
The SES channel, in full
Here is the entire SES-specific surface of the system. Everything else in this article is provider-agnostic.
# src/infrastructure/repositories/ses_email_repository.py
class SESEmailRepository:
"""Thin wrapper over SES. `sender` must be a verified identity
in the deployment account and region."""
def __init__(self, client, sender: str, configuration_set: str | None = None):
self.client = client
self.sender = sender
self.configuration_set = configuration_set
def send(self, *, to, subject: str, body_text: str,
body_html: str = None) -> str:
recipients = [a for a in (to or []) if a]
if not recipients:
raise ValueError("No recipients to send to")
body = {"Text": {"Data": body_text or "", "Charset": "UTF-8"}}
if body_html:
body["Html"] = {"Data": body_html, "Charset": "UTF-8"}
kwargs = {
"Source": self.sender,
"Destination": {"ToAddresses": recipients},
"Message": {
"Subject": {"Data": subject or "", "Charset": "UTF-8"},
"Body": body,
},
}
if self.configuration_set:
kwargs["ConfigurationSetName"] = self.configuration_set
return self.client.send_email(**kwargs).get("MessageId")
Four details in there earn their place:
Charset: "UTF-8"everywhere. Omit it and a shipper called KΓΌhne or a Dutch street name arrives as mojibake. It is a one-word fix that nobody makes until a customer complains.- Text first, HTML optional. Always send a text part. Some clients render it, all spam filters read it, and a text-only notification is a perfectly good notification.
- Return the
MessageId. It is the only handle you will have when someone says "I never got it" β see Observability below. - A configuration set. Optional to SES, not optional to you: it is how sends get tagged, metered, and routed to bounce handling. Create one and always name it.
v1 vs v2. The code above uses the classic boto3.client('ses') API, which is what most existing services are on and is not deprecated. New services should prefer boto3.client('sesv2'), whose send_email takes FromEmailAddress and a Content block, and which is where newer features (list management, tenant-level reputation) land. The shape of the wrapper is identical β that's the point of having one.
Triggering from events, not from the code that did the work
The document pipeline already publishes the fact it produced. The notification service subscribes. Two ways to wire it, matching the two designs.
A dedicated rule per notification type
Precise and cheap: the Lambda only runs for events that could possibly matter, and the handler knows exactly what it received.
DocumentSplitReadyFunction:
Type: AWS::Serverless::Function
Properties:
Handler: src/handlers/event_bus/document_split_ready.handler
# Never retry: a re-delivered event is a second email.
EventInvokeConfig:
MaximumRetryAttempts: 0
Events:
SplitReady:
Type: EventBridgeRule
Properties:
EventBusName: !Sub cargonaut-event-bus-${Stage}
Pattern:
source: [ 'cargonaut.document.ai_splitter' ]
detail-type: [ 'DocumentSplitReadyForReview' ]
def handler(event, context):
detail = event.get("detail", {}) or {}
document = detail.get("document") or {}
document_id = detail.get("document_split_id") or document.get("id")
document_name = detail.get("filename") or "Untitled document"
service = NotificationFactory("Notification").build()
return service.send(
notification_type="document_split_ready",
context={
"document_name": document_name,
"document_id": document_id,
"link": f"{env_vars.OPS_TOOL_BASE_URL}/queue",
},
# same document, same notification, one email
idempotency_key=f"document_split_ready:{document_id}",
)
One catch-all subscriber over the whole bus
This is what a rules table needs: admins invent new triggers at runtime, so the dispatcher has to see everything.
NotificationDispatchFunction:
Properties:
Handler: src/handlers/events/notification_dispatch.handler
EventInvokeConfig:
MaximumRetryAttempts: 0
Events:
AllAppEvents:
Type: EventBridgeRule
Properties:
EventBusName: !Sub Platform-Apps-events-${ApiEnv}
Pattern:
account: [ !Ref AWS::AccountId ] # i.e. everything
# Infra/no-op events must never fan out to notifications.
EXCLUDED_DETAIL_TYPES = {"WarmupLambda", "WarmupApp", "WarmupEvent"}
def handler(event, context):
detail_type = event.get("detail-type") or ""
if detail_type in EXCLUDED_DETAIL_TYPES:
return {"skipped": True, "reason": "excluded detail-type"}
try:
service = NotificationDispatchFactory(service="NotificationDispatch").build()
return service.dispatch_event(
source=event.get("source") or "",
detail_type=detail_type,
detail=event.get("detail") or {},
)
except Exception as exc:
# Swallow: a raise here means EB retries, which means duplicate sends.
logger.exception("Notification dispatch failed: %s" % exc)
return {"matched": 0, "error": str(exc)}
The catch-all has a bill attached. Every event on the bus becomes a Lambda invocation and at least one indexed query β including your highest-volume event, at 3am, forever. Keep the no-match path to a single GIN-indexed lookup that returns zero rows, exclude warmup and other infra chatter by detail-type, and if one source dominates your traffic, narrow the rule pattern to exclude it. A catch-all that also does a join is a catch-all that will show up in your Lambda bill.
Keep the HTTP door too. Both systems expose POST /notifications/{id}/test, which runs the exact same render-and-send path with a sample context. Triggers are doors; the engine is the room. Support has a "send it again" button, and you have a way to verify a template without producing a real business event.
The subject line
The subject is the only part of a notification most people read, and the only part that shows up on a lock screen. It deserves more thought than it usually gets, and it is template-driven, so it is cheap to get right.
subject_template = "Document ready for Split Review β {document_name}"
subject_template = "Scraper issue flagged β {bl_number}"
The rules that actually matter, in priority order:
| Rule | Why |
|---|---|
| Lead with what happened, end with which one | Document ready for Split Review β INV-4471.pdf. Mail clients truncate around 60β70 characters and phones far sooner; the words that survive should be the ones that identify the event. |
| Always include the identifier | A subject with no BL number, invoice number or filename forces the reader to open the mail to learn whether it matters. Ten of those a day and they stop opening any of them. |
| Keep the prefix stable | Everything before the β should be constant per notification type. That is what makes a Gmail filter or an Outlook rule possible, and users will build them. |
| Prefix non-production | [dev] / [staging] from the stage variable, not by hand. A test that looks exactly like production is how someone actions a fake shipment delay. |
| No secrets, no PII | Subjects are logged by every mail server on the path, indexed by the client, and displayed on locked phones. Put the reference in the subject and the detail in the body. |
| No "Notification from β¦" | The From address already says who it's from. Those characters are the ones being truncated. |
Environment prefixing belongs in the renderer, not in every template:
def _subject(self, template: str, context: dict) -> str:
subject = render(template, context)
# stage is injected by the service, never typed into a template
return subject if self.environment == "production" else f"[{self.environment}] {subject}"
A placeholder in a delivered subject is a bug report from your own system. If a user sees Document ready β {document_name}, the event didn't carry the field the template expected. Because the renderer must never throw (next section), this failure is silent β so assert on it: scan rendered subjects for a leftover { or {{ and log a warning. It is three lines, and it is the only thing standing between a template typo and a month of ugly emails.
Bodies: templates that render, never throw
A notification is best-effort by nature. A body template referring to a field that a particular event happened not to carry must not take down the send β and must not take down the other notifications matched by the same event. Both systems solve it the same way, with a renderer that degrades instead of raising.
# Design A β str.format_map with a forgiving dict
class _SafeDict(dict):
"""Leaves unknown placeholders as an em dash instead of raising KeyError."""
def __missing__(self, key: str) -> str:
return "β"
def render(template: str, context: dict) -> str:
return template.format_map(_SafeDict(context or {}))
# Design B β {{ dotted.path }} against the whole event, unknown β ""
_PLACEHOLDER = re.compile(r"\{\{\s*([\w.]+)\s*\}\}")
def render(template: str, context: dict) -> str:
return _PLACEHOLDER.sub(lambda m: _lookup(context, m.group(1)), template or "")
Design B renders against a fixed context shape built from any event, which is what makes admin-authored templates possible at all β an admin writing a rule needs to know what they can reference without reading your event catalogue:
{
"environment": "production", # {{environment}}
"application": "JunctionNet", # {{application}}
"source": "admin.settings", # {{source}}
"event": "UserDeactivated", # {{event}}
"organization": "acme", # {{organization}}
"username": "jdoe", # {{username}}
"payload": { ... } # {{payload.bl_number}}
}
Deep-link, don't describe. Every body should end with a URL that lands on the exact object β built from a base URL environment variable, never hardcoded, so dev links point at dev. A notification whose only call to action is "log in and look for it" wastes the reader's time and yours.
If you add HTML, escape the context. Text bodies are safe by construction. The moment you introduce an HTML part, every interpolated value β a filename, a customer-supplied reference, a note field β is untrusted input being written into markup, and template rendering is not escaping. Use a real templating engine with autoescaping on, and keep sending the text part alongside it.
Recipients: by reference, and one message each
The instinct is to store an email address on the subscription. Do not. Store a reference and resolve it at send time, so a team change, a role change or a corrected address is reflected on the next notification with no data migration:
subscriber_type subscriber_ref resolves to
βββββββββββββββ βββββββββββββββββββ ββββββββββββββββββββββββββββββββββ
email ops@junctionnet.ai itself (escape hatch β use sparingly)
user <user id> SELECT email FROM users WHERE id = β¦
role admin every user with that role IN THIS ORG
team customer_success a config-defined distribution list
def _resolve_one(self, sub: dict, *, session, org_id: str | None) -> list[str]:
stype, ref = sub.get("subscriber_type"), sub.get("subscriber_ref")
if not ref:
return []
if stype == SUBSCRIBER_EMAIL: return [ref]
if stype == SUBSCRIBER_TEAM: return list(self._teams.get(ref, []))
if stype == SUBSCRIBER_USER: return self._emails_for_user(session, ref)
if stype == SUBSCRIBER_ROLE:
# A role is meaningless without an org β never resolve it globally.
sub_org = sub.get("org_id") or org_id
if not sub_org:
logger.warning("role subscription %s has no org to resolve against", ref)
return []
return self._emails_for_role(session, sub_org, ref)
return []
Org scoping is a security control here, not a feature. A role subscription resolved without an org id returns every admin on the platform, and the body contains another customer's shipment. Refuse to resolve rather than widen β and on the rules side, skip any rule whose org doesn't match the event's org. This is the same org_id discipline as Part 11: derive it from the verified context, never from something the caller supplied.
One message per recipient
Once you have the list, the obvious thing is to pass all of it in
ToAddresses. That is one API call instead of five, and it is
wrong for transactional notifications, for three reasons that all arrive later:
- Everyone sees everyone. Internal lists leak to customers, and customer addresses leak to each other. This is a data-protection incident wearing a convenience costume.
- One bad address poisons the batch. A single hard bounce is recorded against a message that had four other, valid recipients β and your bounce-rate maths becomes fiction.
- Suppression is per-address. When one recipient lands on the suppression list, the behaviour of a multi-recipient send is not something you want to reason about at 2am.
for address in recipients: # one send per person
try:
message_id = ses.send(to=[address], subject=subject, body_text=body)
results.append({"to": address, "status": "sent", "id": message_id})
except Exception as exc: # one failure must not stop the rest
results.append({"to": address, "status": "failed", "error": str(exc)})
The cost is real but small: SES bills per recipient either way, so you are paying for API
calls and latency, not postage. Mind the per-second send rate if a single event fans out to
hundreds of people β at that size, hand the fan-out to a queue rather than a
for loop inside one Lambda invocation.
Idempotency: the same event twice, one email
EventBridge guarantees at-least-once delivery. Your pipeline will replay. Someone will re-run a failed job. Each of those is a second copy of an email a human already read, and duplicate notifications destroy trust in a notification system faster than missing ones do.
The fix is a deterministic key derived from the thing being notified about β never from a timestamp or a UUID generated at send time β with a unique index behind it:
idempotency_key = f"document_split_ready:{document_id}"
# in the engine, before rendering anything
if idempotency_key:
existing = self.repository.find_by_idempotency_key(idempotency_key)
if existing is not None:
logger.info("Idempotent replay for key=%s β returning existing", idempotency_key)
return self.repository._serialize(existing)
Note where the check sits: before the render and the SES call, and it returns the original result rather than an error. A replay is not a failure β the caller asked for a notification to exist, and it does.
Choose the key from the business event, not the transport. document_split_ready:{document_id} is right: it stays the same across a replay, a retry, and a manual re-run. The EventBridge event id is wrong β a replay generates a new one, which is exactly the case you're defending against.
Never retry a send
Every other Lambda in your platform should retry. This one must not. An asynchronous invocation that raises gets retried twice by default, and each retry that reaches SES after a partial success is another email in somebody's inbox.
# SAM β on every notification function, without exception
EventInvokeConfig:
MaximumRetryAttempts: 0
Which forces a discipline in the handler, and it is a good one:
| Layer | Rule |
|---|---|
| Channel | Wrap the provider error in ChannelDeliveryError. Never let a boto3 exception escape. |
| Engine | Collect per-recipient and per-rule errors into the result. One failed rule must not abort the others. |
| Handler | Catch everything and return a result object. A raise here is a duplicate email. |
| Record | Persist status sent / failed / skipped with the error text. That row is the retry queue β a human decides. |
"Swallow the exception" contradicts everything you normally do, so write down why. A notification is best-effort and its side effect is irreversible: you cannot un-send an email, but you can always look at a failed row and send it again on purpose. Reliability here means at most once, not at least once.
Bounces and complaints β the thing that gets your domain blocked
This is the section people skip, and it is the one that ends with the whole company unable to send mail. AWS tracks your account's bounce rate and complaint rate. Cross the thresholds and you get a review; keep going and sending is paused β for every service in that account, not just the noisy one.
| Signal | Healthy | What it means |
|---|---|---|
| Hard bounce | Well under 5% | The address doesn't exist. Never send to it again β SES will suppress it for you, and re-sending anyway is what pushes the rate up. |
| Complaint ("mark as spam") | Well under 0.1% | A real person said they didn't want this. The technical fix is suppression; the actual fix is sending less. |
| Soft bounce | β | Mailbox full, greylisting. Transient β but do not build your own retry loop around it. |
You cannot manage what you cannot see, and bounces are asynchronous: SES returns a
MessageId happily and the rejection arrives seconds or minutes
later. Route those events somewhere you'll look:
send βββΆ SES βββΆ the internet
β
ββ configuration set "notifications"
ββ event destination βββΆ SNS topic / EventBridge
ββ Bounce βββΆ mark the address dead, alarm on rate
ββ Complaint βββΆ unsubscribe them, and ask why
ββ Delivery βββΆ close the loop on "I never got it"
ββ Reject βββΆ your own bug (bad content, virus)
SES also maintains an account-level suppression list
that automatically holds addresses that hard-bounced or complained. Leave it on. Then mirror
the signal into your own user table β a email_status column
beats discovering, months later, that a whole customer's notifications have been silently
dropped because one address went bad.
Transactional does not mean exempt. "Users asked for these" is true and irrelevant: a recipient who marks your shipment alert as spam counts exactly the same as a marketing complaint. Give every non-critical notification an off switch that works β a subscription row an admin can delete, or a link in the footer β and the complaint rate looks after itself.
Observability: what to log, and how to test in production
Every send should leave a row. Not a log line that ages out in thirty days β a row you can join to a user and a business object, because the question you will be asked is always the same shape: "did Maria get the delay notice on shipment 4471, and when?"
| Field | Why you'll want it |
|---|---|
notification_type / rule id | Which template produced this. |
status | sent / failed / skipped. Skipped is not a failure β it usually means nobody was subscribed, and that's an answer. |
recipients | Resolved addresses, as actually sent. |
provider_message_ids | The SES MessageId β the join key to bounce and delivery events. |
subject + context | Reproduce the exact render six weeks later, after the template changed. |
idempotency_key | Explains the replay that didn't send. |
error | The provider's message, verbatim. "send failed" helps nobody. |
For testing, SES gives you addresses that behave badly on demand and β crucially β do not count against your reputation. Use them in integration tests and in the dev account:
bounce@simulator.amazonses.com hard bounce
complaint@simulator.amazonses.com complaint
ooto@simulator.amazonses.com out-of-office auto-reply
suppressionlist@simulator.amazonses.com already on the suppression list
success@simulator.amazonses.com clean delivery
The one safety net worth building. In every non-production stage, route all recipients to an override address before the send β one environment variable, checked in the channel, not in every handler. Then a production database restored into staging cannot email four hundred real customers, which is a thing that happens to somebody every year.
Costs, quotas, and when SES is the wrong answer
SES is roughly ten cents per thousand emails plus data β cheap enough that cost never drives the design. Two limits do:
- Sending quota β messages per 24 hours, per account and region. It starts modest after you leave the sandbox and rises automatically as you send clean volume.
- Maximum send rate β messages per second. This is the one a fan-out loop hits. Exceed it and you get
Throttling; handle it with a queue and a concurrency cap, not asleep.
And be honest about the cases where a raw transport is the wrong tool:
| You need⦠| Reach for |
|---|---|
| Transactional mail from your own AWS account, at low cost, with IAM instead of API keys | SES β this article. |
| Designed, responsive HTML templates edited by non-engineers | A template layer on top of SES, or a product like Resend / Postmark. SES has templates, but no editor. |
| Marketing campaigns, segmentation, unsubscribe management | Not SES. Keep marketing mail on a different domain and reputation from your transactional mail. |
| Fan-out to many subscribers with no rendering | SNS. |
| Alerts for engineers about infrastructure | CloudWatch alarms β SNS β Slack/PagerDuty. Do not route these through the product's notification system. |
Where this connects: the events these notifications react to are defined in Part 8; the Lambda, SAM template and layered service structure are Part 6; the verified domain, Route 53 zone and least-privilege role live in the platform layer from Part 4; and the pipeline that promotes all of it dev β prod is Part 12.
A notification system you can hand over looks like this: producers publish facts and know nothing about email; one engine decides, renders and records; SES sits behind a channel interface with a scoped IAM policy and a verified, DKIM-signed domain; nothing retries; every send leaves a row; and bounces come back to a place somebody actually looks. Everything else β SMS, Slack, in-app, a digest instead of ten separate mails β is a new implementation behind deliver().