system-design60 min read

How to Design an Email/Notification System — A Senior+ Guide | Ayodhyya

How to Design an Email/Notification System

Building a SendGrid/Amazon SES-Scale System — Compose, Queue, Deliver, Track, Scale

Senior+ System Design Guide 10,000+ Words 20 Deep-Dive Sections C# · Mermaid · Real-World Case Studies

1. Introduction & Why Email Systems Are Hard

Email remains the backbone of digital communication. Every SaaS product, every e-commerce platform, every banking application sends emails — password resets, order confirmations, marketing campaigns, transaction receipts, shipping notifications. At scale, sending email is one of the most deceptively complex problems in system design because it touches networking, distributed systems, security, compliance, reputation management, and real-time analytics simultaneously.

Consider the numbers: SendGrid delivers over 100 billion emails per month. Amazon SES handles similar volumes. Mailgun, Postmark, SparkPost — each processes tens of billions of messages monthly. Building even a fraction of this infrastructure requires solving problems that go far beyond "connect to an SMTP server and send a message." You need to handle bounces, manage sender reputation across multiple IP addresses, implement rate limiting per customer, track opens and clicks in real-time, process webhooks for delivery status, handle list-unsubscribe headers, comply with CAN-SPAM and GDPR, support template versioning, and ensure at-least-once delivery semantics — all while maintaining sub-second latency on the API.

The challenge is amplified because email delivery is not a closed system. Unlike sending an HTTP request to a known server, email delivery involves negotiating with thousands of different receiving mail servers, each with their own filtering rules, rate limits, and reputation systems. A message that lands in the inbox at Gmail may be rejected by Outlook. A message that passes spam filters today may be blocked tomorrow because your IP reputation degraded. This inherent unpredictability makes email systems fundamentally different from request-response architectures.

Key Insight: Email is the only major communication protocol from the early internet that remains universally adopted. There is no single "email API" — you must speak SMTP, handle DNS records, manage cryptographic authentication, and negotiate with every receiving server individually.

In this deep-dive, we will design a complete email and notification platform from the ground up. We will cover the full lifecycle: from the moment an application calls your API with a message, through template rendering, queueing, reputation-aware routing, SMTP delivery, bounce processing, click/open tracking, and analytics aggregation. We will also extend the system to support multi-channel notifications — push notifications, SMS, and in-app messages — because modern notification platforms do not stop at email.

Why Build vs Buy?

Most companies start by integrating with a third-party provider like SendGrid or SES. But as you scale, several pressures push toward building your own: cost (third-party pricing at billions of messages becomes prohibitive), control (you need fine-grained deliverability management), latency (adding a third-party hop in your critical path for transactional emails), and feature requirements (deep analytics, custom routing logic, complex preference management). Understanding how these systems work internally is essential whether you are building your own, evaluating a vendor, or designing the notification layer of a larger product.

Real-World Systems to Study

  • Amazon SES: High-volume, low-cost, deeply integrated with AWS. Handles billions of emails with a focus on simplicity.
  • SendGrid (Twilio): Full-featured platform with marketing tools, template editors, analytics dashboards, and dedicated IP management.
  • Postmark: Focused exclusively on transactional email with extremely high deliverability and fast delivery times.
  • Mailgun: Developer-first email API with strong parsing and webhook capabilities.
  • Resend: Modern email API built on top of Amazon SES with a developer-friendly interface.

Each of these platforms has made different architectural trade-offs. Our design will synthesize the best ideas from each, targeting a system capable of handling 1 billion emails per day with 99.99% delivery success rate for transactional emails.

2. Functional & Non-Functional Requirements

Functional Requirements

Before designing the system, we must precisely define what it does. An email/notification platform must support the following capabilities:

  1. Send Emails via API: Customers (application developers) call a REST API or SMTP interface to send emails. The API must accept raw HTML, plain text, or template-based messages with variable substitution.
  2. Template Management: Store, version, and render email templates with Handlebar-style variable substitution. Support multiple languages and fallback templates.
  3. Batch Sending: Send a single email to millions of recipients efficiently, with deduplication and per-recipient personalization.
  4. Transactional Email Delivery: High-priority, low-latency delivery for password resets, order confirmations, 2FA codes, and other time-sensitive messages.
  5. Marketing Email Delivery: High-throughput delivery for newsletters, promotions, and campaigns with throttling to protect sender reputation.
  6. Bounce Processing: Detect and classify hard bounces (invalid address), soft bounces (mailbox full, temporary failure), and spam complaints. Update recipient status accordingly.
  7. Open & Click Tracking: Track when recipients open emails (via tracking pixel) and click links (via redirect URLs). Provide real-time and aggregated analytics.
  8. Unsubscribe Management: Support one-click unsubscribe via List-Unsubscribe headers and preference center. Honor global suppression lists.
  9. Webhook Delivery: Notify customer applications of delivery events (delivered, bounced, opened, clicked, complained) via configurable webhook endpoints.
  10. Suppression List Management: Maintain global and per-customer suppression lists. Automatically suppress hard bounces and spam complaints.
  11. Multi-Channel Notifications: Extend beyond email to support push notifications (iOS/Android), SMS, and in-app messaging through a unified API.
  12. Analytics Dashboard: Provide real-time and historical analytics on delivery rates, open rates, click rates, bounce rates, and engagement metrics.
  13. Domain & Sender Authentication: Help customers configure SPF, DKIM, and DMARC records. Manage dedicated IP pools and warming schedules.

Non-Functional Requirements

Requirement Target Rationale
Throughput1 billion emails/dayCompete with major ESPs at global scale
API Latency (P99)< 200ms for acceptApplications block on this for transactional email
Transactional Delivery Latency< 5 seconds (P95)Password resets and 2FA codes must be near-instant
Marketing Delivery Latency< 1 hour for 1M messagesBatch sends can tolerate delay for reputation protection
Availability99.99%Email downtime means missed transactions for customers
DurabilityAt-least-once deliveryNever lose a message; dedup at receiver if needed
Open Tracking Accuracy> 95%Within industry standards given privacy protections
Delivery Success Rate> 99.9% for transactionalTransaction failures directly impact user experience
ComplianceCAN-SPAM, GDPR, CCPALegal requirements in major markets
Design Trade-off: Achieving <200ms API latency while guaranteeing at-least-once delivery requires careful separation between the "accept" path (fast, synchronous) and the "deliver" path (async, durable). The API returns a message ID immediately; actual delivery happens asynchronously.

Out of Scope

For this design, we will not cover: a visual template editor (WYSIWYG), a full marketing automation workflow builder, A/B testing of email content, advanced segmentation engines, or a customer-facing analytics dashboard UI. These are important product features but are orthogonal to the core sending infrastructure.

3. Capacity Estimation & Back-of-Envelope

Traffic Assumptions

Let us assume our platform serves 10,000 customers collectively sending 1 billion emails per day. This breaks down as follows:

Metric Value Calculation
Total emails/day1,000,000,000Design target
Emails/second (avg)~11,6001B / 86,400 seconds
Emails/second (peak, 3x)~35,000Peak during business hours
Average email size50 KBHTML + text + images reference
Daily storage (emails)50 TB1B * 50KB
Storage/month~1.5 PB50 TB * 30 days (raw)
Storage/month (compressed)~300 TB5:1 compression ratio
Webhook events/day~5,000,000,0005 events per email average
API requests/day~2,000,000,0002 API calls per email (send + status)

Bandwidth Estimation

Inbound bandwidth from customer API calls: at 35,000 emails/second with 50 KB average payload, that is approximately 1.75 GB/s of inbound data. Outbound bandwidth for webhooks is similar in volume but smaller in payload (JSON status updates, ~1 KB each), giving us roughly 35 MB/s of webhook traffic. SMTP delivery bandwidth is the largest component: 35,000 emails/second * 50 KB = 1.75 GB/s sustained outbound to receiving mail servers.

Compute Estimation

Template rendering is CPU-intensive. Assuming 2 ms per template render, 35,000 renders/second requires approximately 70 CPU cores just for templating. Bounce processing, webhook delivery, analytics aggregation, and API handling add another 100+ cores. Total estimated compute: ~200 cores for sustained load, ~600 cores for peak.

Key Takeaway: At 1 billion emails/day, storage is measured in petabytes, compute in hundreds of cores, and network bandwidth in gigabytes/second. This is a big data problem as much as it is a messaging problem.

Database Sizing

We need to store: message metadata (~500 bytes per email), delivery events (~200 bytes per event), and recipient data (~100 bytes per recipient). At 1 billion emails/day with 5 events per email: 500 GB/day of metadata + 1 TB/day of events. Over 90 days (typical retention): ~135 TB of metadata + 90 TB of events. This requires a horizontally scaled database cluster with careful partitioning.

Infrastructure Summary

TEXT
=== Infrastructure Sizing (1B emails/day) ===

Application Servers:
  - API Gateway:         20 instances (8 vCPU, 16GB RAM)
  - Template Renderers:  40 instances (8 vCPU, 32GB RAM)
  - Delivery Workers:   100 instances (8 vCPU, 16GB RAM)
  - Bounce Processors:   20 instances (4 vCPU, 8GB RAM)
  - Webhook Dispatchers: 30 instances (4 vCPU, 8GB RAM)

Message Queue:
  - Kafka Cluster:       30 brokers, 600 partitions
  - Partition Strategy:  By recipient domain (for SMTP batching)

Databases:
  - PostgreSQL (metadata):  5-node cluster, 10TB NVMe each
  - Redis (caching/locks):  10-node cluster, 256GB total
  - ClickHouse (analytics): 12-node cluster, 500TB total
  - S3 (email content):     Multi-region, 300TB/month

Load Balancers:
  - HAProxy / ALB:       4 instances, handling 100K RPS

Total Estimated Monthly Cost: $280,000 - $350,000
(infrastructure only, excluding bandwidth and third-party SMTP relay costs)

4. Data Model & Storage Schema

The data model for an email platform is deceptively complex. We must track customers, their domains, sender addresses, templates, individual messages, recipients, delivery events, suppression lists, and analytics aggregates. Below is a comprehensive schema designed for horizontal scaling.

Core Entities

C#
public class Customer
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string ApiKeyHash { get; set; }       // SHA-256 of API key
    public CustomerTier Tier { get; set; }       // Free, Starter, Pro, Enterprise
    public int DailySendLimit { get; set; }
    public int MonthlySendLimit { get; set; }
    public List<VerifiedDomain> Domains { get; set; }
    public List<DedicatedIp> DedicatedIps { get; set; }
    public WebhookConfig WebhookConfig { get; set; }
    public DateTime CreatedAt { get; set; }
    public bool IsActive { get; set; }
}

public class VerifiedDomain
{
    public Guid Id { get; set; }
    public Guid CustomerId { get; set; }
    public string DomainName { get; set; }       // e.g., "mail.example.com"
    public VerificationStatus Status { get; set; }
    public bool SpfConfigured { get; set; }
    public bool DkimConfigured { get; set; }
    public string DkimSelector { get; set; }     // e.g., "sel1", "sel2"
    public string DkimPublicKey { get; set; }
    public string DkimPrivateKey { get; set; }   // Encrypted at rest
    public DmarcPolicy DmarcPolicy { get; set; }
    public DateTime VerifiedAt { get; set; }
}

public enum VerificationStatus
{
    Pending,
    Verified,
    Failed,
    Expired
}

public class EmailMessage
{
    public long Id { get; set; }                 // Snowflake ID
    public Guid CustomerId { get; set; }
    public Guid? TemplateId { get; set; }
    public string FromAddress { get; set; }
    public string FromName { get; set; }
    public string ReplyTo { get; set; }
    public string Subject { get; set; }
    public string HtmlBody { get; set; }         // Rendered HTML
    public string TextBody { get; set; }         // Rendered plain text
    public MessagePriority Priority { get; set; }
    public MessageCategory Category { get; set; } // Transactional, Marketing, System
    public MessageStatus Status { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? SentAt { get; set; }
    public DateTime? DeliveredAt { get; set; }
    public int RecipientCount { get; set; }
    public string IpAddress { get; set; }        // Sending IP used
    public Dictionary<string, string> CustomHeaders { get; set; }
    public Dictionary<string, string> Metadata { get; set; }
    public int RetryCount { get; set; }
}

public enum MessageStatus
{
    Queued,
    Processing,
    Sent,          // Accepted by downstream SMTP
    Delivered,     // Confirmed by receiving server
    Bounced,
    Deferred,      // Temporary failure, will retry
    Failed,
    Cancelled
}

public enum MessagePriority
{
    Low = 0,
    Normal = 1,
    High = 2,
    Critical = 3   // For 2FA, password reset
}

public enum MessageCategory
{
    Transactional,
    Marketing,
    System,        // Password reset, security alerts
    Notification   // Order status, shipping updates
}

Recipient & Event Models

C#
public class Recipient
{
    public long Id { get; set; }
    public long MessageId { get; set; }
    public string EmailAddress { get; set; }
    public string NormalizedEmail { get; set; }  // Lowercase, trimmed
    public RecipientStatus Status { get; set; }
    public DateTime? SentAt { get; set; }
    public DateTime? DeliveredAt { get; set; }
    public DateTime? OpenedAt { get; set; }
    public int OpenCount { get; set; }
    public DateTime? ClickedAt { get; set; }
    public int ClickCount { get; set; }
    public DateTime? BouncedAt { get; set; }
    public BounceType? BounceType { get; set; }
    public BounceCategory? BounceCategory { get; set; }
    public string SmtpResponse { get; set; }
    public string SendingIp { get; set; }
}

public enum RecipientStatus
{
    Pending,
    Sent,
    Delivered,
    Opened,
    Clicked,
    Bounced,
    SpamComplaint,
    Unsubscribed,
    Deferred
}

public class DeliveryEvent
{
    public long Id { get; set; }
    public long MessageId { get; set; }
    public long RecipientId { get; set; }
    public EventType EventType { get; set; }
    public DateTime Timestamp { get; set; }
    public string SmtpResponse { get; set; }
    public string BounceType { get; set; }        // hard, soft
    public string BounceCategory { get; set; }    // undetermined, mailbox_full, etc.
    public string IpAddress { get; set; }
    public string UserAgent { get; set; }         // For open/click tracking
    public string Url { get; set; }               // For click events
    public string GeographicRegion { get; set; }
}

public enum EventType
{
    Queued,
    Sent,
    Delivered,
    Deferred,
    Bounced,
    SpamComplaint,
    Opened,
    Clicked,
    Unsubscribed,
    LinkBlocked
}

public class SuppressionEntry
{
    public Guid Id { get; set; }
    public Guid CustomerId { get; set; }          // null for global suppressions
    public string EmailAddress { get; set; }
    public string NormalizedEmail { get; set; }
    public SuppressionReason Reason { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? ExpiresAt { get; set; }
    public string SourceMessageId { get; set; }
}

public enum SuppressionReason
{
    HardBounce,
    SpamComplaint,
    ManualUnsubscribe,
    ListUnsubscribe,
    FeedbackLoop,
    GlobalSuppression
}

public class EmailTemplate
{
    public Guid Id { get; set; }
    public Guid CustomerId { get; set; }
    public string Name { get; set; }
    public int Version { get; set; }
    public string Subject { get; set; }           // May contain variables
    public string HtmlContent { get; set; }
    public string TextContent { get; set; }
    public List<TemplateVariable> Variables { get; set; }
    public string Language { get; set; }
    public TemplateStatus Status { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime UpdatedAt { get; set; }
}

Database Partitioning Strategy

At our scale, a single database cannot hold all message metadata. We partition the email_messages and recipients tables by time (monthly partitions) and by customer ID (hash-based sharding). This ensures that queries for a specific customer's recent messages hit a small number of partitions, while analytics queries can scan partitions in parallel.

Partitioning Key: For the email_messages table, we use composite partitioning: primary partition by month (for time-based queries and archival), secondary shard by customer_id (for tenant isolation). This gives us O(1) lookups for "show me customer X's emails from last Tuesday."
C#
// Partition-aware query example
public class MessageRepository
{
    private readonly IDbConnection _connection;

    public async Task<IEnumerable<EmailMessage>> GetMessagesByCustomer(
        Guid customerId, DateTime from, DateTime to)
    {
        // Query planner uses customer_id shard + month partition pruning
        var sql = @"
            SELECT * FROM email_messages
            WHERE customer_id = @CustomerId
              AND created_at >= @From
              AND created_at < @To
            ORDER BY created_at DESC
            LIMIT 100";

        return await _connection.QueryAsync<EmailMessage>(sql, new
        {
            CustomerId = customerId,
            From = from,
            To = to
        });
    }

    public async Task<IEnumerable<DeliveryEvent>> GetEventsForMessage(long messageId)
    {
        // Events are co-partitioned with messages
        var sql = @"
            SELECT * FROM delivery_events
            WHERE message_id = @MessageId
            ORDER BY timestamp ASC";

        return await _connection.QueryAsync<DeliveryEvent>(sql, new
        {
            MessageId = messageId
        });
    }
}

5. High-Level Architecture Overview

The architecture follows a pipeline pattern: messages enter through an API gateway, pass through validation and enrichment stages, are placed into durable queues, and are processed by specialized worker pools that handle different aspects of delivery and tracking. The key architectural principle is separation of the fast path (API acceptance) from the slow path (actual delivery).

graph TB subgraph "Ingress Layer" A[Customer Application] -->|"REST API / SMTP"| B[API Gateway / Load Balancer] B --> C[API Authentication + Rate Limiting] C --> D[Input Validation + Sanitization] end subgraph "Processing Layer" D --> E[Template Renderer] D --> F[Raw Email Parser] E --> G[Message Normalizer] F --> G G --> H[Suppression Check] H --> I[Priority Classifier] end subgraph "Queue Layer" I -->|Transactional| J[Kafka: priority-high] I -->|Normal| K[Kafka: priority-normal] I -->|Marketing| L[Kafka: priority-low] end subgraph "Delivery Layer" J --> M[Transactional Workers] K --> N[Normal Workers] L --> O[Marketing Workers] M --> P[Reputation-Aware Router] N --> P O --> P P --> Q[SMTP Connection Pool] Q --> R[Receiving MX Servers] end subgraph "Tracking Layer" R -->|Bounce/Reject| S[Bounce Processor] Q -->|Delivery Confirmation| S T[Open Tracking Pixel] --> U[Event Processor] V[Click Tracking Redirect] --> U U --> W[Kafka: events] end subgraph "Storage Layer" W --> X[PostgreSQL: Message Metadata] W --> Y[ClickHouse: Analytics] W --> Z[Redis: Cache and Locks] W --> AA[S3: Email Content Archive] end subgraph "Webhook Layer" W --> AB[Webhook Dispatcher] AB --> AC[Customer Webhook Endpoints] end

Component Responsibilities

Component Responsibility Technology Scale
API GatewayAuthentication, rate limiting, routingKong / Envoy20 instances
Template RendererVariable substitution, HTML renderingC# + Scriban40 instances
Message NormalizerStandardize format, add trackingC# workers20 instances
Priority ClassifierRoute to correct queue partitionC# workers10 instances
Delivery WorkersSMTP delivery with retry logicC# + MailKit100 instances
Bounce ProcessorParse and classify delivery failuresC# workers20 instances
Event ProcessorAggregate tracking eventsC# + ClickHouse30 instances
Webhook DispatcherReliable webhook delivery to customersC# workers30 instances

Data Flow for a Single Email

sequenceDiagram participant App as Customer App participant API as API Gateway participant Render as Template Renderer participant Norm as Message Normalizer participant Queue as Kafka Queue participant Worker as Delivery Worker participant SMTP as Receiving MX participant Event as Event Processor participant WH as Webhook Dispatcher App->>API: POST /v3/mail/send API->>API: Authenticate + Rate Check API->>Render: Render Template Render->>Norm: Rendered Message Norm->>Norm: Add Tracking Pixels + Links Norm->>Norm: Check Suppression List Norm->>Queue: Enqueue with partition key API-->>App: 202 Accepted message_id Queue->>Worker: Dequeue Message Worker->>Worker: Lookup MX Records cached Worker->>Worker: Check IP Reputation Worker->>SMTP: SMTP MAIL + DATA SMTP-->>Worker: 250 OK or 550 Reject alt Delivered Worker->>Event: Delivery Confirmed Event->>WH: Webhook delivered WH-->>App: POST webhook endpoint else Bounced Worker->>Event: Bounce Detected Event->>Event: Update Suppression List Event->>WH: Webhook bounced WH-->>App: POST webhook endpoint end

6. DNS Authentication — SPF, DKIM, DMARC

Email authentication is the foundation of deliverability. Without proper DNS records, receiving servers will either reject your messages outright or route them to spam. Understanding SPF, DKIM, and DMARC at a deep level is essential for building a credible email platform.

SPF (Sender Policy Framework)

SPF is a DNS TXT record that lists the IP addresses authorized to send email on behalf of a domain. When a receiving server gets an email claiming to be from example.com, it checks the SPF record to verify the sending IP is authorized.

TEXT
# SPF record for mail.example.com
# Allows SendGrid IPs and our own infrastructure
v=spf1 include:sendgrid.net include:_spf.google.com ip4:192.0.2.0/24 ip4:203.0.113.0/24 -all

# Breakdown:
#   v=spf1              - SPF version 1
#   include:sendgrid.net - Delegate to SendGrid SPF record
#   ip4:192.0.2.0/24    - Allow our sending IP range
#   -all                - Reject all other senders

DKIM (DomainKeys Identified Mail)

DKIM adds a cryptographic signature to each email, allowing the receiving server to verify that the message was actually sent by the claimed domain and has not been modified in transit. The private key signs the message; the public key is published in DNS.

C#
public class DkimSigner
{
    private readonly RSA _privateKey;
    private readonly string _domain;
    private readonly string _selector;

    public DkimSigner(string privateKeyPem, string domain, string selector)
    {
        _domain = domain;
        _selector = selector;
        _privateKey = RSA.Create();
        _privateKey.ImportFromPem(privateKeyPem);
    }

    public string Sign(MimeMessage message)
    {
        // DKIM signing follows RFC 6376
        // 1. Select headers to sign (From, To, Subject, Date, Message-ID)
        // 2. Canonicalize headers (simple or relaxed)
        // 3. Compute SHA-256 hash of canonicalized headers
        // 4. Sign the hash with RSA private key
        // 5. Base64-encode the signature

        var headers = new[] { "from", "to", "subject", "date", "message-id" };
        var canonicalized = CanonicalizeHeaders(message, headers);
        var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonicalized));
        var signature = _privateKey.SignData(
            hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);

        var signatureHeader = $"v=1; a=rsa-sha256; d={_domain}; s={_selector};" +
            $" c=relaxed/relaxed;" +
            $" q=dns/txt; h=from:to:subject:date:message-id;" +
            $" bh={Convert.ToBase64String(hash)};" +
            $" b={Convert.ToBase64String(signature)}";

        return signatureHeader;
    }

    private string CanonicalizeHeaders(MimeMessage message, string[] headers)
    {
        // Relaxed canonicalization: lowercase header names,
        // collapse whitespace, remove empty lines
        var sb = new StringBuilder();
        foreach (var header in headers)
        {
            var value = message.Headers[header];
            if (value != null)
            {
                sb.Append($"{header.ToLower()}: {value.Trim()}\r\n");
            }
        }
        return sb.ToString();
    }
}

DMARC (Domain-based Message Authentication)

DMARC ties SPF and DKIM together with a policy that tells receiving servers what to do when authentication fails. It also provides a reporting mechanism so domain owners can monitor who is sending email on their behalf.

TEXT
# DMARC record for example.com
_dmarc.example.com. IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com; ruf=mailto:dmarc-forensic@example.com; fo=1; adkim=r; aspf=r; pct=100"

# Policy Options:
#   p=none     - Do nothing (monitor only)
#   p=quarantine - Send to spam folder
#   p=reject   - Reject the email entirely

# Alignment Modes:
#   adkim=r  - Relaxed DKIM alignment (subdomains OK)
#   adkim=s  - Strict DKIM alignment (exact domain match)
Critical: When onboarding new customers, never skip DNS verification. An improperly configured SPF record can destroy your entire sending domain reputation. Implement automated DNS record checking with clear error messages guiding customers through setup.

DNS Record Management Service

C#
public class DnsVerificationService
{
    private readonly IDnsResolver _dnsResolver;
    private readonly ILogger<DnsVerificationService> _logger;

    public async Task<DomainVerificationResult> VerifyDomainAsync(VerifiedDomain domain)
    {
        var result = new DomainVerificationResult();

        // 1. Check SPF record
        var spfRecord = await _dnsResolver.GetTxtRecordsAsync(domain.DomainName);
        result.SpfConfigured = spfRecord.Any(r =>
            r.Contains("v=spf1") && r.Contains("include:ourplatform.com"));

        // 2. Check DKIM record
        var dkimRecord = await _dnsResolver.GetTxtRecordsAsync(
            $"{domain.DkimSelector}._domainkey.{domain.DomainName}");
        result.DkimConfigured = dkimRecord.Any(r =>
            r.Contains("v=DKIM1") && r.Contains("k=rsa"));

        // 3. Check DMARC record (optional but recommended)
        var dmarcRecord = await _dnsResolver.GetTxtRecordsAsync(
            $"_dmarc.{domain.DomainName}");
        result.DmarcConfigured = dmarcRecord.Any(r => r.Contains("v=DMARC1"));

        // 4. Verify return-path domain (for SPF alignment)
        var returnPath = $"bounces.{domain.DomainName}";
        var mxRecords = await _dnsResolver.GetMxRecordsAsync(returnPath);
        result.ReturnPathConfigured = mxRecords.Any();

        _logger.LogInformation(
            "Domain {Domain} verification: SPF={Spf}, DKIM={Dkim}, DMARC={Dmarc}",
            domain.DomainName, result.SpfConfigured, result.DkimConfigured, result.DmarcConfigured);

        return result;
    }
}

public class DomainVerificationResult
{
    public bool SpfConfigured { get; set; }
    public bool DkimConfigured { get; set; }
    public bool DmarcConfigured { get; set; }
    public bool ReturnPathConfigured { get; set; }
    public bool AllPassed => SpfConfigured && DkimConfigured;
}

7. Email Composition & Templating

Template rendering is one of the most CPU-intensive operations in the system. For marketing campaigns sending 10 million personalized emails, we need to render 10 million unique HTML documents. The templating engine must be fast, secure (no server-side code execution), and support complex logic including conditionals, loops, and partials.

Template Engine Requirements

  • Variable Substitution: Replace {{variable}} placeholders with actual values.
  • Conditionals: {{#if premium}}...{{/if}} for conditional content blocks.
  • Loops: {{#each items}}...{{/each}} for dynamic lists.
  • Partials/Includes: Reusable header/footer components.
  • Layouts: Base templates with content injection points.
  • Auto-Escaping: HTML entities are escaped by default to prevent XSS.
  • Inline CSS: Automatically inlines CSS for email client compatibility.
C#
public class EmailTemplateEngine
{
    private readonly TemplateCache _cache;
    private readonly CssInliner _cssInliner;
    private readonly HtmlSanitizer _sanitizer;

    public EmailTemplateEngine(TemplateCache cache, CssInliner cssInliner, HtmlSanitizer sanitizer)
    {
        _cache = cache;
        _cssInliner = cssInliner;
        _sanitizer = sanitizer;
    }

    public async Task<RenderedEmail> RenderAsync(
        EmailTemplate template,
        Dictionary<string, object> variables,
        string language = "en")
    {
        // 1. Check cache for compiled template
        var compiled = await _cache.GetOrAddAsync(
            $"{template.Id}:v{template.Version}:{language}",
            async () => await CompileTemplate(template, language));

        // 2. Render with variables (Scriban)
        var renderedHtml = compiled.HtmlTemplate.Render(variables);
        var renderedText = compiled.TextTemplate.Render(variables);
        var renderedSubject = compiled.SubjectTemplate.Render(variables);

        // 3. Inline CSS for email client compatibility
        var inlinedHtml = _cssInliner.Inline(renderedHtml);

        // 4. Sanitize to prevent XSS
        var sanitizedHtml = _sanitizer.Sanitize(inlinedHtml);

        return new RenderedEmail
        {
            Subject = renderedSubject,
            HtmlBody = sanitizedHtml,
            TextBody = renderedText
        };
    }

    private async Task<CompiledTemplate> CompileTemplate(EmailTemplate template, string language)
    {
        // Parse templates with Scriban (faster than Razor for simple substitution)
        var htmlTemplate = Template.Parse(template.HtmlContent);
        var textTemplate = Template.Parse(template.TextContent);
        var subjectTemplate = Template.Parse(template.Subject);

        if (htmlTemplate.HasErrors)
            throw new TemplateCompilationException(htmlTemplate.Messages);

        return new CompiledTemplate
        {
            HtmlTemplate = htmlTemplate,
            TextTemplate = textTemplate,
            SubjectTemplate = subjectTemplate
        };
    }
}

Template Versioning Strategy

Templates are versioned immutably. When a customer updates a template, we create a new version rather than overwriting. This ensures that in-flight campaigns using the old version continue to render correctly. Old versions are retained for 90 days for audit purposes.

sequenceDiagram participant Customer participant API as Template API participant DB as Template Store participant Cache as Redis Cache participant Render as Render Workers Customer->>API: PUT /templates/id new version API->>DB: Store new version version=N+1 API->>Cache: Invalidate old cached version API-->>Customer: 200 OK version N+1 Note over Customer,Render: Later, when sending emails... Customer->>API: POST /mail/send template_id version N API->>Cache: Get compiled template cache miss Cache->>DB: Fetch template version N DB-->>Cache: Template content Cache->>Cache: Compile and cache Cache-->>Render: Compiled template Render->>Render: Render with variables

Inlining CSS for Email Clients

Email clients have notoriously poor CSS support. Gmail strips style blocks entirely. Outlook ignores most CSS properties. The only reliable way to style HTML emails is to inline CSS directly onto elements. Our template engine runs a CSS inliner as a post-processing step.

C#
public class CssInliner
{
    public string Inline(string htmlWithStyles)
    {
        // Parse the HTML document
        var doc = HtmlParser.Parse(htmlWithStyles);

        // Extract all style block rules
        var styleBlocks = doc.QuerySelectorAll("style");
        var cssRules = new List<CssRule>();
        foreach (var style in styleBlocks)
        {
            cssRules.AddRange(CssParser.ParseRules(style.TextContent));
            style.Remove();  // Remove the style block
        }

        // Apply CSS rules to matching elements
        foreach (var rule in cssRules)
        {
            var matchingElements = doc.QuerySelectorAll(rule.Selector);
            foreach (var element in matchingElements)
            {
                var existingStyle = element.GetAttribute("style") ?? "";
                element.SetAttribute("style", $"{existingStyle} {rule.Properties}");
            }
        }

        return doc.ToHtml();
    }
}

public class RenderedEmail
{
    public string Subject { get; set; }
    public string HtmlBody { get; set; }
    public string TextBody { get; set; }
}

public class CompiledTemplate
{
    public Template HtmlTemplate { get; set; }
    public Template TextTemplate { get; set; }
    public Template SubjectTemplate { get; set; }
}
Email Client Compatibility: Always test rendered emails across major email clients (Gmail, Outlook, Apple Mail, Yahoo). Use tools like Litmus or Email on Acid to verify rendering. Inlining CSS, using table-based layouts, and avoiding CSS Grid/Flexbox are essential practices for cross-client compatibility.

8. The Sending Pipeline

The sending pipeline is the core of the system. It transforms an API request into a delivered email through a series of well-defined stages. Each stage is independently scalable and failure-isolated. The pipeline uses Kafka for durable message passing between stages, ensuring at-least-once delivery semantics.

Pipeline Stages

graph LR A[1. Validate] --> B[2. Authenticate] B --> C[3. Enrich] C --> D[4. Deduplicate] D --> E[5. Render] E --> F[6. Sanitize] F --> G[7. Personalize] G --> H[8. Track] H --> I[9. Queue] I --> J[10. Route] J --> K[11. Deliver] K --> L[12. Monitor]
C#
public class SendingPipeline
{
    private readonly IValidator<SendRequest> _validator;
    private readonly IAuthenticationService _authService;
    private readonly IEnrichmentService _enrichmentService;
    private readonly IDeduplicationService _deduplicationService;
    private readonly ITemplateEngine _templateEngine;
    private readonly ISanitizer _sanitizer;
    private readonly ITrackingService _trackingService;
    private readonly IKafkaProducer _kafkaProducer;
    private readonly ISuppressionService _suppressionService;

    public async Task<PipelineResult> ProcessAsync(SendRequest request, CancellationToken ct)
    {
        var pipelineId = Guid.NewGuid();
        var sw = Stopwatch.StartNew();

        // Stage 1: Validate input
        var validationResult = await _validator.ValidateAsync(request);
        if (!validationResult.IsValid)
            return PipelineResult.Rejected(validationResult.Errors);

        // Stage 2: Authenticate sender domain
        var domain = await _authService.GetVerifiedDomainAsync(request.From);
        if (domain == null || domain.Status != VerificationStatus.Verified)
            return PipelineResult.Rejected("Sender domain not verified");

        // Stage 3: Enrich with default values, custom headers, tags
        var enriched = await _enrichmentService.EnrichAsync(request, domain);

        // Stage 4: Deduplicate (within 5-minute window)
        var isDuplicate = await _deduplicationService.CheckAsync(enriched);
        if (isDuplicate)
            return PipelineResult.Duplicate(enriched.MessageId);

        // Stage 5: Render template (if template-based)
        RenderedEmail rendered;
        if (enriched.TemplateId.HasValue)
        {
            rendered = await _templateEngine.RenderAsync(
                enriched.TemplateId.Value, enriched.Variables);
        }
        else
        {
            rendered = new RenderedEmail
            {
                Subject = enriched.Subject,
                HtmlBody = enriched.HtmlContent,
                TextBody = enriched.TextContent
            };
        }

        // Stage 6: Sanitize HTML (strip scripts, dangerous attributes)
        rendered.HtmlBody = await _sanitizer.SanitizeAsync(rendered.HtmlBody);

        // Stage 7: Check suppression list for each recipient
        var allowedRecipients = new List<RecipientData>();
        var suppressedCount = 0;
        foreach (var recipient in enriched.Recipients)
        {
            var isSuppressed = await _suppressionService.IsSuppressedAsync(
                recipient.Email, domain.CustomerId);
            if (!isSuppressed)
                allowedRecipients.Add(recipient);
            else
                suppressedCount++;
        }

        // Stage 8: Add tracking pixel and rewrite links
        var tracked = await _trackingService.AddTrackingAsync(rendered, enriched.MessageId);

        // Stage 9: Serialize and enqueue with partition key
        var message = new OutboundMessage
        {
            MessageId = enriched.MessageId,
            CustomerId = domain.CustomerId,
            From = enriched.From,
            Recipients = allowedRecipients,
            Subject = tracked.Subject,
            HtmlBody = tracked.HtmlBody,
            TextBody = tracked.TextBody,
            Priority = enriched.Priority,
            Category = enriched.Category,
            CreatedAt = DateTime.UtcNow
        };

        // Partition by recipient domain for SMTP batching efficiency
        var partitionKey = allowedRecipients.First().Domain;
        await _kafkaProducer.ProduceAsync(
            "outbound-emails", partitionKey, message, ct);

        sw.Stop();
        _logger.LogInformation(
            "Pipeline {PipelineId} completed in {Elapsed}ms. " +
            "MessageId={MessageId}, Recipients={Count}, Suppressed={Suppressed}",
            pipelineId, sw.ElapsedMilliseconds, enriched.MessageId,
            allowedRecipients.Count, suppressedCount);

        return PipelineResult.Accepted(enriched.MessageId, allowedRecipients.Count);
    }
}

Kafka Topic Design

Topic Partitions Replication Retention Consumer Groups
outbound-emails60037 daysdelivery-workers
delivery-events300330 daysevent-processors
bounce-events50330 daysbounce-processors
webhook-events15037 dayswebhook-dispatchers
open-click-events20037 daysanalytics-aggregators
Why 600 partitions for outbound-emails? Each partition can be consumed by exactly one consumer instance in a consumer group. With 100 delivery worker instances, we need at least 100 partitions. But we also want to batch SMTP connections by recipient domain, which requires the same domain to always land on the same partition. With 600 partitions and hash-based partitioning by domain, we get good distribution while maintaining domain affinity.

9. Delivery & SMTP Internals

SMTP delivery is the most complex and failure-prone part of the system. Each email delivery involves DNS lookups, TCP connections, TLS handshakes, SMTP protocol negotiation, and potentially hostile responses from receiving servers that may rate-limit, reject, or silently drop messages. Our delivery workers must handle all of these scenarios gracefully.

MX Record Resolution

Before sending an email, we must determine which mail server handles the recipient's domain. This is done via DNS MX record lookup. MX records include a preference value that indicates priority (lower = higher priority). We cache MX records aggressively (TTL-based, typically 1 hour) because they change infrequently.

C#
public class MxResolver
{
    private readonly IDnsResolver _dnsResolver;
    private readonly IMemoryCache _cache;
    private readonly ILogger<MxResolver> _logger;

    public async Task<IReadOnlyList<MxRecord>> ResolveAsync(string domain)
    {
        var cacheKey = $"mx:{domain.ToLower()}";
        if (_cache.TryGetValue(cacheKey, out IReadOnlyList<MxRecord> cached))
            return cached;

        try
        {
            var records = await _dnsResolver.GetMxRecordsAsync(domain);
            var sorted = records.OrderBy(r => r.Preference).ToList();

            if (sorted.Count == 0)
            {
                // No MX record - fall back to A record (RFC 5321)
                var aRecord = await _dnsResolver.GetARecordAsync(domain);
                if (aRecord != null)
                {
                    sorted.Add(new MxRecord
                    {
                        Host = domain,
                        Preference = 0,
                        IpAddress = aRecord
                    });
                }
            }

            var ttl = records.FirstOrDefault()?.Ttl ?? 3600;
            _cache.Set(cacheKey, sorted.AsReadOnly(), TimeSpan.FromSeconds(ttl));

            _logger.LogDebug("Resolved MX for {Domain}: {Count} records",
                domain, sorted.Count);

            return sorted.AsReadOnly();
        }
        catch (DnsException ex)
        {
            _logger.LogWarning(ex, "DNS resolution failed for {Domain}", domain);
            return Array.Empty<MxRecord>();
        }
    }
}

SMTP Delivery Worker

C#
public class SmtpDeliveryWorker : BackgroundService
{
    private readonly IMessageConsumer _consumer;
    private readonly MxResolver _mxResolver;
    private readonly SmtpConnectionPool _connectionPool;
    private readonly IReputationService _reputationService;
    private readonly IEventEmitter _eventEmitter;
    private readonly DeliveryMetrics _metrics;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var message in _consumer.ConsumeAsync(
            "outbound-emails", stoppingToken))
        {
            try
            {
                await DeliverMessageAsync(message, stoppingToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex,
                    "Failed to deliver message {MessageId}", message.MessageId);
                await HandleDeliveryFailureAsync(message, ex);
            }
        }
    }

    private async Task DeliverMessageAsync(
        OutboundMessage message, CancellationToken ct)
    {
        // Group recipients by domain for batched SMTP sessions
        var recipientsByDomain = message.Recipients
            .GroupBy(r => r.Domain)
            .ToDictionary(g => g.Key, g => g.ToList());

        foreach (var (domain, recipients) in recipientsByDomain)
        {
            // 1. Resolve MX records
            var mxRecords = await _mxResolver.ResolveAsync(domain);
            if (mxRecords.Count == 0)
            {
                await EmitBounceAsync(message, recipients, "DNS resolution failed");
                continue;
            }

            // 2. Select sending IP based on reputation
            var sendingIp = await _reputationService
                .SelectIpForDomainAsync(domain);

            // 3. Get or create SMTP connection
            var connection = await _connectionPool
                .GetConnectionAsync(mxRecords, sendingIp, ct);

            try
            {
                // 4. Send envelope
                await connection.SendMailAsync(
                    from: $"{sendingIp.Domain}",
                    recipients: recipients.Select(r => r.Email).ToList(),
                    message: message,
                    ct: ct);

                // 5. Emit delivery events
                foreach (var recipient in recipients)
                {
                    await _eventEmitter.EmitAsync(new DeliveryEvent
                    {
                        MessageId = message.MessageId,
                        RecipientId = recipient.Id,
                        EventType = EventType.Sent,
                        Timestamp = DateTime.UtcNow,
                        IpAddress = sendingIp.Address
                    });
                    _metrics.IncrementDelivered(domain);
                }
            }
            catch (SmtpException ex) when (IsTemporaryFailure(ex))
            {
                await HandleDeferralAsync(message, recipients, ex);
                _metrics.IncrementDeferred(domain);
            }
            catch (SmtpException ex) when (IsPermanentFailure(ex))
            {
                await EmitBounceAsync(message, recipients, ex.Message);
                _metrics.IncrementBounced(domain);
            }
        }
    }

    private bool IsTemporaryFailure(SmtpException ex)
    {
        // SMTP 4xx responses are temporary failures
        return ex.StatusCode >= 400 && ex.StatusCode < 500;
    }

    private bool IsPermanentFailure(SmtpException ex)
    {
        // SMTP 5xx responses are permanent failures
        return ex.StatusCode >= 500 && ex.StatusCode < 600;
    }
}

SMTP Connection Pool

Creating a new TCP+TLS connection for every email is prohibitively expensive. TLS handshakes alone take 100-300ms. We maintain a pool of persistent SMTP connections, grouped by destination MX host and sending IP. Connections are recycled after a configurable number of messages (default: 500) or time period (default: 30 minutes) to avoid server-side connection limits.

C#
public class SmtpConnectionPool
{
    private readonly ConcurrentDictionary<string, Channel<SmtpConnection>> _pool = new();
    private readonly SmtpConnectionConfig _config;

    public async Task<SmtpConnection> GetConnectionAsync(
        IReadOnlyList<MxRecord> mxRecords,
        SendingIp sendingIp,
        CancellationToken ct)
    {
        // Try MX records in priority order
        foreach (var mx in mxRecords)
        {
            var poolKey = $"{sendingIp.Address}:{mx.Host}";
            var channel = _pool.GetOrAdd(poolKey, _ => CreateChannel());

            // Try to get an existing connection from the pool
            if (channel.Reader.TryRead(out var existing) && existing.IsActive)
            {
                return existing;
            }

            // Create a new connection
            try
            {
                var connection = await SmtpConnection.CreateAsync(
                    mx.Host, mx.IpAddress, 25, sendingIp, _config, ct);

                // Start EHLO/STARTTLS handshake
                await connection.HandshakeAsync(ct);

                return connection;
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex,
                    "Failed to connect to {Host} via {Ip}",
                    mx.Host, mx.IpAddress);
                continue;
            }
        }

        throw new SmtpDeliveryException(
            $"Failed to connect to any MX server for {mxRecords.First().Host}");
    }

    public async Task ReturnAsync(SmtpConnection connection)
    {
        var poolKey = $"{connection.SendingIp.Address}:{connection.DestinationHost}";
        if (_pool.TryGetValue(poolKey, out var channel))
        {
            if (connection.MessageCount < _config.MaxMessagesPerConnection)
            {
                await channel.Writer.WriteAsync(connection);
            }
            else
            {
                await connection.QuitAsync();
                connection.Dispose();
            }
        }
    }
}

SMTP Protocol Deep-Dive

sequenceDiagram participant Sender as Delivery Worker participant Receiver as MX Server Sender->>Receiver: TCP connect to port 25 Receiver-->>Sender: 220 mail.receiver.com ESMTP Sender->>Receiver: EHLO sender.com Receiver-->>Sender: 250-STARTTLS 250-SIZE 52428800 250-8BITMIME Sender->>Receiver: STARTTLS Receiver-->>Sender: 220 Ready to start TLS Note over Sender,Receiver: TLS Handshake ECDHE-RSA-AES256 Sender->>Receiver: EHLO sender.com encrypted Receiver-->>Sender: 250 OK Sender->>Receiver: MAIL FROM bounces@sender.com Receiver-->>Sender: 250 OK Sender->>Receiver: RCPT TO user@receiver.com Receiver-->>Sender: 250 OK or 550 rejected Sender->>Receiver: DATA Receiver-->>Sender: 354 Start mail input Sender->>Receiver: Headers and Body Sender->>Receiver: . Receiver-->>Sender: 250 OK Message queued Sender->>Receiver: QUIT Receiver-->>Sender: 221 Bye
Key Design Decision: We use SMTP pipelining (sending multiple commands without waiting for responses) where the receiving server supports it (indicated by the PIPELINING ESMTP extension). This can reduce round-trips from 7+ to 3-4 for a single message, significantly improving throughput.

10. Bounce & Complaint Handling

Bounce and complaint handling is critical for maintaining sender reputation. A single unrecognized bounce pattern can cause your IP addresses to be blacklisted by major ISPs. The system must classify bounces correctly, update suppression lists in real-time, and notify customer applications via webhooks.

Bounce Classification

Category SMTP Code Action Suppression
Hard Bounce (Bad Address)550 5.1.1Immediate bounce, no retryAdd to suppression list
Hard Bounce (Mailbox Full)552 5.2.2Bounce, may retry onceAdd to suppression after 2nd bounce
Soft Bounce (Temporary)421 4.7.1Retry with exponential backoffNone (transient)
Soft Bounce (Rate Limited)452 4.7.1Throttle and retryNone (transient)
Spam Complaint (FBL)N/A (via FBL)Immediate suppressionAdd to suppression list
Content Rejection550 6.7.1Bounce, investigate contentAlert customer
Authentication Failure550 7.1Bounce, fix DNS recordsAlert customer
C#
public class BounceProcessor : BackgroundService
{
    private readonly IMessageConsumer _consumer;
    private readonly ISuppressionService _suppressionService;
    private readonly IReputationService _reputationService;
    private readonly IWebhookDispatcher _webhookDispatcher;
    private readonly IBounceRepository _bounceRepository;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var evt in _consumer.ConsumeAsync(
            "bounce-events", stoppingToken))
        {
            await ProcessBounceAsync(evt, stoppingToken);
        }
    }

    private async Task ProcessBounceAsync(
        DeliveryEvent bounceEvent, CancellationToken ct)
    {
        var classification = ClassifyBounce(bounceEvent);

        switch (classification.Severity)
        {
            case BounceSeverity.Hard:
                // Immediately add to suppression list
                await _suppressionService.AddAsync(new SuppressionEntry
                {
                    CustomerId = bounceEvent.CustomerId,
                    EmailAddress = bounceEvent.RecipientEmail,
                    Reason = SuppressionReason.HardBounce,
                    CreatedAt = DateTime.UtcNow,
                    SourceMessageId = bounceEvent.MessageId.ToString()
                });

                // Update reputation
                await _reputationService.RecordBounceAsync(
                    bounceEvent.SendingIp,
                    bounceEvent.RecipientDomain, true);

                _logger.LogWarning(
                    "Hard bounce: {Email} - {Response}",
                    bounceEvent.RecipientEmail, bounceEvent.SmtpResponse);
                break;

            case BounceSeverity.Soft:
                // Check retry count
                var retryCount = await _bounceRepository.GetRetryCountAsync(
                    bounceEvent.MessageId, bounceEvent.RecipientId);

                if (retryCount < 3)
                {
                    // Re-enqueue with delay
                    var delay = CalculateBackoffDelay(retryCount);
                    await _consumer.DelayAsync(
                        "outbound-emails", bounceEvent.Message, delay);
                    await _bounceRepository.IncrementRetryCountAsync(
                        bounceEvent.MessageId, bounceEvent.RecipientId);
                }
                else
                {
                    // Max retries exceeded - treat as hard bounce
                    await _suppressionService.AddAsync(new SuppressionEntry
                    {
                        CustomerId = bounceEvent.CustomerId,
                        EmailAddress = bounceEvent.RecipientEmail,
                        Reason = SuppressionReason.HardBounce,
                        CreatedAt = DateTime.UtcNow
                    });
                }
                break;

            case BounceSeverity.Complaint:
                // Spam complaint - highest severity
                await _suppressionService.AddAsync(new SuppressionEntry
                {
                    CustomerId = bounceEvent.CustomerId,
                    EmailAddress = bounceEvent.RecipientEmail,
                    Reason = SuppressionReason.SpamComplaint,
                    CreatedAt = DateTime.UtcNow
                });

                // Aggressively reduce IP reputation
                await _reputationService.RecordComplaintAsync(
                    bounceEvent.SendingIp, bounceEvent.RecipientDomain);

                // Notify customer immediately
                await _webhookDispatcher.DispatchAsync(
                    bounceEvent.CustomerId, new WebhookEvent
                {
                    EventType = "spam_complaint",
                    MessageId = bounceEvent.MessageId,
                    Recipient = bounceEvent.RecipientEmail,
                    Timestamp = DateTime.UtcNow
                });
                break;
        }

        // Always emit to analytics
        await _webhookDispatcher.DispatchAsync(
            bounceEvent.CustomerId, new WebhookEvent
        {
            EventType = classification.Severity.ToString().ToLower(),
            MessageId = bounceEvent.MessageId,
            Recipient = bounceEvent.RecipientEmail,
            Timestamp = DateTime.UtcNow,
            Details = new Dictionary<string, string>
            {
                ["smtp_code"] = bounceEvent.SmtpCode.ToString(),
                ["smtp_response"] = bounceEvent.SmtpResponse,
                ["bounce_type"] = classification.Type.ToString(),
                ["bounce_category"] = classification.Category
            }
        });
    }

    private BounceClassification ClassifyBounce(DeliveryEvent evt)
    {
        var code = evt.SmtpCode;

        return code switch
        {
            // 5.x.x permanent failures
            550 when evt.SmtpResponse.Contains("5.1.1") =>
                new(BounceSeverity.Hard, BounceType.Permanent, "bad_mailbox"),
            550 when evt.SmtpResponse.Contains("5.7.1") =>
                new(BounceSeverity.Hard, BounceType.Permanent, "auth_failure"),
            552 =>
                new(BounceSeverity.Hard, BounceType.Permanent, "mailbox_full"),

            // 4.x.x temporary failures
            421 =>
                new(BounceSeverity.Soft, BounceType.Transient, "server_busy"),
            452 =>
                new(BounceSeverity.Soft, BounceType.Transient, "insufficient_storage"),
            451 =>
                new(BounceSeverity.Soft, BounceType.Transient, "local_error"),

            // Default
            >= 500 =>
                new(BounceSeverity.Hard, BounceType.Permanent, "unknown_5xx"),
            >= 400 =>
                new(BounceSeverity.Soft, BounceType.Transient, "unknown_4xx"),
            _ =>
                new(BounceSeverity.Soft, BounceType.Transient, "unknown")
        };
    }

    private TimeSpan CalculateBackoffDelay(int retryCount)
    {
        // Exponential backoff: 5min, 30min, 2hr
        return retryCount switch
        {
            0 => TimeSpan.FromMinutes(5),
            1 => TimeSpan.FromMinutes(30),
            2 => TimeSpan.FromHours(2),
            _ => TimeSpan.FromHours(6)
        };
    }
}
Bounce Rate Thresholds: If your bounce rate exceeds 2% for any sending domain, major ISPs (Gmail, Outlook, Yahoo) will begin throttling or blocking your messages. Monitor bounce rates per IP, per domain, and per customer in real-time. Auto-pause sending when thresholds are breached.

Feedback Loop (FBL) Processing

Major ISPs (Gmail, Outlook, Yahoo, AOL) provide feedback loops that notify senders when recipients mark messages as spam. These notifications arrive via email to a registered FBL address and must be processed within minutes to prevent reputation damage. We parse FBL messages, extract the original message ID from DKIM signatures or custom headers, and immediately suppress the complaining recipient.

C#
public class FeedbackLoopProcessor
{
    private readonly ISuppressionService _suppressionService;
    private readonly IReputationService _reputationService;

    public async Task ProcessFeedbackLoopAsync(MimeMessage fblMessage)
    {
        // FBL messages contain the original message in a multipart/report
        // We extract the original Message-ID from the headers
        var originalMessageId = ExtractMessageId(fblMessage);
        var complainingEmail = ExtractComplainingAddress(fblMessage);

        if (originalMessageId == null || complainingEmail == null)
        {
            _logger.LogWarning("Could not parse FBL message");
            return;
        }

        // Look up the original message to get customer context
        var originalMessage = await _messageRepository
            .GetByIdAsync(originalMessageId.Value);

        if (originalMessage == null)
        {
            _logger.LogWarning(
                "Original message {Id} not found for FBL",
                originalMessageId);
            return;
        }

        // Suppress the complaining address
        await _suppressionService.AddAsync(new SuppressionEntry
        {
            CustomerId = originalMessage.CustomerId,
            EmailAddress = complainingEmail,
            Reason = SuppressionReason.FeedbackLoop,
            CreatedAt = DateTime.UtcNow,
            SourceMessageId = originalMessageId.ToString()
        });

        // Reduce IP reputation
        await _reputationService.RecordComplaintAsync(
            originalMessage.IpAddress,
            ExtractDomain(complainingEmail));

        _logger.LogInformation(
            "FBL processed: {Email} complained about message {MessageId}",
            complainingEmail, originalMessageId);
    }
}

11. Email Reputation & Deliverability

Email reputation is a fragile, multi-dimensional score that determines whether your messages reach the inbox, the spam folder, or are rejected outright. It is maintained per IP address, per sending domain, and per envelope sender domain. Reputation is influenced by bounce rates, complaint rates, engagement rates, volume consistency, and authentication compliance.

Reputation Dimensions

Dimension Score Range Impact
IP Reputation0-100Per-IP deliverability; new IPs start at 50
Domain Reputation0-100Per-sending-domain score; harder to recover
Volume ConsistencyBinarySudden volume spikes trigger throttling
Bounce Rate0-10%Must stay below 2%; alert at 1%
Complaint Rate0-1%Must stay below 0.1%; auto-pause at 0.05%
Engagement Score0-100Open + click rates indicate content quality
C#
public class ReputationService : IReputationService
{
    private readonly IRedisCache _cache;
    private readonly IReputationRepository _repository;
    private readonly ILogger<ReputationService> _logger;

    private const double BOUNCE_RATE_THRESHOLD = 0.02;     // 2%
    private const double COMPLAINT_RATE_THRESHOLD = 0.001; // 0.1%
    private const double DEFERRED_RATE_THRESHOLD = 0.05;   // 5%

    public async Task<SendingIp> SelectIpForDomainAsync(string recipientDomain)
    {
        // Get all available sending IPs
        var ips = await _repository.GetSendingIpsAsync();

        // Filter by reputation score
        var eligible = ips.Where(ip =>
        {
            var score = GetIpScore(ip);
            return score >= 40; // Minimum threshold
        }).ToList();

        if (eligible.Count == 0)
            throw new NoEligibleIpException(
                "All IPs below minimum reputation threshold");

        // Weight by reputation score
        var totalScore = eligible.Sum(ip => GetIpScore(ip));
        var random = Random.Shared.NextDouble() * totalScore;
        var cumulative = 0.0;

        foreach (var ip in eligible)
        {
            cumulative += GetIpScore(ip);
            if (random <= cumulative)
                return ip;
        }

        return eligible.Last();
    }

    public async Task<double> GetIpScoreAsync(string ipAddress)
    {
        var cacheKey = $"reputation:ip:{ipAddress}";
        if (_cache.TryGetValue(cacheKey, out double cached))
            return cached;

        var metrics = await _repository.GetIpMetricsAsync(
            ipAddress, TimeSpan.FromDays(30));

        double score = 70; // Base score

        // Bounce rate impact
        if (metrics.BounceRate > BOUNCE_RATE_THRESHOLD)
            score -= (metrics.BounceRate - BOUNCE_RATE_THRESHOLD) * 500;

        // Complaint rate impact (heavier penalty)
        if (metrics.ComplaintRate > COMPLAINT_RATE_THRESHOLD)
            score -= (metrics.ComplaintRate - COMPLAINT_RATE_THRESHOLD) * 5000;

        // Deferred rate impact
        if (metrics.DeferredRate > DEFERRED_RATE_THRESHOLD)
            score -= (metrics.DeferredRate - DEFERRED_RATE_THRESHOLD) * 200;

        // Engagement bonus
        score += (metrics.OpenRate * 20) + (metrics.ClickRate * 30);

        // Clamp to 0-100
        score = Math.Clamp(score, 0, 100);

        _cache.Set(cacheKey, score, TimeSpan.FromMinutes(15));
        return score;
    }

    public async Task WarmUpIpAsync(
        string ipAddress, WarmingUpSchedule schedule)
    {
        // Gradually increase volume over 2-4 weeks
        // Day 1: 50 emails/hour
        // Day 7: 500 emails/hour
        // Day 14: 5,000 emails/hour
        // Day 21: 25,000 emails/hour
        // Day 28: Full capacity

        var currentHourlyLimit = schedule.GetCurrentLimit();
        _logger.LogInformation(
            "IP warm-up: {Ip} - current limit: {Limit}/hour",
            ipAddress, currentHourlyLimit);

        await _repository.UpdateIpDailyLimitAsync(ipAddress, currentHourlyLimit);
    }
}

IP Warm-Up Strategy

New IP addresses have no reputation. ISPs treat them with suspicion. The warm-up process involves sending small volumes of well-targeted, highly engaging email to build reputation gradually. During warm-up, you must prioritize engaged recipients and avoid sending to cold lists.

graph TB subgraph "IP Warm-Up Timeline" W1[Day 1-3: 50/hr] --> W2[Day 4-7: 200/hr] W2 --> W3[Day 8-14: 1000/hr] W3 --> W4[Day 15-21: 5000/hr] W4 --> W5[Day 22-28: 25000/hr] W5 --> W6[Day 29+: Full capacity] end subgraph "Reputation Monitoring" W1 --> M1[Bounce Rate Monitor] W2 --> M1 W3 --> M1 M1 -->|Above 2%| H1[PAUSE Investigate] M1 -->|Below 2%| W3 end
Industry Benchmark: Major ISPs publish their postmaster guidelines. Gmail recommends keeping bounce rates below 0.1% and complaint rates below 0.01% for optimal inbox placement. These are targets, not thresholds for action — you should aim to be well below them.

Inbox Placement Monitoring

Beyond reputation scores, we need direct measurement of inbox placement. We use seed-list testing (sending test emails to monitored mailboxes at major ISPs) and integrate with third-party deliverability monitoring services. This gives us real-time visibility into whether our emails are reaching the inbox, the spam folder, or being blocked entirely.

C#
public class InboxPlacementMonitor
{
    private readonly ISeedListProvider _seedListProvider;
    private readonly IPlacementRepository _repository;

    public async Task<PlacementReport> CheckPlacementAsync(
        string sendingDomain, string sendingIp)
    {
        var seedAddresses = await _seedListProvider
            .GetSeedAddressesAsync();

        var results = new Dictionary<string, PlacementResult>();

        // Send test emails to seed addresses
        foreach (var seed in seedAddresses)
        {
            var testEmail = CreateTestMessage(sendingDomain, sendingIp, seed);
            await SendTestEmailAsync(testEmail);

            // Wait for delivery and check placement
            await Task.Delay(TimeSpan.FromMinutes(2));

            var placement = await CheckSeedMailboxAsync(seed);
            results[seed.Isp] = placement;
        }

        var report = new PlacementReport
        {
            Domain = sendingDomain,
            Timestamp = DateTime.UtcNow,
            Results = results,
            InboxRate = results.Values.Count(r => r Location == "inbox") /
                (double)results.Count,
            SpamRate = results.Values.Count(r => r Location == "spam") /
                (double)results.Count,
            MissingRate = results.Values.Count(r => r Location == "missing") /
                (double)results.Count
        };

        await _repository.SaveReportAsync(report);
        return report;
    }
}

12. Unsubscribe Management

Unsubscribe management is a legal requirement (CAN-SPAM, GDPR) and a deliverability best practice. Every marketing email must include a visible unsubscribe link and a List-Unsubscribe header. How you handle unsubscribe requests directly impacts your sender reputation — ISPs track unsubscribe rates and penalize senders with high rates.

Unsubscribe Flow

sequenceDiagram participant Recipient participant UnsubLink as Unsubscribe Link participant API as Unsubscribe API participant Suppression as Suppression Service participant Webhook as Webhook Dispatcher participant Customer as Customer App Recipient->>UnsubLink: Click Unsubscribe link UnsubLink->>API: GET /unsubscribe?token=signed_token API->>API: Validate token HMAC signature API->>Suppression: Add to suppression list Suppression->>Suppression: Persist to database Suppression->>Suppression: Update Bloom filter cache API->>Webhook: Dispatch unsubscribed event Webhook->>Customer: POST webhook endpoint API-->>Recipient: Show confirmation page Note over Recipient,Customer: Future emails to this address are automatically suppressed
C#
public class UnsubscribeService : IUnsubscribeService
{
    private readonly ISuppressionService _suppressionService;
    private readonly ITokenService _tokenService;
    private readonly IWebhookDispatcher _webhookDispatcher;
    private readonly IBloomFilter _suppressionBloomFilter;

    public async Task<UnsubscribeResult> ProcessUnsubscribeAsync(string token)
    {
        // 1. Validate the signed token
        var tokenData = await _tokenService.ValidateUnsubscribeTokenAsync(token);
        if (tokenData == null)
            return UnsubscribeResult.InvalidToken();

        // 2. Check if already suppressed
        var alreadySuppressed = await _suppressionService.IsSuppressedAsync(
            tokenData.Email, tokenData.CustomerId);
        if (alreadySuppressed)
            return UnsubscribeResult.AlreadyUnsubscribed();

        // 3. Add to suppression list
        var suppression = new SuppressionEntry
        {
            Id = Guid.NewGuid(),
            CustomerId = tokenData.CustomerId,
            EmailAddress = tokenData.Email,
            NormalizedEmail = NormalizeEmail(tokenData.Email),
            Reason = SuppressionReason.ManualUnsubscribe,
            CreatedAt = DateTime.UtcNow
        };

        await _suppressionService.AddAsync(suppression);

        // 4. Update bloom filter for fast negative lookups
        _suppressionBloomFilter.Add(suppression.NormalizedEmail);

        // 5. Emit webhook event
        await _webhookDispatcher.DispatchAsync(
            tokenData.CustomerId, new WebhookEvent
        {
            EventType = "unsubscribed",
            Recipient = tokenData.Email,
            Timestamp = DateTime.UtcNow,
            Details = new Dictionary<string, string>
            {
                ["list_id"] = tokenData.ListId,
                ["message_id"] = tokenData.MessageId.ToString(),
                ["method"] = "one_click"
            }
        });

        _logger.LogInformation(
            "Processed unsubscribe for {Email} (customer: {CustomerId})",
            tokenData.Email, tokenData.CustomerId);

        return UnsubscribeResult.Success();
    }

    public string GenerateUnsubscribeLink(
        Guid messageId, string recipientEmail, Guid customerId)
    {
        var token = _tokenService.GenerateUnsubscribeToken(
            new UnsubscribeTokenData
        {
            MessageId = messageId,
            Email = recipientEmail,
            CustomerId = customerId,
            ExpiresAt = DateTime.UtcNow.AddDays(90)
        });

        return $"https://unsubscribe.ourplatform.com/unsubscribe?token={token}";
    }

    private string NormalizeEmail(string email)
    {
        return email.Trim().ToLowerInvariant();
    }
}

List-Unsubscribe Header

Modern email clients (Gmail, Outlook) prominently display an "Unsubscribe" button when they detect a List-Unsubscribe header. This header provides both a mailto: and an HTTP URL for one-click unsubscribe. Implementing this correctly is essential for marketing emails.

C#
public class ListUnsubscribeHeader
{
    public static string Generate(
        Guid messageId, string recipientEmail, Guid customerId)
    {
        var httpUrl = GenerateHttpUrl(messageId, recipientEmail, customerId);
        var mailtoUrl = GenerateMailtoUrl(customerId);

        // RFC 8058: List-Unsubscribe-Post enables one-click unsubscribe
        // without requiring the user to visit a web page
        return $"<{httpUrl}>, <{mailtoUrl}>";
    }

    public static string GeneratePostHeader()
    {
        // RFC 8058: List-Unsubscribe-Post
        return "List-Unsubscribe=One-Click";
    }

    private static string GenerateHttpUrl(
        Guid messageId, string email, Guid customerId)
    {
        var token = TokenService.GenerateSignedToken(
            messageId, email, customerId);
        return $"https://unsubscribe.ourplatform.com/unsubscribe?token={token}";
    }

    private static string GenerateMailtoUrl(Guid customerId)
    {
        return $"mailto:unsub-{customerId}@unsub.ourplatform.com?subject=unsubscribe";
    }
}
GDPR Consideration: Under GDPR, you must also support the "right to be forgotten." This means deleting or fully anonymizing a user's personal data upon request. Implement a separate GDPR erasure flow that processes deletion requests across all systems — email content, delivery events, analytics, and suppression lists — within 30 days.

Suppression List Architecture

The suppression list is the most performance-critical data structure in the system. Every email must be checked against it before entering the delivery pipeline. At 1 billion emails/day, we need O(1) lookups with minimal latency. We use a multi-layer approach: a probabilistic Bloom filter for fast negative checks (no false negatives), backed by a Redis sorted set for deterministic verification, with PostgreSQL as the source of truth.

C#
public class SuppressionService : ISuppressionService
{
    private readonly IBloomFilter _bloomFilter;       // In-memory, O(1)
    private readonly IRedisCache _redisCache;          // Distributed, O(1)
    private readonly ISuppressionRepository _repository; // PostgreSQL

    public async Task<bool> IsSuppressedAsync(string email, Guid customerId)
    {
        var normalized = email.Trim().ToLowerInvariant();

        // Layer 1: Bloom filter (fast negative check)
        // If bloom says "not suppressed", we can skip the database
        if (!_bloomFilter.MightContain(normalized))
            return false;

        // Layer 2: Redis cache (deterministic, distributed)
        var cacheKey = $"suppress:{normalized}";
        var cached = await _redisCache.GetAsync(cacheKey);
        if (cached != null)
            return true;

        // Layer 3: Database (source of truth)
        var isSuppressed = await _repository.IsSuppressedAsync(normalized, customerId);
        if (isSuppressed)
        {
            // Cache in Redis for 24 hours
            await _redisCache.SetAsync(cacheKey, "1", TimeSpan.FromHours(24));
        }

        return isSuppressed;
    }

    public async Task AddAsync(SuppressionEntry entry)
    {
        // 1. Persist to database
        await _repository.AddAsync(entry);

        // 2. Add to Redis cache
        var cacheKey = $"suppress:{entry.NormalizedEmail}";
        await _redisCache.SetAsync(cacheKey, "1", TimeSpan.FromDays(365));

        // 3. Add to bloom filter
        _bloomFilter.Add(entry.NormalizedEmail);

        // 4. Propagate to all regions via Kafka
        await _kafkaProducer.ProduceAsync("suppression-updates", entry);
    }
}

13. Multi-Channel Notifications — Push, SMS, Email

Modern notification systems do not rely on a single channel. A comprehensive platform must support email, push notifications (iOS/Android), SMS, in-app messaging, and potentially Slack/webhook integrations. The key design challenge is providing a unified API while handling the unique delivery semantics of each channel.

Channel Comparison

Channel Latency Reliability Cost/Message Rich Content User Attention
Email1-30 sec99.9%$0.0001Yes (HTML)Medium
Push (iOS)1-5 sec95%FreeLimitedHigh
Push (Android)1-5 sec97%FreeModerateHigh
SMS1-10 sec98%$0.0075Text onlyVery High
In-AppInstant99%FreeFull UILow
graph TB subgraph "Unified Notification API" A[Notification Request] --> B{Channel Router} end subgraph "Channel Processors" B -->|email| C[Email Pipeline] B -->|push| D[Push Notification Service] B -->|sms| E[SMS Gateway] B -->|in_app| F[In-App WebSocket] end subgraph "Channel-Specific Infrastructure" C --> C1[SMTP / SendGrid / SES] D --> D1[APNs / FCM] E --> E1[Twilio / Vonage] F --> F1[WebSocket Hub] end subgraph "Delivery Tracking" C1 --> G[Event Aggregator] D1 --> G E1 --> G F1 --> G G --> H[Analytics Dashboard] end
C#
public class UnifiedNotificationService
{
    private readonly Dictionary<NotificationChannel, IChannelProvider> _providers;
    private readonly IPreferenceService _preferenceService;
    private readonly ITemplateEngine _templateEngine;
    private readonly INotificationRepository _repository;

    public async Task<NotificationResult> SendAsync(NotificationRequest request)
    {
        // 1. Check user preferences
        var preferences = await _preferenceService.GetAsync(
            request.UserId, request.NotificationType);

        // 2. Determine which channels to use
        var channels = DetermineChannels(preferences, request.Urgency);

        // 3. Send through each channel
        var results = new List<ChannelResult>();
        foreach (var channel in channels)
        {
            var provider = _providers[channel];
            try
            {
                var result = await provider.SendAsync(new ChannelMessage
                {
                    UserId = request.UserId,
                    Content = await RenderContent(channel, request),
                    Metadata = request.Metadata,
                    Urgency = request.Urgency
                });
                results.Add(result);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex,
                    "Failed to send via {Channel}", channel);
                results.Add(ChannelResult.Failed(channel, ex.Message));
            }
        }

        // 4. Record notification
        await _repository.SaveAsync(new NotificationRecord
        {
            Id = Guid.NewGuid(),
            UserId = request.UserId,
            Type = request.NotificationType,
            Channels = results,
            CreatedAt = DateTime.UtcNow
        });

        return new NotificationResult(results);
    }

    private List<NotificationChannel> DetermineChannels(
        UserPreference prefs, NotificationUrgency urgency)
    {
        var channels = new List<NotificationChannel>();

        // Critical notifications go through all enabled channels
        if (urgency == NotificationUrgency.Critical)
        {
            if (prefs.EmailEnabled)
                channels.Add(NotificationChannel.Email);
            if (prefs.PushEnabled)
                channels.Add(NotificationChannel.Push);
            if (prefs.SmsEnabled)
                channels.Add(NotificationChannel.Sms);
            if (prefs.InAppEnabled)
                channels.Add(NotificationChannel.InApp);
            return channels;
        }

        // Non-critical: use preferred channel only
        channels.Add(prefs.PreferredChannel);
        return channels;
    }
}

public interface IChannelProvider
{
    NotificationChannel Channel { get; }
    Task<ChannelResult> SendAsync(ChannelMessage message);
}

Push Notification Provider

C#
public class PushNotificationProvider : IChannelProvider
{
    public NotificationChannel Channel => NotificationChannel.Push;

    private readonly IApNsClient _apNsClient;  // iOS
    private readonly IFirebaseClient _firebaseClient;  // Android

    public async Task<ChannelResult> SendAsync(ChannelMessage message)
    {
        var deviceTokens = await _deviceRepository
            .GetTokensAsync(message.UserId);

        var tasks = deviceTokens.Select(async device =>
        {
            try
            {
                if (device.Platform == Platform.iOS)
                {
                    await _apNsClient.SendAsync(device.Token,
                        new ApNsPayload
                    {
                        Title = message.Content.Title,
                        Body = message.Content.Body,
                        Badge = 1,
                        Sound = "default",
                        CustomData = message.Metadata
                    });
                }
                else
                {
                    await _firebaseClient.SendAsync(device.Token,
                        new FcmPayload
                    {
                        Notification = new FcmNotification
                        {
                            Title = message.Content.Title,
                            Body = message.Content.Body
                        },
                        Data = message.Metadata
                    });
                }
            }
            catch (DeviceTokenInvalidException)
            {
                await _deviceRepository.RemoveTokenAsync(device.Token);
            }
        });

        await Task.WhenAll(tasks);
        return ChannelResult.Success(Channel);
    }
}

SMS Provider

C#
public class SmsProvider : IChannelProvider
{
    public NotificationChannel Channel => NotificationChannel.Sms;

    private readonly ITwilioClient _twilioClient;

    public async Task<ChannelResult> SendAsync(ChannelMessage message)
    {
        var phoneNumber = await _userRepository
            .GetPhoneNumberAsync(message.UserId);
        if (phoneNumber == null)
            return ChannelResult.Failed(Channel, "No phone number on file");

        // SMS has strict length limits (160 chars for single segment)
        var text = message.Content.TextBody;
        if (text.Length > 160)
            text = text[..157] + "...";

        var result = await _twilioClient.SendSmsAsync(
            to: phoneNumber,
            from: _config.SmsFromNumber,
            body: text);

        return result.IsSuccess
            ? ChannelResult.Success(Channel)
            : ChannelResult.Failed(Channel, result.ErrorMessage);
    }
}
Smart Channel Escalation: Implement an escalation policy: if a push notification is not opened within 15 minutes for a critical notification, escalate to SMS. If SMS fails, escalate to phone call via Twilio Voice. This ensures critical alerts always reach the user.

14. Preference Center Design

A preference center gives users control over what notifications they receive and through which channels. This is not just a UX feature — it is a deliverability tool. Users who receive only the emails they want are less likely to mark messages as spam. A well-designed preference center reduces complaint rates by 50-70%.

Preference Model

C#
public class UserPreference
{
    public Guid UserId { get; set; }
    public NotificationType NotificationType { get; set; }
    public ChannelPreferences Channels { get; set; }
    public FrequencyPreference Frequency { get; set; }
    public DateTime UpdatedAt { get; set; }
}

public class ChannelPreferences
{
    public bool EmailEnabled { get; set; } = true;
    public bool PushEnabled { get; set; } = true;
    public bool SmsEnabled { get; set; } = false;
    public bool InAppEnabled { get; set; } = true;
    public NotificationChannel PreferredChannel { get; set; }
        = NotificationChannel.Email;

    // Quiet hours (no notifications during these times)
    public TimeOnly QuietHoursStart { get; set; }
    public TimeOnly QuietHoursEnd { get; set; }
    public string TimeZone { get; set; } = "UTC";
}

public enum NotificationType
{
    // Transactional (cannot be unsubscribed)
    PasswordReset,
    TwoFactorAuth,
    AccountSecurity,
    OrderConfirmation,
    ShippingUpdate,

    // Marketing (can be unsubscribed)
    Newsletter,
    ProductUpdates,
    Promotions,
    Recommendations,

    // Social
    CommentReply,
    Mention,
    Follow,
    Like,

    // System
    SystemMaintenance,
    ServiceOutage,
    BillingAlert
}

public enum FrequencyPreference
{
    RealTime,      // Immediate delivery
    HourlyDigest,  // Batch every hour
    DailyDigest,   // Batch once per day
    WeeklyDigest,  // Batch once per week
    Disabled       // No notifications
}

Preference Center UI Architecture

graph TB subgraph "Preference Center Frontend" A[Preference Dashboard] --> B[Notification Type Grid] A --> C[Channel Selector] A --> D[Frequency Settings] A --> E[Quiet Hours Config] end subgraph "Preference API" B --> F[GET /preferences] C --> G[PUT /preferences/type/channels] D --> H[PUT /preferences/type/frequency] E --> I[PUT /preferences/quiet-hours] end subgraph "Enforcement Layer" F --> J[Notification Dispatcher] G --> J H --> J I --> J J --> K{Preference Check} K -->|Allowed| L[Deliver] K -->|Blocked| M[Suppress] K -->|Quiet Hours| N[Queue for later] end
C#
public class PreferenceEnforcementMiddleware
{
    private readonly IPreferenceService _preferenceService;

    public async Task<MiddlewareResult> EvaluateAsync(
        NotificationContext context, CancellationToken ct)
    {
        // 1. System notifications always pass through
        if (context.NotificationType == NotificationType.PasswordReset ||
            context.NotificationType == NotificationType.TwoFactorAuth ||
            context.NotificationType == NotificationType.AccountSecurity)
        {
            return MiddlewareResult.Allow();
        }

        // 2. Get user preferences for this notification type
        var prefs = await _preferenceService.GetAsync(
            context.UserId, context.NotificationType);

        if (prefs == null)
            return MiddlewareResult.Allow(); // No preference = default allow

        // 3. Check if channel is enabled
        if (!IsChannelEnabled(prefs.Channels, context.RequestedChannel))
        {
            // Try fallback to preferred channel
            if (IsChannelEnabled(prefs.Channels,
                prefs.Channels.PreferredChannel))
            {
                context.RequestedChannel = prefs.Channels.PreferredChannel;
                return MiddlewareResult
                    .AllowWithChannelSwitch(context.RequestedChannel);
            }
            return MiddlewareResult.Suppress("Channel disabled by user");
        }

        // 4. Check quiet hours
        if (IsQuietHours(prefs.Channels, DateTime.UtcNow))
        {
            if (context.Urgency == NotificationUrgency.Critical)
                return MiddlewareResult.Allow();

            return MiddlewareResult.DeferUntil(prefs.Channels.QuietHoursEnd);
        }

        // 5. Check frequency preferences
        if (prefs.Frequency != FrequencyPreference.RealTime)
        {
            var shouldBatch = await ShouldBatchAsync(
                context.UserId, context.NotificationType);
            if (shouldBatch)
                return MiddlewareResult.Batch(prefs.Frequency);
        }

        return MiddlewareResult.Allow();
    }

    private bool IsChannelEnabled(
        ChannelPreferences prefs, NotificationChannel channel)
    {
        return channel switch
        {
            NotificationChannel.Email => prefs.EmailEnabled,
            NotificationChannel.Push => prefs.PushEnabled,
            NotificationChannel.Sms => prefs.SmsEnabled,
            NotificationChannel.InApp => prefs.InAppEnabled,
            _ => false
        };
    }

    private bool IsQuietHours(ChannelPreferences prefs, DateTime utcNow)
    {
        var userTime = TimeZoneInfo.ConvertTimeFromUtc(utcNow,
            TimeZoneInfo.FindSystemTimeZoneById(prefs.TimeZone));
        var timeOnly = TimeOnly.FromDateTime(userTime);

        return timeOnly >= prefs.QuietHoursStart
            && timeOnly <= prefs.QuietHoursEnd;
    }
}
Design Principle: Transactional emails (password resets, order confirmations, security alerts) must never be suppressible through the preference center. Only marketing and social notification types should be toggleable. This distinction must be enforced at both the API and database level.

15. Rate Limiting & Throttling

Rate limiting in an email system serves two distinct purposes: protecting your infrastructure from abuse (API rate limits) and protecting your sender reputation from volume spikes (sending rate limits). These are fundamentally different mechanisms that operate at different layers of the system.

API Rate Limiting

API rate limits protect the platform from abuse and ensure fair resource allocation across customers. We implement a sliding window rate limiter using Redis, with limits defined per API key per time window.

C#
public class SlidingWindowRateLimiter
{
    private readonly IRedisConnection _redis;
    private readonly RateLimitConfig _config;

    public async Task<RateLimitResult> CheckAsync(
        string apiKey, string endpoint)
    {
        var limits = _config.GetLimits(apiKey, endpoint);
        var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
        var windowStart = now - limits.WindowMs;

        var key = $"ratelimit:{apiKey}:{endpoint}";

        // Sliding window using Redis sorted sets
        var pipeline = _redis.Pipeline();

        // Remove old entries outside the window
        await pipeline.SortedSetRemoveRangeByScoreAsync(
            key, 0, windowStart);

        // Count current entries
        var count = await pipeline.SortedSetLengthAsync(key);

        // Add current request
        await pipeline.SortedSetAddAsync(key, now.ToString(), now);

        // Set TTL
        await pipeline.KeyExpireAsync(key,
            TimeSpan.FromMilliseconds(limits.WindowMs));

        await pipeline.ExecuteAsync();

        var isAllowed = count < limits.MaxRequests;
        var retryAfter = isAllowed
            ? TimeSpan.Zero
            : TimeSpan.FromMilliseconds(
                limits.WindowMs - (now - windowStart));

        return new RateLimitResult
        {
            IsAllowed = isAllowed,
            Limit = limits.MaxRequests,
            Remaining = Math.Max(
                0, limits.MaxRequests - count - 1),
            ResetAt = DateTimeOffset.UtcNow.Add(retryAfter),
            RetryAfter = retryAfter
        };
    }
}

public class RateLimitConfig
{
    private readonly Dictionary<string, RateLimitRule> _rules = new()
    {
        ["mail/send"] = new RateLimitRule
        {
            PerSecond = 100,
            PerMinute = 3000,
            PerHour = 50000,
            PerDay = 1000000
        },
        ["templates"] = new RateLimitRule
        {
            PerSecond = 50,
            PerMinute = 1000,
            PerHour = 10000,
            PerDay = 100000
        },
        ["analytics"] = new RateLimitRule
        {
            PerSecond = 20,
            PerMinute = 500,
            PerHour = 5000,
            PerDay = 50000
        }
    };
}

Sending Rate Limiting (Reputation Protection)

Unlike API rate limits, sending rate limits protect your IP reputation. A customer suddenly sending 1 million emails through a new IP will trigger ISP throttling. We implement per-customer, per-domain, and per-IP sending rate limits that adapt based on current reputation scores.

C#
public class SendingRateLimiter
{
    private readonly IRedisConnection _redis;
    private readonly IReputationService _reputationService;

    public async Task<ThrottleResult> ShouldThrottleAsync(
        Guid customerId, string sendingIp, string recipientDomain)
    {
        // 1. Customer-level daily limit
        var customerUsage = await GetDailyUsageAsync(customerId);
        var customerLimit = await GetCustomerLimitAsync(customerId);
        if (customerUsage >= customerLimit)
            return ThrottleResult.Throttled(
                "Customer daily limit reached",
                TimeSpan.FromHours(1));

        // 2. Per-IP hourly limit (adapted by reputation)
        var ipReputation = await _reputationService
            .GetIpScoreAsync(sendingIp);
        var ipHourlyLimit = CalculateAdaptiveLimit(
            ipReputation, 100000);
        var ipUsage = await GetHourlyUsageAsync($"ip:{sendingIp}");
        if (ipUsage >= ipHourlyLimit)
            return ThrottleResult.Throttled(
                "IP hourly limit reached",
                TimeSpan.FromMinutes(5));

        // 3. Per-recipient-domain rate limit
        var domainUsage = await GetMinuteUsageAsync(
            $"domain:{recipientDomain}");
        if (domainUsage >= 5000)
            return ThrottleResult.Throttled(
                "Per-domain rate limit reached",
                TimeSpan.FromSeconds(30));

        // 4. Burst detection
        var recentVolume = await GetRecentVolumeAsync(
            customerId, TimeSpan.FromMinutes(5));
        var baselineVolume = await GetBaselineVolumeAsync(customerId);
        if (recentVolume > baselineVolume * 3 && baselineVolume > 0)
            return ThrottleResult.Throttled(
                "Volume spike detected, ramping gradually",
                TimeSpan.FromMinutes(2));

        return ThrottleResult.Allowed();
    }

    private int CalculateAdaptiveLimit(
        double reputationScore, int baseLimit)
    {
        // Scale limit based on reputation
        // Score 100 = 2x base limit
        // Score 50 = 1x base limit
        // Score 0 = 0.1x base limit
        var multiplier = 0.1 + (reputationScore / 100.0) * 1.9;
        return (int)(baseLimit * multiplier);
    }
}
Burst vs Sustained: The rate limiter must distinguish between short bursts (normal during business hours) and sustained over-limit sending. Use multiple time windows (second, minute, hour, day) and only throttle on sustained violations. A single burst exceeding the per-second limit should not cause throttling if the per-hour usage is within bounds.

Customer Tier Rate Limits

Tier API Rate (rps) Daily Send Limit Monthly Send Limit Burst Allowance
Free21003,000None
Starter1050,0001,000,0002x for 10 seconds
Pro50500,00010,000,0003x for 30 seconds
Enterprise200UnlimitedUnlimited5x for 60 seconds

16. Analytics & Tracking — Opens, Clicks, Conversions

Open and click tracking are fundamental features of any email platform. They provide visibility into recipient engagement and are essential for measuring campaign effectiveness, improving deliverability, and powering A/B testing. However, modern privacy protections (Apple Mail Privacy Protection, Google AMP for Email) have made traditional tracking less reliable, requiring sophisticated approaches.

Open Tracking Mechanism

Open tracking works by embedding a 1x1 transparent pixel image in each email. When the recipient's email client loads the image, it makes an HTTP request to our tracking server, which logs the open event. This approach has limitations: it only tracks recipients whose email client loads images automatically.

C#
public class OpenTrackingHandler
{
    private readonly IEventEmitter _eventEmitter;
    private readonly ICacheService _cache;

    public async Task HandleOpenAsync(HttpContext context)
    {
        // Extract tracking data from URL
        var messageId = long.Parse(
            context.Request.RouteValues["messageId"]!.ToString()!);
        var recipientId = long.Parse(
            context.Request.RouteValues["recipientId"]!.ToString()!);

        // Deduplicate: dont count multiple opens from
        // same user agent within 5 min
        var dedupKey = $"open:{messageId}:{recipientId}" +
            $":{context.Request.Headers.UserAgent}";
        var isDuplicate = await _cache.GetAsync(dedupKey) != null;

        if (!isDuplicate)
        {
            await _cache.SetAsync(
                dedupKey, "1", TimeSpan.FromMinutes(5));

            await _eventEmitter.EmitAsync(new DeliveryEvent
            {
                MessageId = messageId,
                RecipientId = recipientId,
                EventType = EventType.Opened,
                Timestamp = DateTime.UtcNow,
                UserAgent = context.Request.Headers
                    .UserAgent.ToString(),
                IpAddress = context.Connection
                    .RemoteIpAddress?.ToString(),
                GeographicRegion = await ResolveGeoIpAsync(
                    context.Connection.RemoteIpAddress)
            });
        }

        // Return 1x1 transparent GIF
        var pixel = Convert.FromBase64(
            "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7");
        context.Response.ContentType = "image/gif";
        context.Response.ContentLength = pixel.Length;
        await context.Response.Body.WriteAsync(pixel);
    }
}

Click Tracking Mechanism

Click tracking rewrites all links in the email to point to our tracking server. When a recipient clicks a link, they are briefly redirected through our server, which logs the click event and the target URL, then issues an HTTP 302 redirect to the original destination.

C#
public class ClickTrackingHandler
{
    private readonly IEventEmitter _eventEmitter;
    private readonly IUrlEncoder _urlEncoder;

    public string RewriteLinks(
        string htmlBody, long messageId, long recipientId)
    {
        // Parse HTML and find all anchor tags
        var doc = HtmlParser.Parse(htmlBody);
        var links = doc.QuerySelectorAll("a[href]");

        foreach (var link in links)
        {
            var originalUrl = link.GetAttribute("href");

            // Dont track: unsubscribe, mailto, tel, javascript
            if (ShouldSkipTracking(originalUrl))
                continue;

            // Encode the original URL
            var encodedUrl = _urlEncoder.Encode(originalUrl);
            var trackingUrl =
                $"https://click.ourplatform.com/r/" +
                $"{messageId}/{recipientId}/{encodedUrl}";

            link.SetAttribute("href", trackingUrl);
        }

        return doc.ToHtml();
    }

    public async Task HandleClickAsync(HttpContext context)
    {
        var messageId = long.Parse(
            context.Request.RouteValues["messageId"]!.ToString()!);
        var recipientId = long.Parse(
            context.Request.RouteValues["recipientId"]!.ToString()!);
        var encodedUrl = context.Request.RouteValues["url"]!
            .ToString()!;

        var originalUrl = _urlEncoder.Decode(encodedUrl);

        // Emit click event
        await _eventEmitter.EmitAsync(new DeliveryEvent
        {
            MessageId = messageId,
            RecipientId = recipientId,
            EventType = EventType.Clicked,
            Timestamp = DateTime.UtcNow,
            Url = originalUrl,
            UserAgent = context.Request.Headers
                .UserAgent.ToString(),
            IpAddress = context.Connection
                .RemoteIpAddress?.ToString()
        });

        // Redirect to original URL
        context.Response.Redirect(originalUrl, permanent: false);
    }

    private bool ShouldSkipTracking(string url)
    {
        return url.StartsWith("mailto:") ||
               url.StartsWith("tel:") ||
               url.StartsWith("javascript:") ||
               url.Contains("/unsubscribe") ||
               url.Contains("ourplatform.com");
    }
}

Analytics Aggregation Pipeline

graph TB A[Open/Click Events] --> B[Kafka: open-click-events] B --> C[Real-Time Counter Service] B --> D[ClickHouse Aggregator] B --> E[Webhook Dispatcher] C --> F[Redis: Real-Time Counters] F --> G[Dashboard API] D --> H[ClickHouse Tables] H --> I[Hourly Aggregation Job] H --> J[Daily Aggregation Job] H --> K[Monthly Aggregation Job] I --> L[Analytics Dashboard] J --> L K --> L E --> M[Customer Webhooks]

ClickHouse Schema for Analytics

C#
public class ClickHouseAnalyticsSchema
{
    // Raw events table
    public const string CreateEventsTable = @"
        CREATE TABLE email_events (
            event_id UInt64,
            message_id Int64,
            customer_id UUID,
            recipient_id Int64,
            event_type Enum8(
                'queued' = 0, 'sent' = 1, 'delivered' = 2,
                'deferred' = 3, 'bounced' = 4, 'complaint' = 5,
                'opened' = 6, 'clicked' = 7, 'unsubscribed' = 8
            ),
            timestamp DateTime64(3),
            domain String,
            sending_ip IPv4,
            recipient_domain LowCardinality(String),
            user_agent String,
            url String,
            geo_region LowCardinality(String),
            metadata Map(String, String)
        )
        ENGINE = MergeTree()
        PARTITION BY toYYYYMMDD(timestamp)
        ORDER BY (customer_id, event_type, timestamp)
        TTL timestamp + INTERVAL 90 DAY";

    // Materialized view for hourly aggregations
    public const string CreateHourlyAggregation = @"
        CREATE MATERIALIZED VIEW email_events_hourly
        ENGINE = SummingMergeTree()
        PARTITION BY toYYYYMM(hour)
        ORDER BY (customer_id, domain, event_type, hour)
        AS SELECT
            customer_id,
            domain,
            event_type,
            toStartOfHour(timestamp) AS hour,
            count() AS event_count,
            uniqState(recipient_id) AS unique_recipients
        FROM email_events
        GROUP BY customer_id, domain, event_type, hour";
}
Apple Mail Privacy Protection (MPP): Since iOS 15, Apple pre-fetches all email content (including tracking pixels) through proxy servers, making "open" data unreliable for Apple Mail users. This affects ~50% of email opens. Track open rates as directional metrics only; rely on click-through rates as the primary engagement signal.

17. Transactional vs Marketing Emails

The distinction between transactional and marketing emails is not just a classification — it fundamentally affects how the system processes, prioritizes, and delivers each message. Transactional emails are triggered by user actions and carry time-sensitive information. Marketing emails are bulk-sent and can tolerate delay. Mixing them on the same IP pool or queue can destroy deliverability for both.

Classification Differences

Characteristic Transactional Marketing
TriggerUser action (signup, purchase)Scheduled campaign
Latency Target< 5 seconds (P95)< 1 hour for batch
UnsubscribableNo (legally exempt)Yes (legally required)
IP PoolDedicated transactional IPsShared marketing IPs
Volume PatternSteady, proportional to trafficSpike during campaigns
ContentUser-specific dataSame content, different recipients
PriorityHigh / CriticalNormal / Low
Queuepriority-high (separate topic)priority-low (separate topic)
C#
public class EmailClassifier
{
    private static readonly HashSet<string> TransactionalTemplates =
        new()
    {
        "password-reset",
        "two-factor-auth",
        "account-verification",
        "order-confirmation",
        "shipping-notification",
        "payment-receipt",
        "account-security-alert",
        "welcome-email",
        "account-deletion-confirmation"
    };

    public EmailClassification Classify(SendRequest request)
    {
        // 1. Check if template is explicitly transactional
        if (request.TemplateId.HasValue &&
            TransactionalTemplates.Contains(
                request.TemplateId.Value.ToString()))
        {
            return new EmailClassification
            {
                Category = MessageCategory.Transactional,
                Priority = MessagePriority.High,
                UsesTransactionalIpPool = true,
                RequiresUnsubscribeLink = false,
                QueuePartition = "priority-high"
            };
        }

        // 2. Check customer-specified category
        if (request.Category == MessageCategory.Transactional)
        {
            return new EmailClassification
            {
                Category = MessageCategory.Transactional,
                Priority = MessagePriority.Normal,
                UsesTransactionalIpPool = true,
                RequiresUnsubscribeLink = false,
                QueuePartition = "priority-high"
            };
        }

        // 3. Default to marketing
        return new EmailClassification
        {
            Category = MessageCategory.Marketing,
            Priority = MessagePriority.Low,
            UsesTransactionalIpPool = false,
            RequiresUnsubscribeLink = true,
            QueuePartition = "priority-low"
        };
    }
}

Separate IP Pools

Marketing emails have higher bounce and complaint rates than transactional emails. If they share IP addresses, the marketing reputation damage affects transactional delivery. We maintain separate IP pools: one or more dedicated IPs for transactional traffic (carefully warmed up and monitored), and a pool of shared or dedicated IPs for marketing traffic.

Customer Abuse Prevention: Some customers will try to send marketing emails classified as transactional to bypass unsubscribe requirements and get better deliverability. Implement automated content analysis (link ratio, promotional keyword detection, image-to-text ratio) to flag and auto-reclassify suspicious "transactional" emails.
C#
public class AbuseDetector
{
    private static readonly string[] PromotionalKeywords = {
        "buy now", "limited time", "discount", "offer expires",
        "unsubscribe", "click here", "free shipping", "sale",
        "act now", "exclusive deal", "newsletter"
    };

    private static readonly string[] TransactionalKeywords = {
        "your order", "password reset", "verify your email",
        "security alert", "invoice", "receipt", "shipment",
        "account update", "confirmation code"
    };

    public ClassificationScore AnalyzeContent(SendRequest request)
    {
        var content = $"{request.Subject} {request.HtmlContent}"
            .ToLower();
        var linkCount = Regex.Matches(content, @"<a\s+href").Count;
        var imageCount = Regex.Matches(content, @"<img\s+src").Count;

        int promotionalScore = 0;
        int transactionalScore = 0;

        foreach (var keyword in PromotionalKeywords)
        {
            if (content.Contains(keyword))
                promotionalScore += 10;
        }

        foreach (var keyword in TransactionalKeywords)
        {
            if (content.Contains(keyword))
                transactionalScore += 10;
        }

        // High link density is promotional
        if (linkCount > 5) promotionalScore += 20;

        // High image density is promotional
        if (imageCount > 3) promotionalScore += 15;

        // Unsubscribe link is required for marketing
        if (content.Contains("unsubscribe"))
            promotionalScore += 10;

        return new ClassificationScore
        {
            PromotionalScore = promotionalScore,
            TransactionalScore = transactionalScore,
            Confidence = Math.Abs(
                promotionalScore - transactionalScore) /
                (double)(promotionalScore +
                    transactionalScore + 1),
            RecommendedClassification =
                promotionalScore > transactionalScore
                    ? MessageCategory.Marketing
                    : MessageCategory.Transactional
        };
    }
}

18. Reliability & Failure Modes

An email system must be highly available because it is often in the critical path of user authentication (password resets, 2FA codes). A 5-minute outage in the email system can lock out thousands of users. The system must handle failures gracefully at every layer — API, queue, delivery, and tracking.

Failure Mode Analysis

Failure Impact Detection Mitigation
API server crashCannot accept new emailsHealth check failuresAuto-scaling, load balancer failover
Kafka broker failureQueue backlog growsConsumer lag monitoringReplication factor 3, ISR min 2
Delivery worker crashIn-flight messages may be lostConsumer group rebalancingKafka auto-commit disabled, manual ack
SMTP connection failureMessages deferred, not lostConnection pool health checksRetry with exponential backoff
DNS resolution failureCannot resolve MX recordsDNS query timeout monitoringCached MX records, fallback to A record
Database failureCannot read/write metadataConnection pool exhaustionRead replicas, connection timeouts
Redis failureRate limiting fails openRedis sentinel/cluster alertsFail open (allow through), alert ops
ISP blockingMessages to specific ISP rejectedBounce rate spike per domainAuto-route to backup MX, IP rotation

Exactly-Once Delivery Semantics

True exactly-once delivery is impossible in distributed systems. We implement at-least-once delivery with idempotent processing. Each message has a unique Snowflake ID. Workers check for duplicate IDs before processing. The deduplication window is 24 hours, maintained via a compact Bloom filter backed by a database.

C#
public class ReliableDeliveryWorker : BackgroundService
{
    private readonly IMessageConsumer _consumer;
    private readonly IDeduplicationService _deduplication;
    private readonly ISmtpDeliverer _deliverer;

    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                var message = await _consumer.ConsumeAsync(
                    "outbound-emails", stoppingToken);

                // Idempotency check
                if (await _deduplication.IsProcessedAsync(
                    message.MessageId))
                {
                    _logger.LogDebug(
                        "Skipping duplicate message {MessageId}",
                        message.MessageId);
                    continue;
                }

                // Process the message
                var result = await _deliverer.DeliverAsync(
                    message, stoppingToken);

                // Mark as processed (idempotent)
                await _deduplication.MarkProcessedAsync(
                    message.MessageId);

                // Commit Kafka offset ONLY after successful processing
                await _consumer.CommitAsync(
                    message.TopicPartitionOffset);

                _logger.LogInformation(
                    "Successfully delivered message {MessageId} " +
                    "to {Count} recipients",
                    message.MessageId, result.DeliveredCount);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex,
                    "Error in delivery worker");

                // Dont commit offset - message will be redelivered
                // Implement circuit breaker to avoid tight error loops
                await Task.Delay(
                    TimeSpan.FromSeconds(5), stoppingToken);
            }
        }
    }
}

Circuit Breaker Pattern

C#
public class SmtpCircuitBreaker
{
    private int _failureCount;
    private DateTime _lastFailureTime;
    private CircuitState _state = CircuitState.Closed;
    private readonly int _failureThreshold = 5;
    private readonly TimeSpan _recoveryTimeout =
        TimeSpan.FromMinutes(5);

    public async Task<bool> ExecuteAsync(Func<Task> action)
    {
        if (_state == CircuitState.Open)
        {
            if (DateTime.UtcNow - _lastFailureTime >
                _recoveryTimeout)
            {
                _state = CircuitState.HalfOpen;
                _logger.LogInformation(
                    "Circuit breaker transitioning to half-open");
            }
            else
            {
                return false;
            }
        }

        try
        {
            await action();

            if (_state == CircuitState.HalfOpen)
            {
                _state = CircuitState.Closed;
                _failureCount = 0;
                _logger.LogInformation(
                    "Circuit breaker closed - recovery confirmed");
            }

            return true;
        }
        catch (Exception ex)
        {
            _failureCount++;
            _lastFailureTime = DateTime.UtcNow;

            if (_failureCount >= _failureThreshold)
            {
                _state = CircuitState.Open;
                _logger.LogWarning(
                    "Circuit breaker opened after {Count} " +
                    "consecutive failures", _failureCount);
            }

            throw;
        }
    }
}

public enum CircuitState
{
    Closed,    // Normal operation
    Open,      // Failing, reject calls
    HalfOpen   // Testing recovery
}

Disaster Recovery

graph TB subgraph "Region A Primary" A1[API Servers] --> A2[Kafka Cluster] A2 --> A3[PostgreSQL Primary] A2 --> A4[Delivery Workers] end subgraph "Region B Standby" B1[API Servers Warm] --> B2[Kafka MirrorMaker] B2 --> B3[PostgreSQL Replica] B4[Delivery Workers Standby] end subgraph "Failover" C1[Route53 Health Check] -->|Detects failure| C2[DNS Failover] C2 -->|Redirects traffic| B1 B3 -->|Promote to primary| B3 A4 -->|Messages in Kafka| B4 end A2 -->|Cross-region replication| B2 A3 -->|Async replication| B3
Recovery Time Objective (RTO): For transactional email, the RTO must be under 60 seconds. This requires warm standby in a secondary region with automated failover. For marketing email, a 15-minute RTO is acceptable. Design separate failover policies for each category.

19. Cost Estimation & Infrastructure Sizing

Understanding the cost structure of an email platform is critical for both build-vs-buy decisions and for setting pricing. The major cost components are compute, storage, bandwidth, third-party relay fees, and operational overhead.

Infrastructure Cost Breakdown (1B emails/day)

Component Instances Spec Monthly Cost
API Gateway (Envoy)204 vCPU, 8GB$4,800
Template Renderers408 vCPU, 32GB$19,200
Delivery Workers1008 vCPU, 16GB$48,000
Bounce Processors204 vCPU, 8GB$4,800
Webhook Dispatchers304 vCPU, 8GB$7,200
Event Processors308 vCPU, 16GB$14,400
Kafka Cluster308 vCPU, 32GB, 2TB NVMe$36,000
PostgreSQL Cluster516 vCPU, 64GB, 10TB NVMe$25,000
Redis Cluster104 vCPU, 64GB$12,000
ClickHouse Cluster1216 vCPU, 64GB, 50TB$36,000
S3 Storage-300TB/month$6,900
Data Transfer-~100TB/month outbound$8,000
Load Balancers4Application LB$2,400
Monitoring (Datadog)-Full stack$8,000

Total Monthly Infrastructure Cost

TEXT
=== Monthly Cost Summary (1B emails/day) ===

Compute:
  API Gateway:              $4,800
  Template Renderers:      $19,200
  Delivery Workers:        $48,000
  Bounce Processors:        $4,800
  Webhook Dispatchers:      $7,200
  Event Processors:        $14,400
  --------------------------------
  Compute Subtotal:        $98,400/month

Data:
  Kafka Cluster:           $36,000
  PostgreSQL Cluster:      $25,000
  Redis Cluster:           $12,000
  ClickHouse Cluster:      $36,000
  S3 Storage:               $6,900
  --------------------------------
  Data Subtotal:          $115,900/month

Network:
  Data Transfer:            $8,000
  Load Balancers:           $2,400
  --------------------------------
  Network Subtotal:        $10,400/month

Operations:
  Monitoring:               $8,000
  Logging (ELK):            $5,000
  CI/CD Pipeline:           $2,000
  --------------------------------
  Ops Subtotal:            $15,000/month

TOTAL INFRASTRUCTURE:     $239,700/month
ANNUAL INFRASTRUCTURE:  $2,876,400/year

Cost per email:           $0.0000799
Cost per 1K emails:       $0.080

Third-Party Cost Comparison

Provider Cost per 1K Emails Monthly Cost at 1B Notes
Amazon SES$0.10$100,000+ data transfer costs
SendGrid$0.20-0.60$200K-600KTiered pricing
Mailgun$0.80$800,000Postgres plan
Postmark$1.25$1,250,000Premium transactional
Self-hosted (design)$0.08$240,000Requires 10+ engineers
Build vs Buy Verdict: At 1B emails/day, self-hosting saves $760K-$1M/month compared to third-party providers. However, this requires a dedicated team of 10-15 engineers. The break-even point is typically around 100M emails/month, below which third-party providers are more cost-effective.

Customer Pricing Model

TEXT
=== Suggested Pricing Tiers ===

Free Tier:
  - 100 emails/day
  - Shared IP pool
  - Basic analytics
  - Community support

Starter ($20/month):
  - 50,000 emails/month
  - Shared IP pool
  - Full analytics
  - Email support

Pro ($80/month):
  - 500,000 emails/month
  - Dedicated IP option
  - Advanced analytics
  - Webhook support
  - Priority support

Enterprise (Custom):
  - Unlimited sends
  - Dedicated IP pool
  - Custom SLA
  - Dedicated support engineer
  - Custom integrations
  - SOC 2 compliance reports

20. Interview Q&A — Common Follow-Up Questions

Q1: How do you handle email delivery to a recipient whose mailbox is full?

When the receiving server returns a "mailbox full" error (SMTP 552 or 452), we classify it as a soft bounce. The system retries delivery with exponential backoff — first retry after 5 minutes, second after 30 minutes, third after 2 hours. If the mailbox is still full after 3 retries, we suppress the address for 7 days and then attempt a single final delivery. If that also fails, the address is permanently suppressed. We do not permanently suppress on the first mailbox-full error because users frequently clean up their mailboxes.

Q2: How would you handle sending 10 million emails for a marketing campaign without impacting transactional email delivery?

We enforce strict separation at every layer. Transactional and marketing emails use different Kafka topics (priority-high vs priority-low), different consumer groups, different IP pools, and different rate limits. Marketing campaigns are broken into micro-batches of 10,000 emails each, sent with delays between batches. During peak transactional volume hours (typically 9 AM - 12 PM local time in the recipient's timezone), we automatically reduce marketing throughput. The marketing worker pool is elastic and can scale down during transactional peaks.

Q3: How do you ensure at-least-once delivery without sending duplicate emails?

We use a combination of idempotent message IDs and a deduplication service. Each message gets a unique Snowflake ID at creation time. Delivery workers check this ID against a deduplication store (Redis + database) before processing. If the worker crashes after delivering an email but before marking it as processed, Kafka will redeliver the message. The idempotency check catches this duplicate and skips it. The deduplication window is 24 hours, which is well beyond the maximum Kafka retention for in-flight messages.

Q4: How do you handle a sudden spike in email volume from a single customer?

Multiple defense mechanisms activate. First, the customer's per-second and per-hour rate limits are enforced at the API layer. Second, the sending rate limiter detects volume spikes (comparing current rate to the customer's baseline) and automatically throttles with graduated delays. Third, if the spike causes bounce rates to rise above thresholds on any IP, that IP's sending rate is automatically reduced. Fourth, if the spike threatens to impact other customers' delivery (via shared infrastructure), we pause the offending customer's sending and notify them via email and Slack.

Q5: How do you design the webhook delivery system to handle unreliable customer endpoints?

Webhook delivery follows a retry pattern with exponential backoff: immediate attempt, then retries at 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, and 24 hours. Each webhook is signed with HMAC-SHA256 so customers can verify authenticity. If an endpoint fails 7 consecutive times, we pause delivery for that customer and alert them. We also provide a replay API so customers can re-trigger webhooks for any time window. Webhook payloads are stored in S3 for 30 days for debugging and replay purposes.

Q6: How does the system handle GDPR "right to be forgotten" requests?

We implement a GDPR erasure pipeline that processes deletion requests within 30 days. The pipeline: (1) Anonymizes all email content in S3 (replace with hash), (2) Deletes personal data from PostgreSQL (email addresses, IP addresses, user agents), (3) Anonymizes delivery events in ClickHouse, (4) Removes the user from all suppression lists (unless required for sender reputation), (5) Deletes device tokens for push notifications, (6) Logs the erasure for compliance audit trail. The pipeline is idempotent and can be re-run safely.

Q7: How would you design an A/B testing feature for email subject lines?

When an A/B test is configured, the system sends variant A to 10% of recipients and variant B to 10%, waits for a configurable window (typically 2 hours), and then sends the winning variant to the remaining 80%. Winning is determined by open rate (for subject line tests) or click rate (for content tests). We use a multi-armed bandit approach for ongoing campaigns, dynamically shifting volume toward the better-performing variant. All variant assignments are deterministic (based on recipient hash) to ensure consistency across retries.

Q8: How do you debug deliverability issues for a specific customer?

Our debugging toolkit includes: (1) Per-customer deliverability dashboards showing bounce rates, complaint rates, and inbox placement by ISP, (2) Message-level trace logs showing every SMTP interaction for a specific message ID, (3) Seed-list testing results showing actual inbox placement at major ISPs, (4) Content analysis tool that checks for spam trigger words, excessive links, and formatting issues, (5) DNS record validator that checks SPF/DKIM/DMARC configuration, (6) IP reputation checker that shows reputation scores across blacklists (Spamhaus, Barracuda, etc.).

Q9: What happens when a major ISP like Gmail starts deferring our messages?

When we detect elevated deferral rates to Gmail (measured per-MX-host), the system automatically: (1) Reduces sending rate to that ISP by 50%, (2) Selects higher-reputation IPs for Gmail-bound traffic, (3) Extends retry intervals to avoid compounding the deferral issue, (4) Alerts the ops team and the customer, (5) If deferral rate exceeds 30% for 30 minutes, pauses all Gmail-bound marketing email and only allows transactional through. We also reach out to Google's postmaster team if the issue persists, providing volume and complaint data to diagnose the root cause.

Q10: How do you handle international email delivery (different ISPs, timezones, regulations)?

We maintain ISP-specific configuration tables with per-ISP rate limits, connection limits, and known quirks (e.g., certain ISPs reject certain header formats). For timezone-aware sending, we store each recipient's timezone preference and schedule delivery during their local business hours. For regulations, we enforce CAN-SPAM for US recipients, GDPR for EU recipients, CASL for Canadian recipients, and PECR for UK recipients — each with different unsubscribe requirements and consent management rules. The system automatically detects recipient timezone from IP geolocation and applies the appropriate regulatory framework.

Interview Tip: When discussing email system design in an interview, always emphasize the separation between the fast path (API acceptance) and slow path (actual delivery). This separation is what enables both low latency and high throughput. Also highlight that email delivery is fundamentally a best-effort, asynchronous process — unlike HTTP request-response patterns.

Email Notification System — Senior+ Guide