Account lifecycle messaging is where email stops being a marketing channel and becomes a feature of your product. The welcome email after signup, the verification link that activates the account, the password reset that lets a user back in, the receipt that confirms payment, the notification that a team member was added — every one of these is a user-critical transaction where failure shows up as a support ticket or a churned account. Treating these messages with the same reliability discipline applied to the rest of the API surface is what separates products where email "just works" from products where the support queue is dominated by "I never got the email." This article is the practical guide to building that reliability layer: the design decisions, the failure modes, the operational patterns, and the landscape of email API providers as it stands in late 2016 after Mandrill's closure to non-MailChimp customers reshaped the market earlier this year.
Key takeaways
- Lifecycle email is triggered by application events and is user-critical. The reliability requirements are closer to payments than to newsletters: loss, duplication, or delay each have specific product consequences.
- Email APIs (HTTP/JSON) have become the dominant pattern for lifecycle sending because they provide direct feedback, structured error handling, and webhook callbacks that SMTP can't easily offer. SMTP is still fine for legacy integrations but API wins for new work.
- The reliability contract rests on four pillars: idempotent sends (retry-safe), structured retries (exponential backoff with deadline), webhook processing (also idempotent), and queue-based decoupling (send is async from trigger).
- Mandrill's decision earlier this year to end service for non-MailChimp customers forced a migration wave. Teams that were locked into a single vendor discovered the value of a provider-agnostic abstraction layer. Build portability in from the start.
- Observability for lifecycle email is non-negotiable. Every send needs a stored trace: the trigger event, the API call, the provider's message ID, webhook events as they arrive, and the final delivery state. Without this trace, debugging "the user didn't get the email" becomes impossible.
Why account lifecycle email is different
Marketing email and operational email are both legitimate workloads, and both have their own disciplines. Account lifecycle email sits in its own category because the failure cost is uniquely visible: if the password reset doesn't arrive, the user is locked out. If the receipt doesn't arrive, the customer calls support. If the welcome email doesn't arrive, the onboarding funnel silently bleeds conversions nobody notices until weeks later.
The properties that make lifecycle email its own thing:
| Property | Lifecycle email | Marketing email | Operational email |
|---|---|---|---|
| Trigger | User action or application event | Campaign schedule | Infrastructure event |
| Time sensitivity | Seconds to minutes | Hours to days | Seconds to minutes |
| Recipient awareness | Expects and needs the message | May or may not want the message | Recipient is the ops team |
| Failure visibility | Very high — becomes support ticket | Low — recipient may not notice | Hidden until incident |
| Volume pattern | Event-driven; matches user activity | Bursty around campaign sends | Event-driven; matches alerts |
| Content | Personalized with transaction detail | Templated mass content | Diagnostic or status |
| Suppression policy | Minimal — these are needed messages | Honor every unsubscribe | N/A (internal audience) |
The "recipient expects and needs the message" property is what shapes the entire reliability model. A user who requested a password reset is actively waiting. They refresh their inbox. They check spam. They try again if it doesn't arrive within sixty seconds. Every dimension of the sending path — API call latency, provider acceptance time, delivery time, recipient-side filtering — contributes to whether the reset actually reaches them before they either retry (generating duplicates) or give up (triggering a support call). Sub-minute total-path latency is the target; longer paths produce measurable user friction. The broader context of how sending reputation evolves as a program grows — and why lifecycle email benefits from a dedicated reputation surface — is covered in planning IP reputation and deliverability for expanding sending programs.
Mapping the lifecycle messages an app actually sends
Before designing the sending infrastructure, the set of lifecycle messages an application actually emits needs to be explicit. Most teams discover, when they first map this, that the number is larger than they thought and that several messages were "inherited" from the original app design without anyone owning them.
The canonical lifecycle set
- Signup welcome — first impression; often includes product orientation. Frequency: once per user.
- Email verification / activation — contains tokenized link that activates the account. Frequency: once per verification attempt; retries common.
- Password reset — tokenized link with short TTL (typically 15-60 minutes). Frequency: user-initiated, sometimes multiple per session.
- Login-from-new-device notification — security signal to account owner. Frequency: occasional.
- Billing receipt / invoice — triggered by successful payment or invoice generation. Frequency: monthly or per transaction.
- Payment failed / card expiring — dunning messages; may have retry cadence. Frequency: as events occur.
- Team invitation — invites a new user to join a workspace. Frequency: as invites are sent.
- Permission or ownership changes — notifies affected users when roles change. Frequency: as changes occur.
- Trial expiration reminders — scheduled ahead of and at trial end. Frequency: scheduled cadence.
- Subscription confirmations and cancellations — state-change notifications. Frequency: as changes occur.
- Data export ready / file share notifications — triggered when an async job completes. Frequency: as jobs complete.
- Usage limit warnings — approaching or at quota limits. Frequency: threshold-driven.
- Product announcements (in-app-related) — features, outage notifications, security events. Frequency: infrequent but often urgent.
The inventory matters because each message has its own reliability tier, its own template, its own suppression rules, and its own audit requirement. A security-critical message like a new-device login notification cannot be suppressed by a marketing unsubscribe; a trial expiration message probably can. Getting the classifications right at the start prevents the category of incident where users unsubscribe from marketing and stop receiving critical lifecycle messages.
SMTP or API: when API wins
The older pattern for sending lifecycle email was SMTP: the application hands the message to a local or remote SMTP server and trusts that the downstream path will deliver it. This still works, and for simple cases it's fine. But as the reliability expectations rise, the limitations of SMTP as an integration protocol become meaningful.
| Dimension | SMTP | HTTP API |
|---|---|---|
| Integration model | Text protocol; libraries wrap it | JSON over HTTPS; native to modern stacks |
| Error feedback | Limited to SMTP response codes | Structured JSON error body with codes, messages, and field-level detail |
| Acceptance confirmation | Accepted by the SMTP server; actual send status is async | Provider returns a message ID synchronously |
| Event feedback | Via bounce messages; delayed, unstructured | Webhooks with JSON event data |
| Content handling | MIME encoding in application code or library | Structured JSON with to, from, subject, html, text as fields |
| Template management | Managed in application code | Provider-hosted templates with variable substitution |
| Attachments | MIME-encoded in payload | Base64-encoded in JSON or referenced by URL |
| Multi-tenancy / subaccounts | Managed via separate credentials | Often first-class with subaccounts and scoped API keys |
| Rate limiting / throttling | Per-connection; coarse | Per-key, per-endpoint; fine-grained |
The practical summary
For legacy applications that already have SMTP integrations and working reliability, staying on SMTP is fine. For new work on lifecycle messaging, API-based integration is the default choice. The three properties that matter most: synchronous acceptance confirmation (you know the provider accepted the message at send-time), structured error responses (you can handle a "validation failed" differently than a "rate limit exceeded"), and webhook callbacks (you learn what happened to the message as events occur).
The reliability contract the email API needs to honor
A lifecycle email sender is effectively a small distributed system. The application emits an event. A queue holds work items. Workers pick up items and call the email API. The provider accepts the message. The provider attempts delivery. Delivery either succeeds, bounces, or enters deferred retry. The provider sends webhook events at each stage. The application consumes those events and updates its own state.
Each of these handoffs can fail. The reliability contract is a set of design decisions that ensure a user-initiated trigger eventually produces either a delivered message or a clearly-logged failure that someone can action.
The four design decisions that anchor the contract
- Queue between the trigger and the send. The user action enqueues a job; a worker handles the API call. This keeps user-facing latency low, allows retries without user awareness, and lets the worker pool be sized independently from the request pool.
- Idempotency keys on every send. The worker includes a stable key (derived from the event ID, not generated fresh on each retry). The provider, if it supports it, dedupes on the key; the application, regardless of provider support, tracks which keys it has attempted and which succeeded.
- Bounded retries with deadline. Transient failures retry; permanent failures (invalid recipient, hard-bounce classification) do not. Retries have a deadline — after a certain elapsed time, the send is marked failed rather than retrying forever.
- Webhook processing also idempotent. Webhooks can be delivered multiple times. Webhook handlers must be safe to run twice on the same event without side effects. Store the event ID, check before processing.
Idempotency keys and the retry model
Retries are the mechanism that turns transient failures into eventual success. They also create the risk of duplicate sends if not done carefully. The idempotency key is the pattern that reconciles these: retry is safe because the key ensures only one message is actually sent per unique event.
How an idempotent email send looks in practice
Python — idempotent lifecycle sendimport requests
import hashlib
import time
def send_password_reset_email(user_id, reset_token, reset_event_id):
"""
Send password reset email. Safe to call multiple times for the
same reset_event_id — only one email will actually be sent.
"""
# Idempotency key derived from the event, not from wall-clock time.
# Two retries for the same reset attempt produce the same key.
idempotency_key = hashlib.sha256(
f"pwd-reset:{user_id}:{reset_event_id}".encode()
).hexdigest()
# Check local state first — have we already successfully sent this?
if email_already_sent(idempotency_key):
return {"status": "already_sent", "key": idempotency_key}
# Issue the API call. Many providers accept Idempotency-Key
# as a header; use it when available.
payload = {
"from": {"email": "noreply@example.com", "name": "Example"},
"to": [{"email": user_email_for(user_id)}],
"template_id": "password-reset-v2",
"substitutions": {
"user_name": user_display_name(user_id),
"reset_url": reset_url_for(reset_token),
"expires_in": "30 minutes",
},
"custom_args": {
"event_type": "password_reset",
"user_id": str(user_id),
"reset_event_id": str(reset_event_id),
},
}
headers = {
"Authorization": f"Bearer {EMAIL_API_KEY}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
}
response = requests.post(
"https://api.emailprovider.example/v1/messages",
json=payload,
headers=headers,
timeout=10,
)
if response.status_code in (200, 201, 202):
# Record success with the provider's message_id for later correlation
record_send_success(
idempotency_key,
message_id=response.json().get("message_id"),
)
return {"status": "sent", "key": idempotency_key}
elif response.status_code in (400, 422):
# Permanent failure — don't retry
record_send_permanent_failure(idempotency_key, response.json())
return {"status": "failed_permanent", "key": idempotency_key}
else:
# Transient failure — raise to trigger retry
raise TransientFailure(
status=response.status_code,
body=response.text,
)
The retry wrapper
Exponential backoff with deadlinedef send_with_retry(send_fn, max_attempts=6, deadline_seconds=900):
"""
Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s between attempts.
Total elapsed with all delays: ~63 seconds plus send time.
Hard deadline at 15 minutes prevents unbounded retry loops.
"""
started_at = time.time()
for attempt in range(max_attempts):
if time.time() - started_at > deadline_seconds:
raise DeadlineExceeded()
try:
return send_fn()
except TransientFailure as e:
if attempt == max_attempts - 1:
raise
sleep_for = min(2 ** attempt, 60)
time.sleep(sleep_for)
Handling webhook events correctly
Webhooks are the provider's feedback channel. When the message is delivered, bounced, opened, clicked, marked as spam, or unsubscribes, the provider POSTs a JSON payload to an endpoint you configure. These events drive the application's understanding of delivery state and populate whatever dashboards, user communication, and troubleshooting the product needs.
The webhook contract — what every receiver needs to do
Node.js — webhook handler (Express)const express = require('express');
const crypto = require('crypto');
const app = express();
// Raw body required for signature verification
app.use('/webhook/email-events',
express.raw({ type: 'application/json', limit: '1mb' }));
app.post('/webhook/email-events', async (req, res) => {
// 1. Verify the signature — reject forged payloads
const signature = req.header('X-Provider-Signature');
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('bad signature');
}
// 2. Parse the payload
let events;
try {
events = JSON.parse(req.body.toString());
} catch (err) {
return res.status(400).send('invalid JSON');
}
// 3. Process each event idempotently
for (const event of events) {
const eventId = event.event_id;
// Skip if we've seen this event ID before
if (await eventAlreadyProcessed(eventId)) continue;
await handleEmailEvent(event);
await markEventProcessed(eventId);
}
// 4. Return 200 quickly — the provider retries on non-2xx
return res.status(200).send('ok');
});
async function handleEmailEvent(event) {
const message_id = event.message_id;
const type = event.event; // 'delivered', 'bounce', 'open', etc.
switch (type) {
case 'delivered':
await updateEmailState(message_id, 'delivered', event.timestamp);
break;
case 'bounce':
await updateEmailState(message_id, 'bounced', event.timestamp);
if (event.bounce_type === 'hard') {
await addToSuppressionList(event.recipient);
}
break;
case 'spam_report':
await updateEmailState(message_id, 'spam_reported', event.timestamp);
await addToSuppressionList(event.recipient);
break;
case 'open':
case 'click':
await recordEngagement(message_id, type, event);
break;
}
}
The four webhook discipline rules
- Verify signatures. Every mature provider signs webhook payloads with an HMAC; reject anything that doesn't verify. Without this, your webhook endpoint is world-accessible and can be spoofed.
- Process idempotently. Webhooks can arrive multiple times due to retry. Store the event ID, check before processing. Don't re-decrement counters or re-send follow-up actions on duplicate delivery.
- Return 200 quickly. The provider retries non-2xx responses, so slow processing becomes amplified load. Accept the payload, queue the work, return 200 within a few hundred milliseconds. Actual processing happens asynchronously.
- Log every event with its ID. For debugging "why didn't the user get the email?" later, you need the full event trace: was the message delivered, when, did it bounce, what was the classification. Every event is valuable observability data.
Decoupling send from trigger with a queue
The single most important architectural decision for lifecycle email reliability is decoupling the trigger from the send via a queue. Without this, every API call happens during a user request, every failure blocks the user's action, and retries are impractical because they'd extend request latency beyond what users tolerate.
The queue choices in 2016
| Queue | Model | Good fit when |
|---|---|---|
| AWS SQS | Managed, at-least-once delivery | Already on AWS; want minimal ops overhead |
| Redis (via Sidekiq, Resque, RQ, Bull) | In-memory; relies on persistence config | Redis already deployed; simple job semantics |
| RabbitMQ | AMQP; rich routing | Complex routing needs, priority queues |
| Apache Kafka | Log-based; durable | High volume; event sourcing elsewhere in the stack |
| Amazon SNS + SQS | Pub/sub with queue backing | Multiple subscribers to the same event |
| PostgreSQL-based (e.g., Que) | Database-backed queue | Existing Postgres; want transactional guarantees with application writes |
The invariants the queue has to preserve
The queue's job is to make the send tolerant of transient failures without duplicating work. The invariants:
- At-least-once delivery of jobs. A job submitted to the queue is eventually processed by a worker, even if the first worker dies mid-job. This is the core guarantee every reasonable queue provides.
- Idempotent workers. Because the queue is at-least-once, workers must tolerate being asked to process the same job twice. The idempotency key from section 5 is how this is made safe.
- Visibility timeout long enough for API call plus margin. Most managed queues have a "visibility timeout" — the time a worker has to finish before the job becomes available again. Set this long enough for the email API call plus retry delays, typically 2-5 minutes.
- Dead-letter queue for persistent failures. Jobs that fail repeatedly (past the retry deadline) should move to a dead-letter queue rather than loop forever. The DLQ is where the "I need to investigate why this user never got their welcome email" work gets surfaced.
Templating and the template-versus-content separation
Every lifecycle email is a combination of a template and variable data. Keeping these separated matters because it makes the sending system audit-friendly, enables A/B testing of templates without code changes, and simplifies internationalization.
Two templating patterns
- Provider-hosted templates
- The template lives in the provider's template system (SendGrid Dynamic Templates, Mailgun Templates, Postmark Templates, Mandrill-style Handlebars templates). The application references the template by ID and provides substitution variables. The provider renders. Pros: non-developers can edit templates; the API call payload stays small. Cons: vendor lock-in on template syntax; template versioning is the provider's problem.
- Application-side rendering
- The application renders the HTML and text itself (using Mustache, Handlebars, Jinja, or similar) and sends fully-rendered content to the provider. Pros: no vendor lock-in on templating; developer workflow (git, code review); portable across providers. Cons: non-developers can't edit without engineering involvement; API payload includes full HTML.
Most mature teams end up with a hybrid: templates live in the application codebase (for portability and developer workflow), but content blocks within templates are editable via a content management system or markdown files (for non-developer workflow). The exact balance depends on organizational structure.
The separation of concerns that matters
- Code should not contain marketing copy. The email the user gets for password reset is not hardcoded inside the authentication module.
- Template changes should not require code deploys. The marketing team can update welcome email wording without filing a ticket.
- A/B tests on templates should be possible without engineering involvement. If the team wants to test two versions of the upgrade-prompt email, the mechanism shouldn't require a release.
Authentication, DKIM, and the sending-domain question
Lifecycle email needs proper SPF, DKIM, and DMARC configuration as much as any other mail. The questions that actually matter in implementation: which domain does the mail come from, what does the provider need to be authorized to send on your behalf, and how does this play with your marketing email sending.
The three common sending-domain patterns
| Pattern | Send from | Trade-offs |
|---|---|---|
| Main domain, shared with marketing | hello@example.com | Simplest; marketing reputation affects lifecycle deliverability |
| Subdomain for transactional | noreply@mail.example.com | Reputation separation; authentication clean; most common pattern |
| Separate domain entirely | noreply@examplemail.com | Maximum isolation; extra DNS management; less brand cohesion |
For most applications in 2016, the subdomain pattern (mail.example.com or email.example.com) is the right choice. It gives you an independent reputation surface for lifecycle email while keeping the domain relationship clear to recipients. Provider setup typically involves publishing CNAMEs for DKIM under the subdomain and adjusting SPF to authorize the provider. The broader playbook for coordinating SPF, DKIM, and DMARC across all sending — including the enforcement progression that protects lifecycle mail from being spoofed — is covered in DMARC in practice: early lessons from the first wave of adopters.
The DKIM-alignment reality
Every major email API provider in 2016 supports DKIM signing with your domain. The configuration is a small amount of DNS work (publish CNAMEs to the provider's signing hosts) done once per provider per sending domain. Skipping this work — and letting the provider sign with their own domain as fallback — is how lifecycle mail ends up failing DMARC alignment and landing in spam folders. Do the DKIM setup; it's a ten-minute task that pays off indefinitely. The mechanics of the signing process itself, which is what the provider is doing under the hood, are detailed in DKIM signing for transactional email.
Observability: what you need to be able to answer in production
The most common question asked of a lifecycle email system is "did the user actually get the email?" The observability stack has to be able to answer this in seconds, not hours. The team that can answer it fast catches problems before they become patterns; the team that can't answer it inherits those patterns as long-running incidents.
The questions you need to be able to answer
- Did user X's password reset email send? Was the API call made, was it accepted by the provider, what was the provider's message ID.
- Did it actually reach the inbox? Did a delivered webhook arrive? If not, what about bounced or deferred?
- If it bounced, why? Hard bounce (invalid address), soft bounce (mailbox full, transient), or classification bounce (provider rejected).
- What's our current delivery rate? Percentage of sends that end up delivered across the last hour, day, week.
- Which template is performing worst? Which lifecycle email has the highest bounce or spam-report rate?
- Are any recipient domains blocking us? Which mailbox providers are deferring or rejecting; is it specific?
- Did this webhook actually get processed? The event arrived; did our handler succeed?
What "observability" looks like in practice
Every sent email has a record in your database keyed by the idempotency key or internal message ID. That record tracks: the trigger event, the API call metadata, the provider's message ID once received, each webhook event as it arrives (with timestamps), and the current state. A user-search UI in the admin tool looks up a user's recent lifecycle email and shows the full trace: "Welcome email to user@example.com at 2016-10-15 14:23 — accepted by provider (msg-id abc123) — delivered webhook received at 14:23:42 — opened webhook at 15:12."
Building this observability is not hard; it's a couple of database tables and some webhook processing. The teams that build it during initial implementation have a multiplier on their ability to operate the system. The teams that don't end up rebuilding it later, usually in the middle of an incident.
Choosing between providers in late 2016
The provider landscape has been shaken up this year in a way worth naming explicitly. MailChimp's Mandrill — once a popular transactional provider integrated with a widely-used marketing product — ended service for non-MailChimp customers earlier this year. Teams that were on Mandrill had weeks to migrate. The forced migration was painful; it also clarified which providers people chose when they had to pick again.
The providers teams evaluate today
| Provider | Positioning | Notable strengths |
|---|---|---|
| SendGrid | Broad-market transactional + marketing | Mature API, large SDK set, subuser model, widely adopted |
| Mailgun | Developer-focused transactional (part of Rackspace) | Clean API, strong routing/rules engine, EU data residency |
| Postmark | Opinionated transactional-only | Focus on transactional, fast delivery, product simplicity |
| SparkPost | High-volume transactional (ex-Message Systems) | Built on Momentum engine, strong deliverability analytics |
| Amazon SES | Raw infrastructure; lowest cost at volume | AWS-native, cheapest per send, simpler feature set |
| Mandrill | MailChimp-bundled transactional (now MailChimp-only) | Closed to new non-MailChimp customers since April 2016 |
The portability lesson from Mandrill
The Mandrill shutdown was a migration forcing function. Teams that had built their email layer as a thin wrapper over the Mandrill API had a harder time migrating than teams that had built a provider-agnostic abstraction. The abstraction adds modest upfront cost; it saves significant cost when you need to switch providers for any reason — acquisition, pricing changes, deliverability issues, vendor failure, or simply cost optimization.
sendEmail(template_id, recipient, variables, metadata). The implementation wraps whichever provider you're using today. Swapping providers is a new implementation of the interface, not a rewrite of every caller. Django Anymail is a concrete example of this pattern in Python; equivalent wrappers exist or are worth writing for other stacks. The abstraction takes a day to set up and insures against the class of migration that tanked teams this past spring.
Frequently asked questions
What about rate limits imposed by the provider?
Every provider has rate limits, usually expressed as requests per second. For lifecycle email volumes (typically hundreds to low-thousands per hour), the limits don't bind. For higher volumes, negotiate. The application should respect limits by tracking its own send rate and backing off if it sees 429 responses, not by assuming the provider will never rate-limit.
How do we handle suppression lists?
Every major provider maintains a suppression list of addresses that bounced, complained, or unsubscribed. Respect it. Also maintain your own application-side suppression list for address-level state (user deleted account, explicit no-email preference). Both lists are checked before enqueuing a send; the provider's is checked at their end as well. The underlying complaint-and-feedback-loop plumbing that populates these lists is covered in feedback loops and complaint handling for bulk senders.
Should critical lifecycle emails (password reset) bypass suppression?
It depends. Hard-bounces (invalid addresses) should not be retried — the address doesn't exist. Spam complaints are trickier — the user said they didn't want mail, but password reset arguably overrides that for the user's own benefit. Most mature implementations send password reset regardless of marketing-level suppression, but not if the address hard-bounced.
How do we test lifecycle emails in development?
Tools like Mailtrap and MailHog provide test inboxes that catch outbound mail without delivering it. Configure a different API key or SMTP endpoint for non-production, point it at the test sink, verify rendering and content. For production-like testing, provider sandbox modes or dedicated test accounts are options.
What if we need to send to millions of users (like a policy change notification)?
Use the same pipeline but with capacity planning. Pre-enqueue all the sends, let the worker pool drain over time, respect provider rate limits. Most providers can handle high volume if you give them hours rather than minutes. Bursting hundreds of thousands of sends in a minute usually triggers rate limiting or — worse — deliverability problems as the destination ISPs see the spike.
How does this interact with unsubscribe requirements?
Marketing email requires an unsubscribe mechanism by law in most jurisdictions. Lifecycle email typically doesn't, because the user needs those messages to use the product. Keep them separate: different templates, possibly different sending domains, different suppression logic. Lifecycle sends go out even to users who unsubscribed from marketing; marketing sends respect all unsubscribes.
Can we rely on provider webhooks for billing-critical state?
Partially. Webhooks tell you what the provider observed, which is mostly accurate but has edge cases (webhook delivery failures, provider outages, late webhooks). For truly billing-critical events — the receipt that must be delivered — combine webhook-based state with active checks via the provider's API at least occasionally. Webhooks are the primary signal; API polling is the safety net.
Closing perspective
Lifecycle email, done well, is invisible. The user signs up and the welcome email arrives. They forget their password and the reset arrives in seconds. They complete a purchase and the receipt is in their inbox before they've closed the tab. When these things work, nobody notices; when they don't, every failure produces friction that accumulates into churn, support load, and reputation damage.
The engineering discipline to get here is well-understood in late 2016. Decouple triggers from sends via a queue. Use idempotency keys so retries are safe. Handle webhooks with signature verification and idempotent processing. Separate templates from content. Build observability that lets you answer "did this user get the email" in seconds. Maintain the DKIM alignment and sending-domain hygiene that keeps deliverability stable. Build a provider-agnostic abstraction so the forced-migration lesson from Mandrill doesn't repeat.
None of this is novel; all of it is worth doing. The teams whose lifecycle email we admire most are the ones that treated it with the same architectural care applied to the rest of their product surface. The teams whose lifecycle email produces the most support tickets are the ones that treated it as configuration rather than engineering. The difference is a few weeks of upfront work and a permanent operational advantage.
For teams building this today, the honest sequencing: start with the queue and the idempotent send. Add webhook handling once basic sending works. Build the observability layer before you think you need it. Set up DKIM and proper authentication before you launch publicly. Consider the abstraction layer from day one. Plan for one or two provider migrations over the product's life, because they happen, and the teams that plan for them have an easier time when they do.