← Workshops Β· Part 9 Β· Notifications

Sending Notifications with Amazon SES

Email is the last mile of an event-driven system. How to prepare SES so your mail actually arrives, then wire events β†’ rules β†’ subject β†’ send β€” without the code that did the work knowing anything about email.

πŸ“ Test yourself ↓
35 min readNotificationsRead or present

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:

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

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.

RecordWhat it provesIf you skip it
Domain verificationYou control the domain.SES refuses to send at all.
DKIMThe message was signed by you and wasn't altered in transit.Unsigned mail. Heavily penalised; required by Gmail and Yahoo for bulk senders.
SPFThe sending server is allowed to send for your domain.Fails alignment; lands you in spam.
Custom MAIL FROMThe 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.
DMARCWhat 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:

ElementWhat it stops
Identity ARN as the resourceSending as a different verified domain in the same account.
Configuration-set ARN in the resource listNothing β€” 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 conditionA 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.

Design

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 registryB β€” configurable rules
Add a notificationCode + deployA form in the portal
Reviewable in a PRYesNo
Templates tested in CIYesNot really
Per-customer variationAwkwardNatural β€” the rule is org-scoped
Typical triggerA dedicated EventBridge rule per typeOne catch-all subscriber over the whole bus
Blast radius of a bad templateCaught before mergeLive, immediately
Best forNotifications the product ownsNotifications 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.

Build

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:

πŸ“Œ

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:

RuleWhy
Lead with what happened, end with which oneDocument 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 identifierA 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 stableEverything 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 PIISubjects 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:

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.

Operate

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:

LayerRule
ChannelWrap the provider error in ChannelDeliveryError. Never let a boto3 exception escape.
EngineCollect per-recipient and per-rule errors into the result. One failed rule must not abort the others.
HandlerCatch everything and return a result object. A raise here is a duplicate email.
RecordPersist 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.

SignalHealthyWhat it means
Hard bounceWell 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?"

FieldWhy you'll want it
notification_type / rule idWhich template produced this.
statussent / failed / skipped. Skipped is not a failure β€” it usually means nobody was subscribed, and that's an answer.
recipientsResolved addresses, as actually sent.
provider_message_idsThe SES MessageId β€” the join key to bounce and delivery events.
subject + contextReproduce the exact render six weeks later, after the template changed.
idempotency_keyExplains the replay that didn't send.
errorThe 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:

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 keysSES β€” this article.
Designed, responsive HTML templates edited by non-engineersA template layer on top of SES, or a product like Resend / Postmark. SES has templates, but no editor.
Marketing campaigns, segmentation, unsubscribe managementNot SES. Keep marketing mail on a different domain and reputation from your transactional mail.
Fan-out to many subscribers with no renderingSNS.
Alerts for engineers about infrastructureCloudWatch 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.

Check yourself

Quiz β€” 25 questions

Answer every question, then submit to see your score and the correct answers.

Wiring notifications into your platform?

JunctionNet builds event-driven AWS platforms and the notification systems on top of them β€” verified SES domains, least-privilege IAM, EventBridge triggers, and notification rules your ops team can edit. Reach out for help or a team workshop.

βœ‰οΈ Get in touch ← Back to workshops