How to Design an Email/Notification System
Building a SendGrid/Amazon SES-Scale System — Compose, Queue, Deliver, Track, Scale
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.
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:
- 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.
- Template Management: Store, version, and render email templates with Handlebar-style variable substitution. Support multiple languages and fallback templates.
- Batch Sending: Send a single email to millions of recipients efficiently, with deduplication and per-recipient personalization.
- Transactional Email Delivery: High-priority, low-latency delivery for password resets, order confirmations, 2FA codes, and other time-sensitive messages.
- Marketing Email Delivery: High-throughput delivery for newsletters, promotions, and campaigns with throttling to protect sender reputation.
- Bounce Processing: Detect and classify hard bounces (invalid address), soft bounces (mailbox full, temporary failure), and spam complaints. Update recipient status accordingly.
- Open & Click Tracking: Track when recipients open emails (via tracking pixel) and click links (via redirect URLs). Provide real-time and aggregated analytics.
- Unsubscribe Management: Support one-click unsubscribe via List-Unsubscribe headers and preference center. Honor global suppression lists.
- Webhook Delivery: Notify customer applications of delivery events (delivered, bounced, opened, clicked, complained) via configurable webhook endpoints.
- Suppression List Management: Maintain global and per-customer suppression lists. Automatically suppress hard bounces and spam complaints.
- Multi-Channel Notifications: Extend beyond email to support push notifications (iOS/Android), SMS, and in-app messaging through a unified API.
- Analytics Dashboard: Provide real-time and historical analytics on delivery rates, open rates, click rates, bounce rates, and engagement metrics.
- Domain & Sender Authentication: Help customers configure SPF, DKIM, and DMARC records. Manage dedicated IP pools and warming schedules.
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Throughput | 1 billion emails/day | Compete with major ESPs at global scale |
| API Latency (P99) | < 200ms for accept | Applications 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 messages | Batch sends can tolerate delay for reputation protection |
| Availability | 99.99% | Email downtime means missed transactions for customers |
| Durability | At-least-once delivery | Never lose a message; dedup at receiver if needed |
| Open Tracking Accuracy | > 95% | Within industry standards given privacy protections |
| Delivery Success Rate | > 99.9% for transactional | Transaction failures directly impact user experience |
| Compliance | CAN-SPAM, GDPR, CCPA | Legal requirements in major markets |
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/day | 1,000,000,000 | Design target |
| Emails/second (avg) | ~11,600 | 1B / 86,400 seconds |
| Emails/second (peak, 3x) | ~35,000 | Peak during business hours |
| Average email size | 50 KB | HTML + text + images reference |
| Daily storage (emails) | 50 TB | 1B * 50KB |
| Storage/month | ~1.5 PB | 50 TB * 30 days (raw) |
| Storage/month (compressed) | ~300 TB | 5:1 compression ratio |
| Webhook events/day | ~5,000,000,000 | 5 events per email average |
| API requests/day | ~2,000,000,000 | 2 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.
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.
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).
Component Responsibilities
| Component | Responsibility | Technology | Scale |
|---|---|---|---|
| API Gateway | Authentication, rate limiting, routing | Kong / Envoy | 20 instances |
| Template Renderer | Variable substitution, HTML rendering | C# + Scriban | 40 instances |
| Message Normalizer | Standardize format, add tracking | C# workers | 20 instances |
| Priority Classifier | Route to correct queue partition | C# workers | 10 instances |
| Delivery Workers | SMTP delivery with retry logic | C# + MailKit | 100 instances |
| Bounce Processor | Parse and classify delivery failures | C# workers | 20 instances |
| Event Processor | Aggregate tracking events | C# + ClickHouse | 30 instances |
| Webhook Dispatcher | Reliable webhook delivery to customers | C# workers | 30 instances |
Data Flow for a Single Email
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)
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.
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; }
}
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
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-emails | 600 | 3 | 7 days | delivery-workers |
| delivery-events | 300 | 3 | 30 days | event-processors |
| bounce-events | 50 | 3 | 30 days | bounce-processors |
| webhook-events | 150 | 3 | 7 days | webhook-dispatchers |
| open-click-events | 200 | 3 | 7 days | analytics-aggregators |
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
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.1 | Immediate bounce, no retry | Add to suppression list |
| Hard Bounce (Mailbox Full) | 552 5.2.2 | Bounce, may retry once | Add to suppression after 2nd bounce |
| Soft Bounce (Temporary) | 421 4.7.1 | Retry with exponential backoff | None (transient) |
| Soft Bounce (Rate Limited) | 452 4.7.1 | Throttle and retry | None (transient) |
| Spam Complaint (FBL) | N/A (via FBL) | Immediate suppression | Add to suppression list |
| Content Rejection | 550 6.7.1 | Bounce, investigate content | Alert customer |
| Authentication Failure | 550 7.1 | Bounce, fix DNS records | Alert 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)
};
}
}
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 Reputation | 0-100 | Per-IP deliverability; new IPs start at 50 |
| Domain Reputation | 0-100 | Per-sending-domain score; harder to recover |
| Volume Consistency | Binary | Sudden volume spikes trigger throttling |
| Bounce Rate | 0-10% | Must stay below 2%; alert at 1% |
| Complaint Rate | 0-1% | Must stay below 0.1%; auto-pause at 0.05% |
| Engagement Score | 0-100 | Open + 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.
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
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";
}
}
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 |
|---|---|---|---|---|---|
| 1-30 sec | 99.9% | $0.0001 | Yes (HTML) | Medium | |
| Push (iOS) | 1-5 sec | 95% | Free | Limited | High |
| Push (Android) | 1-5 sec | 97% | Free | Moderate | High |
| SMS | 1-10 sec | 98% | $0.0075 | Text only | Very High |
| In-App | Instant | 99% | Free | Full UI | Low |
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);
}
}
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
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;
}
}
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);
}
}
Customer Tier Rate Limits
| Tier | API Rate (rps) | Daily Send Limit | Monthly Send Limit | Burst Allowance |
|---|---|---|---|---|
| Free | 2 | 100 | 3,000 | None |
| Starter | 10 | 50,000 | 1,000,000 | 2x for 10 seconds |
| Pro | 50 | 500,000 | 10,000,000 | 3x for 30 seconds |
| Enterprise | 200 | Unlimited | Unlimited | 5x 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
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";
}
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 |
|---|---|---|
| Trigger | User action (signup, purchase) | Scheduled campaign |
| Latency Target | < 5 seconds (P95) | < 1 hour for batch |
| Unsubscribable | No (legally exempt) | Yes (legally required) |
| IP Pool | Dedicated transactional IPs | Shared marketing IPs |
| Volume Pattern | Steady, proportional to traffic | Spike during campaigns |
| Content | User-specific data | Same content, different recipients |
| Priority | High / Critical | Normal / Low |
| Queue | priority-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.
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 crash | Cannot accept new emails | Health check failures | Auto-scaling, load balancer failover |
| Kafka broker failure | Queue backlog grows | Consumer lag monitoring | Replication factor 3, ISR min 2 |
| Delivery worker crash | In-flight messages may be lost | Consumer group rebalancing | Kafka auto-commit disabled, manual ack |
| SMTP connection failure | Messages deferred, not lost | Connection pool health checks | Retry with exponential backoff |
| DNS resolution failure | Cannot resolve MX records | DNS query timeout monitoring | Cached MX records, fallback to A record |
| Database failure | Cannot read/write metadata | Connection pool exhaustion | Read replicas, connection timeouts |
| Redis failure | Rate limiting fails open | Redis sentinel/cluster alerts | Fail open (allow through), alert ops |
| ISP blocking | Messages to specific ISP rejected | Bounce rate spike per domain | Auto-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
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) | 20 | 4 vCPU, 8GB | $4,800 |
| Template Renderers | 40 | 8 vCPU, 32GB | $19,200 |
| Delivery Workers | 100 | 8 vCPU, 16GB | $48,000 |
| Bounce Processors | 20 | 4 vCPU, 8GB | $4,800 |
| Webhook Dispatchers | 30 | 4 vCPU, 8GB | $7,200 |
| Event Processors | 30 | 8 vCPU, 16GB | $14,400 |
| Kafka Cluster | 30 | 8 vCPU, 32GB, 2TB NVMe | $36,000 |
| PostgreSQL Cluster | 5 | 16 vCPU, 64GB, 10TB NVMe | $25,000 |
| Redis Cluster | 10 | 4 vCPU, 64GB | $12,000 |
| ClickHouse Cluster | 12 | 16 vCPU, 64GB, 50TB | $36,000 |
| S3 Storage | - | 300TB/month | $6,900 |
| Data Transfer | - | ~100TB/month outbound | $8,000 |
| Load Balancers | 4 | Application 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-600K | Tiered pricing |
| Mailgun | $0.80 | $800,000 | Postgres plan |
| Postmark | $1.25 | $1,250,000 | Premium transactional |
| Self-hosted (design) | $0.08 | $240,000 | Requires 10+ engineers |
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.