23 years from Stockholm
Operator notes · From the Stockholm desk

Designing transactional email workflows around application events

A technical editorial guide for teams evaluating infrastructure, delivery trade-offs and the operational boundary around this category.

Transactional email used to be an afterthought. The product team would decide that "we should send a confirmation email when users sign up," a developer would add a three-line call to an SMTP library at the end of the signup function, and everyone would move on. That approach works for roughly six months in the life of a product. After that, the signup function also sends a welcome email, notifies the admin team, triggers a CRM update, and logs to a data warehouse — and all of it is tangled into the same synchronous code path, where any failure in any of those integrations can break user signup entirely. This article is about how to avoid that tangled outcome by treating email as a first-class consumer of application events, with the same architectural care you would give any other downstream system.

Key takeaways

  • The synchronous "send email inline" pattern scales to maybe the first year of a product's life. After that, every new integration added to a signup or purchase flow is another potential failure point in a critical user path.
  • The right model is event-driven: product actions publish events; email is one of several consumers that react to those events. The email workflow is decoupled from the action that triggered it.
  • The outbox pattern solves the "did we save to the database AND publish the event?" consistency problem without needing distributed transactions. It is the pattern most production systems actually use.
  • Idempotency at every consumer is non-negotiable. Event brokers deliver at-least-once; if your email consumer processes a duplicate, the recipient gets a duplicate message. That breakage is avoidable with a deduplication table.
  • Observability — tracing a specific email from the originating event through to mailbox delivery — is the capability that makes this architecture operationally sustainable. Without it, incidents take hours instead of minutes to diagnose.

Email as a first-class event consumer

The easiest way to understand the problem with inline email is to look at a signup function that has been alive for three years. What started as save_user() then send_welcome_email() has accumulated: a CRM integration, a Stripe customer creation, an analytics event, a notification to Slack, a log entry, a notification to the support team for high-value signups, an onboarding scheduler, a subscription to a drip campaign. Each one was added for a good reason. Together they form a critical path where any single failure breaks signup for new users.

The pattern that scales is inverted. The signup function is responsible for saving the user and publishing an event: user.signed_up. That event is all the function does. Everything else — email, CRM, analytics, notifications — becomes a consumer of that event, running asynchronously, isolated from the signup transaction, able to fail and retry without affecting the user's ability to sign up. Email is just one of several consumers, treated the same way as the others.

The architectural shift is significant but the benefits are tangible. Signup becomes faster because it no longer waits for downstream systems. Failures in downstream systems don't cascade. New consumers can be added without touching the signup code. The signup function becomes testable in isolation from every other concern. The email workflow becomes testable in isolation from signup. For the broader treatment of account lifecycle messaging — which is the category of email most often driven by product events — see building reliable API-based email for account lifecycle messaging.

The moment you have more than one thing happening in response to a product event, you have an event-driven system. The only question is whether you acknowledge it architecturally or leave it implicit in a tangle of inline calls. The explicit version is almost always cheaper to maintain. — A reasonable framing for the inflection point

The architecture that actually scales

The production-ready version of event-driven email has four moving parts, each with a specific responsibility. Understanding what each piece does, and what it explicitly does not do, makes the architecture legible.

The four components of event-driven email infrastructure
ComponentResponsibilityWhat it does NOT do
Event publisherEmits typed events when product state changes; persists events atomically with stateDoes not know about email, CRM, or any downstream consumer
Event broker / queueDurable storage and delivery of events; retry on consumer failure; ordering where neededDoes not interpret events; treats them as opaque payloads
Email consumerSubscribes to relevant events, renders templates, calls email provider, handles retriesDoes not talk to the database; operates on event data plus reference lookups
Email providerActual SMTP relay or API that delivers to the recipient's mailboxDoes not know about your event model; receives a formed message

The information flow

Event-driven email flow from product action to mailbox From product event to mailbox, with observability at every hop Each stage isolated; each stage monitored; failure at one stage doesn't cascade Product user.signed_up Outbox same transaction Broker Kafka / SNS / Pub/Sub Consumer render + send Email API outbound CRM consumer Analytics consumer Slack notifier
The broker fans out the single product event to multiple consumers. Email is one among several, each with its own retry policy and its own failure domain. Nothing cascades upstream.

Two properties of this architecture deserve emphasis. First, the broker is a boundary: everything upstream (product code) doesn't know who consumes; everything downstream (consumers) doesn't know who produced. That decoupling is what lets each side evolve independently. Second, the email consumer is a service with its own deployment, its own metrics, its own on-call rotation if you want. It is not a library or a function embedded in the product code.

The outbox pattern for email events

The trickiest part of event-driven architecture is the consistency problem: how do you ensure that if a user signs up, an event absolutely gets published, and that if an event gets published, the user absolutely signed up? Naive implementations — save to database, then publish to broker — have a race condition in the middle. If the application crashes between the two operations, you end up with either a signed-up user with no event, or an event without a corresponding user.

The outbox pattern solves this. Instead of publishing directly to the broker, the application writes the event to an outbox table in the same database transaction as the business state change. A separate process reads new rows from the outbox and publishes them to the broker. The atomicity of the database transaction guarantees that either both the user and the event get written, or neither does.

pseudocode# Inside the signup transaction
BEGIN TRANSACTION
    INSERT INTO users (id, email, created_at, ...) VALUES (...)
    INSERT INTO event_outbox (
        event_id,       -- UUID, unique
        aggregate_id,   -- the user id
        event_type,     -- 'user.signed_up'
        payload,        -- JSON with relevant fields
        created_at,
        status          -- 'pending'
    ) VALUES (...)
COMMIT

# Separate outbox publisher process
while True:
    events = SELECT * FROM event_outbox WHERE status = 'pending'
                 ORDER BY created_at LIMIT 100
    for event in events:
        broker.publish(event.event_type, event.payload)
        UPDATE event_outbox SET status = 'published', published_at = now()
                  WHERE event_id = event.event_id

# Or use CDC (change data capture) on the outbox table
# Kafka Connect with Debezium is the canonical approach.

Three details matter. First, the outbox publisher can safely retry publishing on failure because the broker will deduplicate based on event_id (assuming your broker supports that — Kafka does; SNS does with FIFO topics; SQS FIFO does; basic AWS SQS does not natively). Second, the outbox table can be drained asynchronously; it does not have to complete synchronously with the signup. Third, the outbox is durable: events that fail to publish on the first attempt will be retried until they succeed or you manually intervene.

Idempotency: the discipline nobody enjoys

Event brokers deliver at-least-once, which means duplicates. Your consumer will sometimes receive the same event twice — because the broker retried, because a consumer restarted mid-processing, because network conditions briefly went weird. If your email consumer sends on every receipt, duplicate events produce duplicate emails. Recipients see two welcome emails, customer support fields complaints, and your complaint rate ticks up for no good reason.

The fix is idempotent consumers: every consumer records the IDs of events it has processed and refuses to process the same event twice. The implementation is a few lines of code, the benefit is permanent, and skipping it is how otherwise-solid systems produce duplicate-email incidents at the worst possible times.

The pattern that actually works

email consumerdef on_event(event):
    """Email consumer handler — idempotent by event_id."""
    with transaction.atomic():
        # Step 1: record that we're processing this event, before side effects
        try:
            ProcessedEvent.objects.create(
                event_id=event.event_id,
                consumer_name='email',
                received_at=now()
            )
        except IntegrityError:
            # Duplicate. Already processed. Return silently.
            logger.info("skipping duplicate", extra={'event_id': event.event_id})
            return

        # Step 2: now it's safe to do the side effect
        template = lookup_template(event.event_type)
        context = build_context(event.payload)
        rendered = render(template, context)

        # The send itself is an external side effect. If it fails, the
        # ProcessedEvent row will be rolled back by the outer transaction,
        # and the broker will redeliver.
        email_provider.send(
            to=event.payload['email'],
            subject=rendered.subject,
            html=rendered.html,
            metadata={
                'event_id': event.event_id,
                'user_id': event.aggregate_id,
                'template': template.name,
            }
        )

The trick is the order of operations inside the transaction. The ProcessedEvent row gets inserted before the send. If the send fails, the transaction rolls back, the ProcessedEvent row is gone, and the broker will redeliver the event. If the send succeeds, the transaction commits both the record and the completed send. If the consumer crashes after the send but before the commit, the email went out but the record didn't — and on redelivery, the system sends again. That last edge case produces a duplicate, but it is much rarer than the naive crash-between-record-and-send alternative.

The classic idempotency bug Developers sometimes implement idempotency by recording the event ID after the send: "we'll record it once we know the send succeeded." This is exactly the wrong order. If the consumer crashes between send and record, the next redelivery sends again. The record must be inserted first; the send must happen inside the same transaction; the rollback-on-send-failure is the recovery mechanism.

Retries, backoff, and dead-letter queues

Not every send failure is a duplicate problem. Some failures are transient — the email provider is having a bad thirty seconds — and the right response is to wait briefly and retry. Some failures are permanent — the recipient's domain doesn't exist, the template has a bug, the API key is wrong — and no amount of retrying will help. Distinguishing the two and handling each correctly is its own discipline.

The retry ladder

Retry strategy by failure class
Failure classExampleStrategy
Transient networkConnection timeout to email APIExponential backoff with jitter; retry 5-7 times over ~10 minutes
Provider rate limit429 response; X-RateLimit-Remaining: 0Respect Retry-After header; requeue event at that time
Provider server error500-series response; downstream infrastructure issueExponential backoff; after 30 minutes move to DLQ for manual review
Invalid recipient (hard bounce at validation)400: no such mailboxDo NOT retry; add to suppression list; log and move on
Invalid template or content422: template missing variableDo NOT retry; alert ops; DLQ immediately
Authentication failure401/403: bad API keyDo NOT retry; page on-call; DLQ with alert
Suppressed recipientRecipient previously unsubscribed or bouncedSilently skip; not an error condition

Exponential backoff with jitter — the implementation

retry logicimport random, time

def send_with_retries(event, max_attempts=6):
    """Retry with exponential backoff + jitter; DLQ on exhaustion."""
    base_delay = 2.0   # seconds
    max_delay  = 600.0 # cap at 10 minutes

    for attempt in range(max_attempts):
        try:
            response = email_provider.send(...)
            if response.ok:
                return response

            # Non-retryable errors go to DLQ immediately
            if response.status in (400, 401, 403, 404, 422):
                dlq.publish(event, reason=f"non_retryable_{response.status}")
                return

            # Retryable. Fall through to backoff below.
        except TransientError:
            pass  # Backoff and retry

        # Exponential backoff with full jitter
        delay = min(max_delay, base_delay * (2 ** attempt))
        delay = random.uniform(0, delay)
        time.sleep(delay)

    # All attempts exhausted. Send to DLQ for human investigation.
    dlq.publish(event, reason="retry_exhausted")

The jitter — multiplying the backoff by a random fraction — prevents "thundering herd" behavior where many consumers retry at the exact same moment after a provider outage. Without jitter, a brief provider outage can produce synchronized retry storms that make the recovery slower. This is an AWS best practice since at least 2015, and it generalizes beyond retries to any situation where many workers wait on the same external resource.

Dead-letter queues as a safety net

Events that fail all retries go to a dead-letter queue (DLQ). The DLQ is a place where events sit until a human decides what to do with them. Common outcomes: replay them after the underlying bug is fixed, drop them if the recipient has since unsubscribed, or archive them for audit purposes. What matters is that the DLQ exists — a pipeline that silently drops failed events on the floor is a pipeline that will mysteriously fail to send critical mail and nobody will know until the support ticket arrives days later.

Templating: where it should live

Every event-driven email system needs to render templates. The question is where the templates live and who is responsible for editing them. The answer affects development velocity, deploy cadence, and how quickly the marketing team can iterate on copy without waiting for an engineering deploy.

Where templates can live, and the consequences of each choice
PatternHow it worksGood forTrade-offs
Templates in codeHTML files in the application repo, rendered at send timeSmall teams; templates rarely change; engineering owns emailTemplate changes require deploys; marketing team can't edit directly
Templates in databaseCMS-style, editable through internal admin UIMedium teams wanting non-engineer editing; moderate flexibilityYou maintain the editor UI; version history and rollback are your problem
Provider-hosted templatesSendGrid Dynamic Templates, Mailgun templates, Postmark templatesMarketing iteration without code deploys; provider-managed versioningTied to provider; migration cost if you switch
External design system (MJML, Maizzle)Templates in a dedicated repo, compiled to HTML, either stored or sent inlineComplex design systems; cross-product consistencyBuild pipeline adds complexity; sync between design and runtime adds moving parts

Most teams converge on some variation of provider-hosted templates. Marketing edits copy in a WYSIWYG editor; engineering references the template by ID in the send call and supplies variables. A clean separation of concerns, and the provider handles the versioning story. The downside — provider lock-in — is real but usually acceptable given the operational benefits. Regardless of where templates live, the underlying sending path still needs proper DKIM signing per domain; the practical setup is covered in DKIM signing for transactional email.

What the send call looks like with external templates

send with template idemail_provider.send(
    to=event.payload['email'],
    template_id='welcome_v3',       # provider-hosted template
    template_variables={
        'first_name': event.payload['first_name'],
        'login_url':  build_login_url(event.aggregate_id),
        'support_email': 'help@example.com',
    },
    metadata={
        'event_id': event.event_id,
        'user_id':  event.aggregate_id,
        'template': 'welcome_v3',   # redundant but helpful for analytics
    }
)

Event taxonomy for a growing product

One of the under-appreciated benefits of treating email as an event consumer is that you are forced to have a clean event taxonomy. Events need names, types, schemas, and version discipline. Getting this right early saves significant pain later when the event stream has hundreds of distinct event types and multiple consumers depending on each one.

Naming conventions that scale

Dot-separated namespace
Events are named like user.signed_up, order.placed, invoice.paid. The namespace (first segment) reflects the aggregate; the action (second segment) reflects what happened. Dots make grep-able names and map cleanly to topic or subject conventions in most brokers.
Past tense for the action
user.signed_up, not user.sign_up. Events describe things that have happened, not things being requested. The tense is part of the semantic contract; imperatives would imply commands, which is a different pattern.
Versioned payload schemas
The event payload is a contract between producer and consumer. When the schema changes, version it: user.signed_up.v2, or add a schema_version field to the payload. Consumers should tolerate unknown fields (forward compatibility) but should not be required to handle breaking changes without explicit migration.
Rich enough to act on
Events should contain the information consumers need without them having to re-query the database. The email consumer for user.signed_up should be able to render the welcome email from the event alone; it should not need to query the user table for the email address, because that race condition is exactly what the event-driven pattern avoids.

A minimal event payload shape

event payload{
    "event_id":       "01DFKQZP8VWTRKX2M7TW5H9XF4",
    "event_type":     "user.signed_up",
    "event_version":  2,
    "occurred_at":    "2019-09-11T14:22:18.042Z",
    "aggregate_id":   "user_abc123",
    "payload": {
        "user_id":     "user_abc123",
        "email":       "jane@example.com",
        "first_name":  "Jane",
        "plan":        "professional",
        "trial_days":  14,
        "signup_source": "organic_search"
    },
    "metadata": {
        "producer":    "accounts-service",
        "trace_id":    "abc-123-def-456",
        "correlation_id": "req-789-xyz"
    }
}

The payload is self-contained enough to render any transactional email a new-user flow would need. The metadata is where observability lives: trace_id for distributed tracing, correlation_id for tying events back to the originating request, producer for debugging which service emitted. Getting these fields into every event from day one pays enormous dividends during incident investigation later.

Observability: tracing an email from event to inbox

The difference between an event-driven email system that is operationally sustainable and one that generates recurring incidents is observability. Specifically: can you trace a specific email from the originating product event, through the broker, through the consumer, through the email provider, to the recipient's mailbox, with timestamps and diagnostic data at every hop? If yes, incidents take minutes to diagnose. If no, they take hours.

The trace chain

  1. Product action: event ID generated. The event_id is a stable identifier that will follow this event through every downstream system. Store it in the event payload; include it in metadata on every derived record.
  2. Outbox: event_id in database row. Timestamps for created_at and published_at let you measure outbox drain latency.
  3. Broker: event_id as message header or key. Kafka keys the partition; SNS/SQS carries it as metadata.
  4. Consumer: event_id as idempotency key in the ProcessedEvent table. Logs emitted by the consumer include event_id so every log line is correlatable.
  5. Email provider: event_id passed as metadata on the send. SendGrid custom_args, Mailgun v:my-event-id, Postmark Metadata. Every major provider has a metadata field; use it.
  6. Provider webhooks: event_id returned in delivery/open/click events. When the recipient interacts with the message, the webhook carries event_id, letting you close the loop back to the originating action.
  7. Analytics / data warehouse: event_id as the join key. All downstream analysis, from "what was the open rate of the welcome email this month" to "did Jane receive her welcome email," can be answered by joining on event_id.
The dividend from investing in tracing A senior engineer debugging "Jane says she didn't get her welcome email" should be able to: paste Jane's email into a dashboard, see the originating event_id, click through the outbox timestamp, broker timestamp, consumer acceptance, provider acceptance, delivery event, and open event — all in under sixty seconds. That debugging speed comes from decisions made at system-design time, not from the monitoring tools the team picks later.

Handling failure modes gracefully

Every event-driven email system eventually encounters the failure modes that test its design. Understanding them in advance makes the response predictable rather than improvisational.

  1. Provider outage. The email API is down. Events pile up in the consumer's retry queue. The right behavior: retries respect the provider's status; when back-off is exhausted, events move to the DLQ; operations is alerted; events are replayed from DLQ after recovery. The wrong behavior: consumers spin indefinitely, queue backs up, product event flow is impacted.
  2. Consumer bug. A code bug in the email consumer causes every send to fail. Same pattern as provider outage: retries exhaust, events move to DLQ, alerting fires. The consumer is rolled back to a working version, DLQ is replayed. Critical: the bug does not affect the product code that emitted the events.
  3. Template regression. A template change breaks rendering for some subset of events. Sends fail with 422; DLQ fills. The template is reverted, DLQ is replayed. Nobody's signup was blocked.
  4. Event schema breaking change. The producer ships a new event schema that the consumer doesn't understand. This is the scenario versioned schemas prevent. If versioning was done correctly, the consumer handles old and new schemas; if not, the consumer throws on the new schema and events pile up until someone notices.
  5. Broker partition. The broker is temporarily unavailable. Outbox rows accumulate; publisher can't publish. When the broker recovers, the publisher drains the outbox. Nothing was lost because the outbox is durable.
  6. Consumer can't keep up with volume. Event volume exceeds consumer throughput. Queue depth grows. The solution is horizontal scaling: more consumer instances, which is possible because consumers are idempotent and stateless. Scaling is a capacity decision, not an emergency re-architecture.

When event-driven is overkill

Everything in this article assumes the cost-benefit analysis favors event-driven architecture. For some systems, it doesn't. The honest list of when to stay synchronous:

  • A simple product with one or two email flows. An early-stage product sending a welcome email and a password reset email doesn't need a broker and three services. Inline sending with reasonable error handling is enough, and the team can revisit when the product genuinely outgrows it.
  • An established monolith with no other event-driven use cases. Introducing Kafka just for email is a lot of operational weight for one benefit. If the system doesn't already have an event broker for other reasons, consider simpler patterns first.
  • Legacy systems that can't easily emit events. If adding event emission to a legacy system would require significant refactoring, the right approach is often to keep the legacy path synchronous while introducing events for new functionality.
  • Workloads where ordering guarantees matter more than throughput. If your email sends need strict ordering (they usually don't), the architectural tradeoffs change and naive broker-based fan-out may not fit.

The pattern that scales well and is often overlooked: a thin background worker using something like Celery or Sidekiq or BullMQ, which is 80% of the benefit of full event-driven architecture with 20% of the operational cost. Before building the full thing, consider whether a job queue plus a worker is actually sufficient for the volumes involved. For the broader story of how sending infrastructure has to evolve as products grow, scaling outbound email beyond shared hosting covers the technical thresholds at which each architectural pattern starts to matter.

Frequently asked questions

Do we really need Kafka for this, or can we use SQS?

Both work. Kafka offers stronger ordering guarantees and better support for very high throughput, at the cost of operational complexity. SQS is simpler to run (managed), with SQS FIFO giving you ordering when you need it. For most teams, SQS is the better starting point unless you already have Kafka for other reasons. Google Pub/Sub, Azure Service Bus, and RabbitMQ are also fine choices with different trade-offs.

How do we prevent the same user from getting duplicate emails?

Idempotent consumers prevent the same event from producing duplicate emails. That handles the accidental-duplicate case. For intentional deduplication — "don't send this user the same welcome email twice even if they signed up twice" — you need application-level logic that checks whether the user has already received this specific message. The provider's metadata field is often where this lives.

What about email opens and clicks — are those events too?

Yes, and you should consume them. Every major email provider emits webhooks for delivered, bounced, opened, clicked, complained, and unsubscribed events. These become events in your own system: email.delivered, email.bounced, etc. Consumers process them like any other event — updating suppression lists, updating user records, updating analytics. The same idempotency pattern applies.

How do we handle time-delayed emails (send this in 24 hours)?

Schedule them at the broker level if supported (SQS with visibility delay, Redis-based scheduled jobs), or at the email provider if they support scheduled sending. Another pattern: a separate scheduler service that emits a delayed event when the trigger fires. The email consumer processes the delayed event normally.

What if the user's email address changes between the event and the send?

Two approaches. First: include the email address in the event payload; the consumer uses whatever was in the payload even if the user has since changed it. Second: include only the user_id; the consumer looks up the current email at send time. The first is more consistent with "events are immutable facts about the past"; the second is more compliant with GDPR-like concerns about using stale contact information. Most teams go with option 2 for consent-sensitive sends and option 1 for everything else.

Can the email consumer call the database to look up user details?

Yes, with discipline. Treat it as a read-only reference lookup, not as a source of truth. Cache aggressively. Handle lookups that fail (user deleted, user not yet replicated) gracefully. Some purists argue the event should contain all needed data and consumers should never query the source DB; in practice, mid-sized systems typically do both, with events containing the hot data and lookups for rarely-changed reference data.

Closing perspective

The transition from inline email sends to event-driven email workflows is a kind of architectural coming-of-age for most products. The synchronous version works until it doesn't, and the switchover is usually triggered by a specific incident — a third-party integration failure that broke signup, a burst of volume that made synchronous sends timeout, a compliance requirement that made inline sends unworkable. Teams that made the switch before the incident look prescient; teams that made it after spent six months in pain they could have avoided.

The architecture described here — outbox, broker, idempotent consumers, retries with DLQ, observability at every hop — is not novel. It is the set of patterns that has converged across thousands of production systems over the past five to ten years because it actually works. Chris Richardson's microservices patterns, Kafka's guarantees, Stripe's webhook conventions, AWS's recommended retry policies — all point to the same design space. The cost of adopting these patterns is real but finite; the cost of not adopting them accumulates indefinitely as the system grows.

For teams considering the move, the honest advice: don't wait for the forcing incident. Do the gradual introduction — outbox first, broker next, one consumer at a time migrated from inline sends — while the system is still small enough to reorganize. A product with three email flows is trivially migrated; a product with thirty is a six-month project. Timing matters.

For teams that have already made the move: the continuing investment is in observability and operational discipline. The architecture is not self-maintaining. The teams that run event-driven email reliably are the ones who treat their DLQ as a first-class surface — read regularly, triaged promptly, replayed carefully. They are the ones who keep their event taxonomy clean, version their schemas deliberately, and resist the temptation to let events become garbage cans for arbitrary data. And they are the ones whose email workflows just work, quietly, through every product evolution the business throws at them. That quiet reliability is the goal, and it is what event-driven email architecture exists to deliver. The underlying model that makes inbox placement possible in the first place — how providers evaluate senders — is covered in sender reputation fundamentals, which frames why clean event-driven sending matters operationally.