How to Design an Email Marketing Platform like Mailchimp — A Senior+ Guide
Building drag-and-drop email builders, automation workflows, and sending infrastructure for 11M+ users
1. Introduction — The Mailchimp Ecosystem
Email marketing remains one of the highest-ROI digital channels, generating approximately $36 for every $1 spent according to Litmus research. At the center of this ecosystem stands Mailchimp, a platform that has transformed from a side project into a powerhouse serving over 11 million users worldwide, processing more than 14 billion emails every month. Founded in 2001 by Ben Chestnut and Dan Kurzius, Mailchimp was acquired by Intuit in 2021 for $12 billion — a testament to the enduring value of email marketing infrastructure.
Mailchimp's success is rooted in its focus on small and medium-sized businesses. Unlike enterprise-focused competitors like Salesforce Marketing Cloud or Adobe Campaign, Mailchimp democratized email marketing by offering a generous free tier, an intuitive drag-and-drop editor, and pre-built templates requiring zero coding knowledge. This product-led growth strategy allowed it to capture massive market share among startups, e-commerce stores, content creators, and local businesses.
Building a Mailchimp-like platform is a profoundly complex system design challenge. You are not merely sending emails — you are building a visual editor that renders responsive HTML across hundreds of email clients, a segmentation engine that can filter millions of contacts in real time, a scheduling system that respects timezone preferences and throttling limits, and a deliverability engine that maintains sender reputation across thousands of shared and dedicated IP addresses. Every component must operate at internet scale while maintaining sub-second response times for user-facing operations.
In this article, we will dissect every major component of a Mailchimp-like system. We will estimate capacity requirements for processing 14 billion emails per month, design a data model that supports complex segmentation queries, architect a drag-and-drop email builder, implement email sending infrastructure with ISP relationship management, and build an analytics pipeline that provides real-time campaign insights. By the end, you will have a comprehensive blueprint for designing an email marketing platform from scratch.
Mailchimp's product surface extends far beyond simple email blasts. The platform encompasses landing pages, social media advertising, customer journey automation, predictive analytics powered by machine learning, a content studio for asset management, and a marketplace of integrations. For the scope of this system design, we will focus on the core email marketing platform — the campaign builder, audience management, sending infrastructure, automation engine, and analytics layer — while acknowledging that a production system would include these additional product surfaces.
Key Mailchimp Statistics
| Metric | Value |
|---|---|
| Monthly Active Users | 11M+ |
| Emails Sent Per Month | 14B+ |
| Total Contacts Managed | 300M+ |
| Free Tier Limit | 500 contacts, 1,000 sends/month |
| Paid Plans | Essentials, Standard, Premium |
| Revenue (2023) | ~$1.3B ARR |
| Acquisition Price | $12B (Intuit, 2021) |
The platform's architecture must support multi-tenancy at massive scale, where millions of accounts share infrastructure while maintaining strict data isolation. Each account may have audiences ranging from 500 to 5 million contacts, and campaigns may target anywhere from a hundred recipients to millions. The system must gracefully handle Black Friday traffic spikes when e-commerce customers send promotional campaigns to their entire lists, while simultaneously ensuring that transactional and automated emails maintain their latency SLAs.
2. Functional & Non-Functional Requirements
Functional Requirements
The first step in any system design is translating product capabilities into concrete functional requirements. For a Mailchimp-like platform, the core functional areas are:
Campaign Creation
- Users can create email campaigns using a drag-and-drop visual editor or raw HTML.
- Users can select from pre-built responsive templates or save custom templates.
- Users can compose subject lines, preview text, sender name, and reply-to addresses.
- Users can schedule campaigns for immediate sending or future delivery.
- Users can send test emails to verify rendering across email clients.
- Users can create A/B test variants for subject lines, content, and send times.
Audience Management
- Users can create multiple audiences (lists) per account.
- Users can import contacts via CSV upload, API, or integrations.
- Users can add custom fields and merge fields to contacts.
- Users can apply tags to contacts for organizational purposes.
- Users can create segments based on demographic, behavioral, and engagement criteria.
- Users can manage subscription status (subscribed, unsubscribed, bounced, cleaned).
Automation and Journeys
- Users can create multi-step automation workflows triggered by events (signup, purchase, tag addition).
- Users can add time delays, conditional splits, and action steps within workflows.
- Users can track automation performance (entries, exits, conversions).
- Users can enable abandoned cart recovery for e-commerce integrations.
Analytics and Reporting
- Users can view real-time open rates, click rates, bounce rates, and unsubscribe rates.
- Users can view click maps showing which links received engagement.
- Users can track revenue attribution from email-driven purchases.
- Users can compare campaign performance across time periods.
Deliverability and Compliance
- System must automatically handle bounces (hard and soft) and update contact status.
- System must enforce CAN-SPAM, GDPR, and CCPA compliance requirements.
- System must manage unsubscribe requests within one business day.
- System must authenticate sending domains via SPF, DKIM, and DMARC.
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Availability | 99.99% | Missed campaigns during peak hours directly impact revenue |
| Latency (API) | p99 < 500ms | Dashboard and editor responsiveness |
| Throughput | 14B emails/month | ~5.4M emails/minute at peak |
| Durability | No data loss | Campaign configurations and contact data are irreplaceable |
| Consistency | Eventual consistency acceptable | Analytics can lag by seconds |
| Scalability | Linear horizontal scaling | Must handle seasonal traffic spikes (Black Friday) |
| Security | SOC 2 Type II, GDPR | Handling PII for 300M+ contacts |
When we say 99.99% availability, we mean approximately 52 minutes of downtime per year. For an email platform processing millions of campaigns for small businesses — many of whom depend on email for a significant portion of their revenue — even brief outages during peak business hours (Tuesday through Thursday, 9 AM to 2 PM in the recipient's timezone) can cause measurable financial harm. The architecture must be designed with redundancy at every layer: load balancers, application servers, database replicas, message queues, and the email sending pipeline itself.
The system must also handle graceful degradation. If the analytics pipeline falls behind due to a traffic spike, the core sending infrastructure must not be affected. If the template rendering service experiences issues, already-queued campaigns must continue to send. This principle of service isolation is critical for maintaining overall system health.
3. Capacity Estimation and Back-of-Envelope Math
Before designing the architecture, we need concrete numbers to guide infrastructure sizing. Let us estimate the capacity requirements for a Mailchimp-scale system processing 14 billion emails per month.
Email Volume
- Total emails/month: 14,000,000,000 (14B)
- Emails/day: 14B / 30 is approximately 467M
- Emails/second (average): 467M / 86,400 is approximately 5,400 EPS
- Emails/second (peak, 5x average): approximately 27,000 EPS
The peak-to-average ratio is important. Email marketing has strong temporal patterns — campaigns are overwhelmingly scheduled between 9 AM and 2 PM in the recipient's timezone, and Tuesday through Thursday sees the highest volume. This means our system must handle peak throughput that is 5-10x the average, and we must design for timezone-aware scheduling that distributes load across UTC offsets.
User and Account Scale
| Metric | Estimate |
|---|---|
| Total accounts | 11,000,000 |
| Paid accounts | ~1,000,000 |
| Active accounts (monthly) | ~6,000,000 |
| Average contacts per account | ~2,500 |
| Total contacts stored | ~300,000,000 |
| Average campaigns per account/month | ~8 |
| Templates per account (average) | ~15 |
Storage Estimation
- Contact records: 300M contacts at ~2KB per record equals 600GB
- Template HTML: 11M accounts times 15 templates times ~50KB equals ~8.25TB
- Campaign content: 11M times 8 campaigns/month times 12 months times ~30KB equals ~31.7TB/year
- Analytics events: 14B open/click events times ~200 bytes equals 2.8TB/month
- Media assets: ~10TB (images, videos embedded in emails)
- Total storage (year 1): ~55TB
Bandwidth Estimation
- Inbound (API traffic): ~100K requests/second times ~5KB average equals 500MB/s
- Outbound (email data): 27K emails/second times ~50KB average equals 1.35GB/s at peak
- Dashboard traffic: ~500K concurrent users times ~100KB page weight equals ~50GB loaded per second via CDN
Compute Estimation
- Web servers (API): ~200 instances for API traffic
- Email sending workers: ~500 instances for peak throughput
- Segmentation workers: ~100 instances for real-time segment evaluation
- Analytics processors: ~50 instances for event ingestion
- Database: ~50-node PostgreSQL cluster with read replicas
These numbers demonstrate the scale at which a Mailchimp-like system operates. The architecture must support high-throughput batch processing for email sending, low-latency request handling for the user-facing application, and massive storage capacity for contact data, templates, and analytics — all while maintaining strict deliverability standards and compliance requirements. Each subsystem can be sized independently based on these estimates, allowing for targeted capacity planning and cost optimization.
4. Core Data Model
The data model for an email marketing platform must support complex relationships between accounts, audiences, contacts, campaigns, templates, automations, and segments. Let us design each entity and its relationships.
Entity Relationships
Account
Every user of the platform has an Account, which serves as the top-level tenant. The account determines billing plan, sending limits, feature access, and data isolation boundaries.
Account {
UUID id (PK)
STRING email
STRING company_name
ENUM plan (free, essentials, standard, premium)
INT monthly_send_limit
INT contact_limit
STRING timezone
JSONB settings
TIMESTAMP created_at
TIMESTAMP updated_at
UUID api_key
JSONB billing_info
}
Audience (List)
An audience is a collection of contacts. Each account can have multiple audiences, and each audience maintains its own subscription status, custom fields, and segment definitions. This is the fundamental unit of audience organization.
Audience {
UUID id (PK)
UUID account_id (FK)
STRING name
TEXT default_from_name
STRING default_reply_to
INT contact_count
JSONB merge_fields
JSONB settings
TIMESTAMP created_at
}
Contact
Contacts represent individual subscribers. A contact belongs to exactly one audience but can appear across multiple campaigns within that audience. The contact model must support merge fields (personalization tokens), tags, and engagement tracking.
Contact {
UUID id (PK)
UUID audience_id (FK)
STRING email_address
STRING first_name
STRING last_name
ENUM status (subscribed, unsubscribed, bounced, cleaned, pending)
JSONB merge_fields
JSONB location
TIMESTAMP signup_timestamp
TIMESTAMP last_changed
FLOAT engagement_score
}
Campaign
A campaign represents a single email blast or a step in an automation. It contains the content, targeting criteria, scheduling information, and performance metrics. The campaign model is the most complex entity, supporting regular campaigns, A/B tests, and automation-triggered sends.
Campaign {
UUID id (PK)
UUID account_id (FK)
UUID audience_id (FK)
UUID template_id (FK)
STRING title
STRING subject_line
STRING preview_text
STRING from_name
STRING reply_to
ENUM type (regular, plain-text, ab-test, automation)
ENUM status (draft, scheduled, sending, sent, paused)
JSONB recipients
JSONB settings
JSONB ab_test_config
TIMESTAMP schedule_send_time
TIMESTAMP sent_at
JSONB report_summary
}
Segment
Segments define dynamic groups of contacts based on filter conditions. They are stored as serialized query trees that can be evaluated against the contact database in real time or pre-computed and cached.
Segment {
UUID id (PK)
UUID audience_id (FK)
STRING name
JSONB conditions (query tree)
INT estimated_size
BOOL is_static
TIMESTAMP last_calculated
TIMESTAMP created_at
}
Automation
Automations define multi-step workflows that execute in response to triggers. Each automation contains a sequence of steps (emails, delays, conditions, splits) that are processed by the automation engine.
Automation {
UUID id (PK)
UUID account_id (FK)
UUID audience_id (FK)
STRING name
ENUM status (paused, started, archived)
JSONB trigger_config
JSONB workflow_steps
INT total_entries
INT total_exits
TIMESTAMP created_at
}
Template
Templates store email designs as serialized JSON document trees (for drag-and-drop templates) or raw HTML. Templates support versioning, enabling users to roll back changes.
Template {
UUID id (PK)
UUID account_id (FK)
STRING name
ENUM type (design, code)
JSONB design_data (block tree for visual editor)
TEXT html_content
TEXT plain_text
INT version
TIMESTAMP created_at
TIMESTAMP updated_at
}
The data model follows a multi-tenant pattern where the account_id serves as the primary tenant boundary. All queries are scoped to an account to prevent cross-tenant data leakage. The segment conditions field uses a JSON-based query tree format that can represent complex boolean logic, enabling users to build segments like "contacts who opened the last campaign AND live in New York AND have purchased in the last 30 days."
5. API Design
The Mailchimp-like platform exposes a RESTful API that serves three primary consumers: the web dashboard (first-party client), third-party integrations, and internal services. The API follows REST conventions with JSON payloads, uses OAuth 2.0 for authentication, and implements rate limiting per API key.
Campaign Endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/campaigns | Create a new campaign |
| GET | /api/v1/campaigns/:id | Get campaign details |
| PATCH | /api/v1/campaigns/:id | Update campaign settings |
| POST | /api/v1/campaigns/:id/send | Send or schedule a campaign |
| POST | /api/v1/campaigns/:id/test | Send test emails |
| GET | /api/v1/campaigns/:id/report | Get campaign analytics |
| POST | /api/v1/campaigns/:id/pause | Pause a scheduled campaign |
| DELETE | /api/v1/campaigns/:id | Delete a draft campaign |
Contact Endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/audiences/:id/contacts | Add a single contact |
| POST | /api/v1/audiences/:id/contacts/batch | Batch add/update contacts (up to 500) |
| GET | /api/v1/audiences/:id/contacts/:email | Get contact by email |
| PATCH | /api/v1/audiences/:id/contacts/:email | Update contact fields |
| DELETE | /api/v1/audiences/:id/contacts/:email | Remove a contact |
| POST | /api/v1/audiences/:id/contacts/import | Import CSV file |
| GET | /api/v1/audiences/:id/segments/:sid/contacts | List segment members |
Automation Endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/automations | Create an automation workflow |
| GET | /api/v1/automations/:id | Get automation details and stats |
| PATCH | /api/v1/automations/:id | Update automation settings |
| POST | /api/v1/automations/:id/start | Start the automation |
| POST | /api/v1/automations/:id/pause | Pause the automation |
| GET | /api/v1/automations/:id/emails/:eid/report | Get email-level stats |
Example: Create Campaign Request
POST /api/v1/campaigns
Authorization: Bearer {api_key}
Content-Type: application/json
{
"type": "regular",
"title": "Summer Sale 2026",
"audience_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"template_id": "t9876543-21ab-cdef-0123-456789abcdef",
"settings": {
"subject_line": "Summer Sale: Up to 50% Off!",
"preview_text": "Don't miss our biggest sale of the year",
"from_name": "Ayodhyya Store",
"reply_to": "support@ayodhyya.com"
},
"recipients": {
"segment_opts": {
"segment_id": "seg_abc123"
}
},
"schedule": {
"send_time": "2026-07-15T09:00:00Z",
"timewarp": true
}
}
Example: Campaign Response
HTTP/1.1 201 Created
{
"id": "c7890abc-def1-2345-6789-abcdef012345",
"type": "regular",
"title": "Summer Sale 2026",
"status": "draft",
"audience_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"template_id": "t9876543-21ab-cdef-0123-456789abcdef",
"settings": {
"subject_line": "Summer Sale: Up to 50% Off!",
"preview_text": "Don't miss our biggest sale of the year",
"from_name": "Ayodhyya Store",
"reply_to": "support@ayodhyya.com"
},
"recipients": {
"segment_id": "seg_abc123",
"estimated_recipient_count": 245000
},
"report_summary": {
"emails_sent": 0,
"opens": 0,
"unique_opens": 0,
"clicks": 0,
"unique_clicks": 0
},
"created_at": "2026-07-14T10:30:00Z",
"updated_at": "2026-07-14T10:30:00Z"
}
Rate Limiting
The API enforces rate limits based on the account's plan tier. Free accounts are limited to 100 requests per minute, while Premium accounts receive 10,000 requests per minute. Rate limit headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset) are included in every response. Batch endpoints have separate, higher limits to accommodate bulk operations.
The API design prioritizes consistency and predictability. All timestamps use ISO 8601 format in UTC, all IDs are UUIDs, and all error responses follow a consistent format with an error code, human-readable message, and a documentation link. Pagination uses cursor-based pagination rather than offset-based, as offset pagination becomes inefficient with large datasets and concurrent writes.
6. High-Level Architecture
The architecture of a Mailchimp-like platform follows a microservices pattern with clear separation between the user-facing application layer, the business logic layer, and the email sending infrastructure. The key architectural principle is that the sending pipeline must be completely decoupled from the rest of the system to ensure reliable, on-time delivery.
Service Responsibilities
| Service | Responsibility | Database | Scaling Pattern |
|---|---|---|---|
| Campaign Service | Campaign CRUD, scheduling, state management | PostgreSQL | Horizontal |
| Audience Service | Contact management, import, merge | PostgreSQL | Horizontal |
| Template Service | Template storage, rendering, versioning | S3 + Redis | Cache-based |
| Automation Service | Workflow execution, trigger evaluation | PostgreSQL + Kafka | Event-driven |
| Segmentation Service | Segment evaluation, contact filtering | PostgreSQL + Elasticsearch | Read replicas |
| Analytics Service | Event ingestion, aggregation, reporting | Elasticsearch + PostgreSQL | Stream processing |
| Email Builder | Visual editor backend, block rendering | S3 + Redis | Stateless |
| Send Scheduler | Campaign scheduling, timezone management | Redis + Kafka | Partition-based |
| Throttle Manager | Rate limiting per ISP, IP rotation | Redis | Stateful |
| Bounce Handler | Bounce classification, contact status updates | Kafka + PostgreSQL | Event-driven |
Communication Patterns
Services communicate through two primary mechanisms: synchronous REST or gRPC calls for request-response operations (dashboard interactions), and asynchronous Kafka messages for event-driven operations (campaign sends, analytics events, automation triggers). This hybrid approach ensures low latency for user-facing operations while maintaining reliable, ordered processing for background operations.
The Kafka cluster serves as the central nervous system of the platform. Every significant state change — campaign created, contact added, email sent, link clicked, bounce received — generates a Kafka event. Downstream services subscribe to these event streams to update their own state, trigger workflows, or aggregate analytics. This event sourcing pattern provides natural audit trails and enables new features to be built by consuming existing event streams without modifying the producing services.
The CDN layer serves all static assets — the web dashboard JavaScript bundles, CSS files, template preview images, and user-uploaded media. This offloads approximately 80% of traffic from the application servers. The API Gateway handles authentication, rate limiting, request routing, and response caching for common read operations like template listing and account settings.
7. Drag-and-Drop Email Builder
The drag-and-drop email builder is the most visible and technically challenging component of the platform. It must provide a What-You-See-Is-What-You-Get (WYSIWYG) editing experience while generating email-safe HTML that renders correctly across hundreds of email clients, including Outlook, Gmail, Yahoo Mail, Apple Mail, and various mobile clients. This is a remarkably difficult problem because email HTML is stuck in a quasi-1999 state — most email clients do not support modern CSS features like Flexbox, Grid, or CSS variables.
Block-Based Architecture
The editor uses a block-based document model where every email is a tree of typed blocks. Each block corresponds to a visual element — heading, paragraph, image, button, divider, social links, or a layout container. The block tree is stored as a serialized JSON document, separate from the rendered HTML. This separation allows the editor to manipulate the document structure without worrying about HTML output, while the template engine handles HTML generation from the block tree.
Block Tree Data Structure
The block tree is a recursive data structure where container blocks (like columns) can hold child blocks. Each block has a type, a unique ID, and a set of type-specific properties. The tree is stored as a JSON document in the template record and can be manipulated programmatically or via the visual editor.
public class EmailBlock
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Type { get; set; } // heading, text, image, button, columns, divider
public Dictionary<string, object> Properties { get; set; } = new();
public List<EmailBlock> Children { get; set; } = new();
public BlockStyle Style { get; set; } = new();
}
public class BlockStyle
{
public string BackgroundColor { get; set; }
public string Padding { get; set; }
public string TextAlign { get; set; }
public string BorderRadius { get; set; }
public string Border { get; set; }
public string Width { get; set; }
}
// Example block tree for a simple email
var blockTree = new EmailBlock
{
Type = "container",
Children = new List<EmailBlock>
{
new EmailBlock
{
Type = "image",
Properties = new Dictionary<string, object>
{
["src"] = "https://cdn.example.com/logo.png",
["alt"] = "Company Logo",
["width"] = "200"
}
},
new EmailBlock
{
Type = "heading",
Properties = new Dictionary<string, object>
{
["text"] = "Summer Sale!",
["level"] = 1
}
},
new EmailBlock
{
Type = "text",
Properties = new Dictionary<string, object>
{
["content"] = "Hi *|FIRST_NAME|*, check out our deals."
}
},
new EmailBlock
{
Type = "button",
Properties = new Dictionary<string, object>
{
["label"] = "Shop Now",
["url"] = "https://store.example.com/sale",
["backgroundColor"] = "#FF6B35",
["textAlign"] = "center"
}
}
}
};
HTML Rendering Pipeline
Converting the block tree into email-safe HTML is a multi-step process. First, the HTML generator traverses the block tree and produces semantic HTML using tables for layout (because email clients do not reliably support Flexbox or Grid). Second, the CSS inliner converts all CSS rules into inline styles, because many email clients strip style tags. Third, the compatibility layer adds Outlook-specific conditional comments, VML fallbacks for background images, and client-specific workarounds.
The final HTML must be tested against a rendering preview service (like Litmus or Email on Acid) that screenshots the email across 90+ email clients. The builder provides a preview pane that shows approximate rendering, but the full cross-client preview happens asynchronously after the user saves the template.
Responsive Design for Email
Email responsive design uses a fundamentally different approach than web responsive design. Because most email clients strip media queries, responsive emails use a hybrid technique: a combination of fluid tables, max-width on containers, and preprocessor-based column stacking where multi-column layouts collapse to single columns on mobile. The HTML generator must implement this hybrid approach automatically, detecting column layout blocks and generating the appropriate responsive fallback markup.
8. Email Template System
The template system is the backbone of email creation, enabling users to design once and reuse across multiple campaigns. Mailchimp offers three template paradigms: pre-built themed templates, custom-coded HTML templates, and user-saved templates from previous campaigns. The system must support all three while maintaining version control and enabling personalization through dynamic content blocks.
Template Categories
| Category | Description | Customization | Target User |
|---|---|---|---|
| Pre-built Themes | Professionally designed, 80+ layouts | Content + Colors + Images | Non-technical users |
| Saved Templates | User-created templates from editor | Full editing | Regular creators |
| Code Templates | Raw HTML/MJML templates | Unlimited | Developers |
| Dynamic Templates | Templates with conditional blocks | Logic-based rendering | Advanced users |
Dynamic Content Blocks
Dynamic content blocks allow template sections to display different content based on recipient data or conditions. For example, a product recommendation block can show different products to different recipients based on their purchase history. The template engine evaluates these conditions at send time, generating personalized HTML for each recipient.
public class DynamicContentRenderer
{
public string RenderBlock(EmailBlock block, Contact recipient,
Dictionary<string, object> mergeData)
{
if (block.Type != "dynamic")
return RenderStaticBlock(block);
var condition = block.Properties["condition"] as string;
var trueContent = block.Properties["trueContent"] as EmailBlock;
var falseContent = block.Properties["falseContent"] as EmailBlock;
bool conditionResult = EvaluateCondition(condition, recipient, mergeData);
var activeBlock = conditionResult ? trueContent : falseContent;
return RenderBlock(activeBlock, recipient, mergeData);
}
private bool EvaluateCondition(string condition, Contact recipient,
Dictionary<string, object> mergeData)
{
// Supports conditions like:
// "mergeFields.TIER == 'VIP'"
// "tags contains 'new-customer'"
// "engagementScore > 0.7"
// "location.state == 'California'"
var evaluator = new ConditionEvaluator();
return evaluator.Evaluate(condition, recipient, mergeData);
}
}
Template Versioning
Every template save creates a new version, storing the previous version in a version history table. This enables users to roll back changes, compare versions, and maintain an audit trail. The version history is implemented as an append-only log, with the current version pointer stored in the template record. Old versions are never deleted — they are referenced by historical campaigns for archival and compliance purposes.
Merge Field System
Merge fields are personalization tokens that get replaced with contact-specific values at send time. The syntax uses asterisks as delimiters: *|FIRST_NAME|*, *|LAST_NAME|*, *|EMAIL|*. Custom merge fields support any data the user has stored on their contacts. The template engine scans the HTML output for merge field patterns and performs replacement, with a fallback value for contacts where the field is empty.
The merge field system also supports advanced features like conditional blocks (*|IF:FIELD|*...*|END:IF|*) and loop blocks for product arrays (*|LOOP:PRODUCTS|*...*|END:LOOP|*). These constructs are parsed into an abstract syntax tree before being evaluated for each recipient. The loop blocks are particularly complex, as they must iterate over product arrays and render the loop body for each item, handling cases where the array is empty or exceeds a maximum iteration count.
Template Storage and Caching
Templates are stored in PostgreSQL as both the block tree (JSON) and pre-rendered HTML. When a user opens the template editor, the block tree is loaded into the browser and reconstructed into the visual editor. When a campaign sends, the template HTML is generated from the block tree, inlined with CSS, personalized with merge fields, and then cached for the duration of the send. The rendered HTML is stored in S3 for archival, ensuring that historical campaigns can be viewed without re-rendering.
9. Audience and Contact Management
Audience management is the foundation of email marketing. Every campaign targets a specific audience, and the quality of audience data directly impacts deliverability, engagement, and revenue. Mailchimp's audience management system handles millions of contacts per account, supports complex data models with custom fields and merge fields, and provides tools for data hygiene and enrichment.
Core Audience Concepts
An audience (historically called a "list") is a top-level container for contacts. Unlike some competitors that allow contacts to exist across multiple lists, Mailchimp enforces a one-list-per-contact model within an account. This simplifies unsubscribe management — when a contact unsubscribes from one audience, they are removed from all campaigns targeting that audience, preventing embarrassing duplicate sends.
Each audience maintains its own set of custom fields (text, number, date, radio, dropdown, address, phone, and birthday types), default settings (from name, reply-to email, timezone), and subscription status tracking. The audience schema is flexible — users can add up to 50 merge fields and 500 tags per audience, allowing diverse use cases from simple newsletter lists to complex CRM-like data structures.
Contact Import Pipeline
Contact import is a resource-intensive operation that must handle files up to 2GB containing millions of rows. The import pipeline uses a multi-stage architecture to process imports asynchronously without impacting system performance.
The validation stage parses the CSV, validates email formats, checks field type constraints, and identifies rows with errors. Invalid rows are quarantined and reported to the user. The deduplication stage checks for existing contacts by email address and merges or updates based on the user's import settings (add new only, update existing, or merge). The enrichment stage applies default values, normalizes data (trimming whitespace, lowercasing emails), and enriches with timezone and location data from IP geolocation if available. The write stage performs bulk inserts into PostgreSQL using COPY commands, then publishes contact-created or contact-updated events to Kafka for downstream processing.
Tag System
Tags provide a flexible, user-defined classification system for contacts. Unlike merge fields (which are structured data), tags are freeform strings that can be applied to contacts for segmentation, automation triggers, and organizational purposes. A contact can have up to 500 tags, and tags can be applied programmatically via the API, during import, or through automation workflows.
Tags are stored in a many-to-many relationship table with the contacts table. The tag table includes the tag name, a reference color for visual identification in the dashboard, and usage statistics. Because tags are frequently queried for segmentation, the tag-contact mapping is indexed for fast lookups and cached in Redis for hot accounts.
Subscription Status Management
Each contact has a subscription status that determines whether they receive campaign emails. The status model must handle the full lifecycle: pending (double opt-in confirmation pending), subscribed (active and receiving emails), unsubscribed (opted out), bounced (delivery failed), and cleaned (permanently removed due to hard bounces or spam complaints).
| Status | Receives Email | Transition Trigger | Reversible |
|---|---|---|---|
| Subscribed | Yes | Double opt-in confirmed | N/A |
| Pending | No | Signup (if double opt-in enabled) | Yes (reconfirmation) |
| Unsubscribed | No | Unsubscribe link clicked | Yes (resubscribe) |
| Bounced | No | Email bounce received | Soft: auto after success; Hard: no |
| Cleaned | No | Hard bounce or spam complaint | No |
The subscription status is a critical field for deliverability. Sending emails to unsubscribed or bounced contacts damages sender reputation and can result in ISP blocks. The campaign sending pipeline checks subscription status immediately before sending each email, and any status change during a send is immediately propagated to prevent duplicate sends in concurrent campaigns.
10. Segmentation Engine
Segmentation is the capability that transforms email marketing from a broadcast tool into a precision instrument. Rather than sending the same message to every contact, segments allow marketers to target specific groups based on demographics, behavior, engagement history, and custom data. Mailchimp's segmentation engine must evaluate complex filter conditions across millions of contacts while returning results in seconds.
Segment Condition Model
Segments are defined as trees of conditions combined with boolean logic. Each condition specifies a field, an operator, and a value. Conditions can be combined with AND (all must match), OR (any must match), and NOT (must not match) operators. The condition tree is stored as a JSON document and evaluated by the segmentation engine.
public class SegmentCondition
{
public string Field { get; set; } // "merge_fields.TIER", "engagement_score", "tags"
public string Operator { get; set; } // "equals", "contains", "greater_than", "before", "in"
public object Value { get; set; } // "VIP", 0.7, ["tag1", "tag2"]
}
public class SegmentRule
{
public string Logic { get; set; } // "AND", "OR", "NOT"
public List<SegmentRule> Children { get; set; } = new();
public SegmentCondition Condition { get; set; }
}
Common Segment Types
| Segment Type | Example | Underlying Data |
|---|---|---|
| Demographic | Contacts in California over age 30 | Merge fields (location, age) |
| Behavioral | Opened last 3 campaigns | Engagement events |
| Engagement | Active in last 30 days | Last open/click timestamp |
| E-commerce | Purchased in last 7 days, order value > $50 | Shopify integration data |
| Tag-based | Tagged "VIP" or "subscriber" | Tag associations |
| Campaign-based | Clicked link in campaign X | Campaign event data |
| Date-based | Subscribed before January 2026 | Signup timestamp |
Evaluation Strategies
The segmentation engine uses two strategies depending on the segment's purpose. For static segments (used in reports and analytics), the engine evaluates the condition tree once and materializes the result set into a temporary table. For dynamic segments (used in campaign targeting and automation triggers), the engine evaluates the condition tree at send time to ensure the most up-to-date membership.
Static segments are evaluated by translating the JSON condition tree into SQL WHERE clauses. The translation is recursive — AND nodes become SQL AND operators, OR nodes become SQL OR operators, and leaf conditions become individual WHERE predicates. For segments involving engagement data (opens, clicks), the SQL joins with the event tables using indexed lookups. For segments involving large-scale behavioral analysis (opened every campaign in the last 6 months), the engine uses pre-computed engagement summaries stored in Elasticsearch to avoid expensive SQL joins across billions of event rows.
Segment Performance Optimization
Segment evaluation performance depends on several factors: the complexity of the condition tree, the size of the contact table, the selectivity of the conditions, and the availability of appropriate indexes. The segmentation engine implements several optimization strategies:
- Index hints: The query planner uses statistics about field cardinality to choose optimal indexes. High-cardinality fields (email, signup_date) use B-tree indexes, while low-cardinality fields (status, tags) use bitmap indexes.
- Condition reordering: The optimizer reorders AND conditions to evaluate the most selective conditions first, reducing the intermediate result set size.
- Materialized views: For frequently-used segments, the engine creates materialized views that are refreshed periodically (every 5-15 minutes) and used for read-heavy operations.
- Partition pruning: The contacts table is partitioned by audience_id, and queries are scoped to a single partition, reducing the scan size by orders of magnitude.
11. Campaign Creation Flow
The campaign creation flow is the primary user journey on the platform. It follows a structured path from concept to delivery, with each step building on the previous. Understanding this flow is essential because it touches virtually every backend service and determines the architecture of the main application workflow.
Flow Overview
Step 1: Campaign Configuration
The user begins by naming their campaign, selecting the target audience, and optionally applying a segment filter. The audience selection step triggers an asynchronous segment size estimation — the system evaluates the segment conditions and displays the approximate recipient count. This count is an estimate (calculated by sampling 10% of the contact table and extrapolating) rather than an exact count, because the exact count would require a full table scan that could take minutes for large audiences.
Step 2: Content Design
The content design step launches the email builder described in Section 7. The builder loads the user's saved template or a blank canvas, provides the block palette for drag-and-drop editing, and shows a live preview panel. The builder auto-saves every 10 seconds to prevent data loss, storing the block tree in Redis with a TTL of 24 hours and periodically flushing to PostgreSQL.
Step 3: Testing
Before sending the campaign to the full audience, users can send test emails to up to 20 specific email addresses. The test send bypasses the normal scheduling pipeline and goes directly through the SMTP relay, arriving within seconds. The test email includes all merge field personalization (using placeholder values) and is flagged as a test in the email headers so it does not count toward the user's monthly send limit.
Step 4: Scheduling
The scheduling step is more complex than it appears. Users can choose to send immediately, schedule for a specific UTC time, or enable "Timewarp" — Mailchimp's feature that delivers the email at the optimal time in each recipient's local timezone. Timewarp requires the system to group recipients by timezone, calculate the equivalent UTC send time for each group, and schedule separate send batches for each timezone group.
The scheduler stores the campaign with a scheduled_time and a scheduled_timezone_mode (UTC or Timewarp). A background polling service queries for campaigns where scheduled_time is less than or equal to NOW() and status equals 'scheduled', picks them up, and transitions them to the sending pipeline. For Timewarp campaigns, the scheduler generates N separate send jobs (one per timezone group) with staggered UTC send times.
Step 5: Sending Pipeline
Once a campaign enters the sending pipeline, the system resolves the target audience, evaluates the segment (if applicable) to get the final recipient list, renders the template for each recipient (applying merge fields), and enqueues individual send jobs into Kafka. The Kafka topic is partitioned by recipient domain (gmail.com, yahoo.com, outlook.com, etc.) to ensure that sends to the same domain are processed by the same consumer group, enabling per-domain throttling.
Campaign State Machine
The campaign object follows a strict state machine that governs transitions. A campaign starts as draft, transitions to scheduled when the user picks a send time, moves to sending when the first email is dispatched, and settles at sent when the last email is delivered. The user can pause a scheduled campaign to modify it, or cancel a scheduled campaign to prevent sending. A sending campaign can be paused (holding remaining emails in the queue) but not canceled, as emails already sent cannot be recalled.
12. Email Sending Infrastructure
The email sending infrastructure is the most operationally complex component of the platform. It must maintain relationships with dozens of Internet Service Providers (ISPs), manage thousands of IP addresses across multiple sending domains, implement intelligent throttling to avoid spam filters, and handle the real-time feedback loop of bounces, complaints, and engagement signals. Building this infrastructure is the primary reason large email platforms have significant barriers to entry.
Architecture Overview
SMTP Relay Architecture
Email sending begins with SMTP (Simple Mail Transfer Protocol) connections. The send worker establishes an SMTP connection to the target MX server (resolved via DNS), authenticates with the appropriate EHLO/MAIL FROM identity, and transmits the email content. Each connection is stateless and independent, but the worker must manage connection pooling, TLS negotiation, retry logic, and error handling.
The platform uses a tiered sending architecture. Tier 1 consists of dedicated IPs for high-volume senders with established reputations, handling the bulk of campaign sends. Tier 2 consists of shared IPs for smaller senders, where the platform aggregates volume across multiple accounts to maintain sufficient sending volume for good ISP reputation. Tier 3 is the warm-up pool for newly provisioned IPs, which gradually increase sending volume over 4-6 weeks to establish reputation without triggering spam filters.
IP Warmup Protocol
New IP addresses cannot immediately send high volumes — ISPs treat sudden traffic from unknown IPs as a spam signal. The warmup protocol gradually increases daily volume according to a prescribed schedule:
| Day | Max Volume per IP | Target Recipients |
|---|---|---|
| 1-3 | 500/day | Most engaged contacts (opened last email) |
| 4-7 | 1,000/day | Engaged contacts |
| 8-14 | 5,000/day | Contacts active in last 30 days |
| 15-21 | 10,000/day | Contacts active in last 60 days |
| 22-28 | 25,000/day | Contacts active in last 90 days |
| 29-35 | 50,000/day | Full audience |
| 36+ | Unlimited | Production volume |
Throttling Strategy
The throttle manager enforces multiple rate limits simultaneously: per-IP throughput (to prevent ISP blocks), per-domain throughput (to avoid triggering per-domain rate limits), per-account throughput (to enforce billing limits), and global throughput (to protect shared infrastructure). The throttle manager uses a token bucket algorithm implemented in Redis, where each bucket represents an (IP, domain) pair and tokens are added at the configured rate.
The throttling is adaptive — if the bounce handler detects elevated bounce rates from a particular ISP, the throttle manager automatically reduces the sending rate to that ISP and redistributes the load to other IPs. This feedback loop typically responds within 30 seconds, preventing sustained over-delivery to a problematic ISP.
Connection Pool Management
Maintaining SMTP connections is expensive — each connection requires TCP handshake, TLS negotiation, and EHLO greeting. The send workers maintain a connection pool for frequently-contacted MX servers (Gmail, Outlook, Yahoo), keeping warm connections alive and reusing them for subsequent emails. The pool is managed per-worker with configurable idle timeouts (typically 5 minutes for Gmail, 2 minutes for smaller ISPs) and maximum pool sizes based on the ISP's documented connection limits.
13. Deliverability and Reputation Management
Deliverability is the percentage of emails that successfully reach recipients' inboxes (not spam folders). For a marketing platform, maintaining high deliverability is existential — if the platform's emails consistently land in spam, customers leave. Deliverability depends on three pillars: authentication (proving you own the sending domain), reputation (building trust through consistent behavior), and content (avoiding spam triggers).
Email Authentication Protocols
Modern email authentication relies on three DNS-based protocols that work together to verify the sender's identity. ISPs check all three before deciding whether to accept an email.
| Protocol | Purpose | DNS Record Type | Impact |
|---|---|---|---|
| SPF | Authorizes specific IPs to send on behalf of the domain | TXT | Pass/Fail |
| DKIM | Cryptographically signs emails to verify they were not modified | TXT | Pass/Fail |
| DMARC | Specifies policy for handling SPF/DKIM failures | TXT | Policy enforcement |
SPF (Sender Policy Framework)
SPF is a DNS TXT record that lists the IP addresses authorized to send email for a domain. When an ISP receives an email, it checks the sending IP against the SPF record. If the IP is not listed, the SPF check fails. The platform must maintain SPF records for every sending domain, which is complicated because many users send from domains they do not control (their company's domain). The platform provides users with the SPF record they need to add to their DNS, and monitors DNS to detect misconfigurations.
DKIM (DomainKeys Identified Mail)
DKIM adds a cryptographic signature to each email, which the receiving server can verify by looking up the sender's public DKIM key in DNS. This proves that the email was sent by an authorized server and was not modified in transit. The platform generates DKIM key pairs per sending domain and signs every email with the appropriate private key. Key rotation happens automatically every 6 months.
DMARC (Domain-based Message Authentication)
DMARC builds on SPF and DKIM by allowing domain owners to specify a policy for handling authentication failures. A DMARC policy of p=none means failures are reported but emails are still delivered. A policy of p=quarantine means failures go to spam. A policy of p=reject means failures are rejected entirely. The platform helps users configure DMARC gradually, starting with p=none while monitoring aggregate reports, then upgrading to p=quarantine once authentication rates exceed 95%.
Feedback Loops
Feedback loops (FBLs) are mechanisms by which ISPs notify the sender when a recipient marks an email as spam. Major ISPs (Gmail, Yahoo, Outlook, AOL) offer FBL programs that send complaints back to the platform. When a complaint is received, the platform immediately unsubscribes the contact and records the complaint against the sending campaign. This rapid response prevents repeated complaints from the same recipient, which is the fastest way to damage sender reputation.
Bounce Handling
Bounces are classified into two categories: hard bounces (permanent failures — invalid address, domain not found, mailbox full permanently) and soft bounces (temporary failures — server down, mailbox full temporarily, content too large). Hard bounces result in immediate contact status change to bounced or cleaned. Soft bounces are retried up to 3 times over 72 hours before being classified as a hard bounce.
Sender Score and IP Reputation
Every sending IP accumulates a reputation score based on historical sending behavior: bounce rates, complaint rates, engagement rates, volume consistency, and authentication pass rates. The platform monitors IP reputation across all major ISPs using tools like SenderScore (Return Path), Microsoft SNDS (Smart Network Data Services), and Google Postmaster Tools. If an IP's reputation drops below a threshold, it is automatically removed from the sending pool and placed in recovery mode, where it sends only to highly engaged contacts to rebuild reputation.
14. Automation and Customer Journeys
Marketing automation is the fastest-growing segment of email marketing platforms, transforming one-time campaign blasts into ongoing customer conversations. Mailchimp's automation engine supports multi-step workflows triggered by subscriber actions, time delays, and conditional logic. Designing this system requires a workflow engine, a trigger evaluation system, and a state machine for tracking each subscriber's progress through each workflow.
Automation Workflow Model
Trigger Types
| Trigger | Event Source | Typical Use Case |
|---|---|---|
| Signup | Contact creation event | Welcome email series |
| Tag Added | Tag assignment event | Onboarding sequence |
| Purchase | Shopify/WooCommerce webhook | Post-purchase follow-up |
| Cart Abandoned | E-commerce integration | Cart recovery series |
| Email Opened | Open tracking pixel | Engagement follow-up |
| Link Clicked | Click tracking redirect | Interest-based content |
| Date/Anniversary | Scheduled evaluation | Birthday emails |
| Inactivity | Engagement decay detection | Re-engagement campaign |
Workflow Execution Engine
The automation engine is built as an event-driven state machine. Each subscriber in a workflow is tracked by an AutomationExecution record that stores the current step, timestamps of completed steps, and the subscriber's data snapshot at entry time. The engine evaluates triggers by subscribing to Kafka events (contact-created, email-opened, purchase-made) and checking whether any active automations are listening for those events.
When a trigger fires, the engine creates an AutomationExecution record, places the subscriber at the first step, and schedules the step's action. If the action is an email send, it enqueues the email into the campaign sending pipeline. If the action is a delay, it schedules a delayed execution using a time-based Kafka topic with timestamp-based partitioning. If the action is a condition, it evaluates the condition against the subscriber's data and routes them to the appropriate branch.
Delay Implementation
Delays are implemented using a technique called "delayed message delivery" in Kafka. When a subscriber hits a delay step, the engine calculates the execution time (current time plus delay duration), serializes the execution state, and publishes it to a Kafka topic partitioned by the target execution timestamp. A consumer group reads from this topic and only processes messages whose timestamp has passed, effectively implementing a persistent, distributed timer. This approach is more reliable than in-memory timers (which are lost on process restart) and more efficient than polling a database for due actions.
Automation Analytics
Each automation maintains per-step analytics: how many subscribers entered the step, how many completed it, how many exited the workflow at this step, and the time spent in each step. This data enables marketers to identify bottlenecks (e.g., a 60% drop-off at a particular email) and optimize their workflows. The analytics are aggregated in real time using Kafka Streams and stored in Elasticsearch for dashboard visualization.
15. A/B Testing Framework
A/B testing is a core feature that enables marketers to optimize their campaigns by testing variations against subsets of their audience. Mailchimp supports A/B testing across three dimensions: subject lines, email content, and send times. The testing framework must handle variant creation, audience splitting, statistical significance calculation, and winner selection — all while maintaining deliverability standards for the test sends.
Test Design Matrix
| Test Type | Variants | Split Options | Winner Metric | Minimum Sample |
|---|---|---|---|---|
| Subject Line | 2-3 | 10%, 20%, 50% | Open rate | 5,000 per variant |
| Email Content | 2 | 10%, 20%, 50% | Click rate | 5,000 per variant |
| Send Time | 2 | 50% | Open rate | 5,000 per variant |
A/B Test Execution Flow
When a user creates an A/B test campaign, the system first identifies the target audience and applies any segment filters. The remaining contacts are then randomly split into N groups (one per variant plus the control). The split is deterministic based on a hash of the contact ID modulo the number of variants, ensuring consistent assignment even if the contact list changes slightly between test creation and send time.
The test variants are sent to the sample groups at the scheduled time. The system then waits for a configurable measurement window (typically 4-24 hours) during which engagement data is collected. After the measurement window, the system calculates which variant has the highest winning metric using a statistical significance test (typically a z-test for proportions with a 95% confidence interval). The winning variant is automatically sent to the remaining non-test recipients.
public class AbTestWinnerCalculator
{
public VariantResult CalculateWinner(List<VariantResult> variants,
double confidenceLevel = 0.95)
{
// For each pair of variants, run a two-proportion z-test
var sorted = variants.OrderByDescending(v => v.MetricValue).ToList();
for (int i = 0; i < sorted.Count - 1; i++)
{
var winner = sorted[i];
var runnerUp = sorted[i + 1];
double zScore = CalculateZScore(
winner.MetricValue, winner.SampleSize,
runnerUp.MetricValue, runnerUp.SampleSize);
double pValue = 2 * (1 - NormalCDF(Math.Abs(zScore)));
if (pValue < (1 - confidenceLevel))
{
return new VariantResult
{
VariantId = winner.VariantId,
MetricValue = winner.MetricValue,
ConfidenceLevel = 1 - pValue,
IsSignificant = true
};
}
}
// No statistically significant winner found
return new VariantResult { IsSignificant = false };
}
private double CalculateZScore(double p1, double n1, double p2, double n2)
{
double pPooled = (p1 * n1 + p2 * n2) / (n1 + n2);
double se = Math.Sqrt(pPooled * (1 - pPooled) * (1/n1 + 1/n2));
return (p1 - p2) / se;
}
}
Send Time Optimization
Send time A/B testing is unique because it tests the same content at different times rather than different content at the same time. The system splits the audience into two groups, sends Group A at time T1 and Group B at time T2 (typically 3-4 hours apart), and measures which time window produces higher open rates. The winning time is then applied to future campaigns targeting similar audience segments.
The A/B testing framework also supports multivariate testing on higher-tier plans, where multiple variables are tested simultaneously (e.g., subject line times content times send time). Multivariate testing requires larger sample sizes to achieve statistical significance, as the audience is split across more combinations. The system calculates the required sample size before launching the test and warns users if their audience is too small to produce reliable results.
16. Analytics and Reporting
Analytics is the feedback loop that transforms email marketing from guesswork into data-driven decision making. Mailchimp's analytics platform tracks billions of events — opens, clicks, unsubscribes, bounces, forwards, and purchases — and aggregates them into real-time dashboards, campaign reports, and audience insights. The analytics architecture must handle high-throughput event ingestion, support flexible ad-hoc queries, and provide sub-second dashboard response times.
Event Tracking Architecture
Every email sent by the platform includes two invisible tracking mechanisms: a 1x1 pixel for open tracking and rewritten URLs for click tracking. When a recipient opens an email, their email client requests the tracking pixel from the platform's tracking server, generating an open event. When a recipient clicks a link, they are first redirected through the platform's click tracking server (which records the click), then forwarded to the original destination URL.
Key Metrics
| Metric | Definition | Calculation |
|---|---|---|
| Open Rate | % of delivered emails opened | Unique Opens / Delivered x 100 |
| Click Rate | % of delivered emails with at least one click | Unique Clicks / Delivered x 100 |
| Click-Through Rate | % of opened emails with at least one click | Unique Clicks / Unique Opens x 100 |
| Bounce Rate | % of sent emails that bounced | Bounces / Sent x 100 |
| Unsubscribe Rate | % of delivered emails resulting in unsubscribe | Unsubscribes / Delivered x 100 |
| Spam Complaint Rate | % of delivered emails reported as spam | Complaints / Delivered x 100 |
| Revenue Per Email | Revenue attributed to email per recipient | Total Revenue / Delivered |
| Forward Rate | % of opens that result in forwards | Forwards / Opens x 100 |
Click Map Visualization
Click maps provide a visual representation of which links in an email received the most engagement. The analytics platform stores the position (x, y coordinates) and order of every clickable element in the email, then aggregates click data to produce a heatmap overlay. This helps marketers understand which content blocks, CTAs, and design elements drive engagement, informing future design decisions.
Revenue Attribution
Revenue attribution connects email engagement to e-commerce purchases. When a recipient clicks an email link that leads to a purchase on an integrated Shopify or WooCommerce store, the platform records the revenue against the campaign. Attribution is typically first-touch (the email that drove the final click before purchase) or last-touch (the last email opened before purchase). The platform supports configurable attribution windows (typically 5-30 days) and multi-touch attribution models for advanced users.
Real-Time vs. Batch Analytics
Open and click events are processed in two parallel pipelines. The real-time pipeline (powered by Kafka Streams) ingests events and updates running counters in Redis, enabling the dashboard to show "live" campaign statistics during the first hour after send. The batch pipeline (powered by scheduled ETL jobs) aggregates events into daily, weekly, and monthly rollups stored in PostgreSQL, enabling historical comparisons and trend analysis. The batch pipeline also computes derived metrics like engagement scores and segment membership changes.
17. Integrations Platform
Mailchimp's integrations platform connects the email marketing system with the broader ecosystem of business tools — e-commerce platforms, CRM systems, content management systems, and automation platforms. Over 300 native integrations plus a Zapier and Make integration layer enable data to flow between Mailchimp and tools like Shopify, WordPress, Salesforce, and Google Analytics. The integration architecture must handle diverse data formats, authentication mechanisms, and sync frequencies.
Integration Architecture
Integration Types
| Type | Data Flow | Sync Frequency | Examples |
|---|---|---|---|
| E-commerce | Bidirectional | Real-time webhooks + hourly poll | Shopify, WooCommerce, BigCommerce |
| CRM | Bidirectional | Every 15 minutes | Salesforce, HubSpot, Zoho |
| CMS | One-way (from CMS) | On publish | WordPress, Squarespace |
| Analytics | One-way (to Analytics) | On campaign send | Google Analytics, Facebook Pixel |
| Automation | Bidirectional | Real-time | Zapier, Make, Workato |
Shopify Integration Deep Dive
The Shopify integration is Mailchimp's most popular and complex integration. It synchronizes customers, products, orders, and store events between Shopify and Mailchimp. When a customer makes a purchase on Shopify, a webhook fires that the platform's webhook receiver processes in real time, updating the contact's purchase history, triggering abandoned cart automations, and refreshing product recommendation data.
The integration uses Shopify's OAuth 2.0 flow for authentication, REST and GraphQL APIs for data access, and webhooks for real-time event notifications. The sync engine maintains a mapping between Shopify customer IDs and Mailchimp contact IDs, handling the complexity of duplicate detection (a customer might have multiple Shopify orders but should be a single Mailchimp contact) and data normalization (Shopify addresses use a different field structure than Mailchimp merge fields).
Webhook Processing
Webhook receivers must handle high throughput, ensure at-least-once delivery, and gracefully handle out-of-order events. The webhook receiver validates the HMAC signature on each webhook, deduplicates using the webhook ID, and enqueues the event into Kafka for asynchronous processing. Failed webhooks are retried with exponential backoff, and the receiver maintains a dead letter queue for events that fail after 5 retries.
The integrations platform also provides a Developer API and a framework for building custom integrations. Developers can register webhook endpoints, define custom field mappings, and configure sync rules through the platform's UI. Custom integrations are sandboxed from the core platform to prevent third-party code from affecting system stability.
18. GDPR and Compliance
Operating an email marketing platform requires strict compliance with multiple international regulations: CAN-SPAM (US), GDPR (EU), CASL (Canada), and POPIA (South Africa). Non-compliance can result in fines up to 4% of global annual revenue under GDPR. The platform must build compliance into its core architecture rather than treating it as an afterthought.
Regulatory Comparison
| Regulation | Region | Consent Required | Unsubscribe Deadline | Max Fine |
|---|---|---|---|---|
| CAN-SPAM | United States | Opt-out only | 10 business days | $50,120 per email |
| GDPR | European Union | Opt-in required | Without undue delay (30 days max) | 20M EUR or 4% revenue |
| CASL | Canada | Opt-in required | 10 business days | $10M per violation |
| POPIA | South Africa | Opt-in required | Reasonable time | R10M |
GDPR Implementation
GDPR compliance requires several platform-level capabilities. First, consent management: every contact must have a recorded consent timestamp, the source of consent (signup form, import, API), and the specific purposes for which consent was given. Second, data access: contacts must be able to request a copy of all data stored about them, which the platform must provide within 30 days. Third, data erasure: contacts must be able to request deletion of all their data, which must be executed promptly. Fourth, data portability: contacts must be able to export their data in a machine-readable format.
Consent Record Schema
public class ConsentRecord
{
public string Id { get; set; }
public string ContactId { get; set; }
public string AudienceId { get; set; }
public ConsentStatus Status { get; set; } // Granted, Withdrawn
public string Purpose { get; set; } // "marketing_email", "analytics", "third_party_sharing"
public string Source { get; set; } // "signup_form", "api_import", "manual_opt_in"
public string IpAddress { get; set; }
public string FormVersion { get; set; }
public DateTime Timestamp { get; set; }
public DateTime? WithdrawnAt { get; set; }
}
public enum ConsentStatus
{
Granted,
Withdrawn
}
Unsubscribe Management
Every marketing email must include a visible unsubscribe link. When a recipient clicks the link, they are taken to an unsubscribe page that processes the request immediately, updating the contact's status to unsubscribed. The platform also supports one-click unsubscribe via the List-Unsubscribe email header, which allows email clients to show an unsubscribe button directly in the inbox. Both mechanisms update the contact record within seconds and suppress the contact from all future campaigns within the same audience.
Data Residency
GDPR requires that personal data of EU residents be processed and stored within the EU or in countries with adequate data protection agreements. The platform implements data residency through regional database deployments: EU data is stored in EU-based PostgreSQL clusters and S3 buckets, while US data is stored in US-based infrastructure. The data residency is determined by the account owner's location at signup and cannot be changed without migrating the data to the appropriate region.
The compliance engine runs automated audits that scan the platform for potential violations: contacts without consent records, campaigns sent without unsubscribe links, data exports that exceeded the 30-day SLA, and erasure requests that have not been completed. These audits generate compliance reports for the platform's Data Protection Officer and are reviewed quarterly.
19. Anti-Spam and List Hygiene
Maintaining a clean email list is both a user responsibility and a platform obligation. Dirty lists with invalid addresses, spam traps, and disengaged contacts destroy sender reputation and can result in the platform being blocklisted by ISPs. Mailchimp implements aggressive list hygiene measures that protect both individual accounts and the shared sending infrastructure.
Email Validation Pipeline
Every email address enters a validation pipeline at three points: signup form submission, CSV import, and API contact creation. The validation pipeline performs multiple checks to verify the email address is legitimate, deliverable, and not associated with abuse.
| Check | Method | Blocks Send | Severity |
|---|---|---|---|
| Format validation | Regex RFC 5322 | Yes | Critical |
| Domain existence | DNS MX record lookup | Yes | Critical |
| SMTP verification | RCPT TO command (without sending) | Yes | Critical |
| Disposable email detection | Blocklist of 5,000+ disposable domains | Yes | High |
| Role account detection | Pattern matching (admin@, info@, etc.) | No (flagged) | Medium |
| Spam trap detection | Third-party service (Kickbox, ZeroBounce) | Yes | Critical |
| Typo detection | Domain similarity matching | Suggests correction | Low |
List Cleaning Automation
The platform runs automated list cleaning on a weekly basis for all audiences. The cleaning process identifies and suppresses contacts that exhibit signs of disengagement or deliverability risk. Contacts that have not opened or clicked any email in the last 90 days are flagged as "at risk" and excluded from campaign sends unless the user explicitly overrides the suppression. This practice, known as "sunsetting," protects sender reputation by reducing sends to unengaged recipients.
Spam Trap Monitoring
Spam traps are email addresses used by ISPs and anti-spam organizations to identify senders with poor list practices. There are three types: pristine traps (addresses that have never been used by a real person and exist solely as traps), recycled traps (formerly valid addresses that have been deactivated and repurposed as traps), and typo traps (common misspellings of popular domains, like gmial.com instead of gmail.com). Hitting a spam trap is a serious deliverability event that can result in immediate IP blocking.
The anti-spam team also monitors blacklists (Spamhaus, Barracuda, SORBS) for any of the platform's sending IPs or domains. If an IP is listed on a blacklist, the IP is immediately removed from the sending pool, an investigation is launched, and a delisting request is submitted after the root cause is addressed. The monitoring runs every 15 minutes, ensuring that blacklistings are detected and responded to within minutes rather than hours.
Engagement-Based Filtering
Beyond traditional anti-spam measures, the platform uses engagement-based filtering to predict and prevent deliverability issues before they occur. Machine learning models analyze historical engagement data to predict the likelihood that a campaign will trigger spam complaints or high bounce rates. If the model's confidence exceeds a threshold, the campaign is flagged for review before sending, giving the user an opportunity to refine their targeting.
20. Database Design Deep Dive
The database layer of an email marketing platform must handle a unique combination of workloads: high-throughput writes for event ingestion (opens, clicks), complex analytical queries for segmentation, time-series data for campaign scheduling, and point lookups for contact retrieval. No single database technology optimally handles all these workloads, so the platform uses a polyglot persistence approach with PostgreSQL as the primary relational store, Elasticsearch for search and analytics, Redis for caching and real-time counters, and S3 for blob storage.
Contact Event Timeline
Every contact has an event timeline that records every interaction: signup, email sends, opens, clicks, bounces, unsubscribes, tag changes, and custom field updates. This timeline is the source of truth for contact history and powers the engagement scoring engine. The event table uses a time-series-optimized schema with partitioning by contact_id and clustering by timestamp.
CREATE TABLE contact_events (
contact_id UUID NOT NULL,
event_time TIMESTAMP NOT NULL,
event_type VARCHAR(50) NOT NULL,
event_data JSONB NOT NULL,
campaign_id UUID,
metadata JSONB,
PRIMARY KEY (contact_id, event_time, event_type)
) PARTITION BY HASH (contact_id);
-- Create 16 partitions for parallel query execution
CREATE TABLE contact_events_p0 PARTITION OF contact_events
FOR VALUES WITH (MODULUS 16, REMAINDER 0);
CREATE TABLE contact_events_p1 PARTITION OF contact_events
FOR VALUES WITH (MODULUS 16, REMAINDER 1);
-- Index for campaign-specific event queries
CREATE INDEX idx_contact_events_campaign
ON contact_events (campaign_id, event_type, event_time);
-- Index for time-range queries (engagement scoring)
CREATE INDEX idx_contact_events_time
ON contact_events (contact_id, event_time DESC);
Campaign Metrics Schema
Campaign metrics are aggregated in near-real-time as events flow through the Kafka pipeline. The metrics table stores both raw counts and computed rates, updated atomically using PostgreSQL's UPSERT (ON CONFLICT DO UPDATE) capability.
CREATE TABLE campaign_metrics (
campaign_id UUID PRIMARY KEY,
emails_sent INT DEFAULT 0,
emails_delivered INT DEFAULT 0,
hard_bounces INT DEFAULT 0,
soft_bounces INT DEFAULT 0,
unsubscribes INT DEFAULT 0,
spam_complaints INT DEFAULT 0,
opens INT DEFAULT 0,
unique_opens INT DEFAULT 0,
clicks INT DEFAULT 0,
unique_clicks INT DEFAULT 0,
forwards INT DEFAULT 0,
revenue DECIMAL(12, 2) DEFAULT 0,
last_updated TIMESTAMP DEFAULT NOW(),
open_rate DECIMAL(5, 4) GENERATED ALWAYS AS
CASE WHEN emails_delivered > 0
THEN unique_opens::DECIMAL / emails_delivered
ELSE 0 END STORED,
click_rate DECIMAL(5, 4) GENERATED ALWAYS AS
CASE WHEN emails_delivered > 0
THEN unique_clicks::DECIMAL / emails_delivered
ELSE 0 END STORED
);
-- Real-time metric update function
CREATE OR REPLACE FUNCTION update_campaign_metric(
p_campaign_id UUID,
p_metric_type TEXT,
p_increment INT DEFAULT 1
) RETURNS VOID AS $$
BEGIN
INSERT INTO campaign_metrics (campaign_id, last_updated)
VALUES (p_campaign_id, NOW())
ON CONFLICT (campaign_id) DO UPDATE SET
last_updated = NOW(),
emails_sent = campaign_metrics.emails_sent +
CASE WHEN p_metric_type = 'sent' THEN p_increment ELSE 0 END,
emails_delivered = campaign_metrics.emails_delivered +
CASE WHEN p_metric_type = 'delivered' THEN p_increment ELSE 0 END,
unique_opens = campaign_metrics.unique_opens +
CASE WHEN p_metric_type = 'unique_open' THEN p_increment ELSE 0 END,
unique_clicks = campaign_metrics.unique_clicks +
CASE WHEN p_metric_type = 'unique_click' THEN p_increment ELSE 0 END;
END;
$$ LANGUAGE plpgsql;
Engagement Scoring
The engagement score is a composite metric that represents a contact's likelihood of engaging with future emails. It is calculated using a weighted formula that considers recency, frequency, and monetary value (RFM) of engagement. The score is stored on the contact record and updated by a batch job that runs every 15 minutes, processing contacts that have had new engagement events since the last update.
The analytics events table (opens, clicks) is the highest-volume table, receiving approximately 500 million rows per day. To manage this volume, the table uses TimescaleDB's hypertable feature, which automatically partitions by time and provides time-series-specific query optimizations. Events older than 12 months are compressed using columnar compression, reducing storage costs by 90% while maintaining queryability.
21. Caching Strategy
Caching is critical for reducing database load and improving response times for frequently-accessed data. The platform implements a multi-layer caching strategy using Redis as the primary distributed cache, with in-memory LRU caches at the application level for ultra-hot data. The caching strategy must balance performance gains with data freshness requirements — stale segment sizes or template content can cause user confusion, while slightly stale analytics data is acceptable.
Cache Architecture
Cache Targets
| Data Type | Cache Key Pattern | TTL | Invalidation |
|---|---|---|---|
| Account settings | account:{id}:settings | 1 hour | Write-through |
| Audience merge fields | audience:{id}:fields | 1 hour | Write-through |
| Template HTML | template:{id}:html:{version} | 24 hours | Version-based (immutable) |
| Segment size estimate | segment:{id}:size | 15 minutes | TTL expiration |
| Contact record | contact:{audience_id}:{email} | 5 minutes | Write-through |
| Throttle counters | throttle:{ip}:{domain} | 1 minute | Token bucket (Redis) |
| Campaign metrics (real-time) | metrics:{campaign_id}:realtime | 30 seconds | Event-driven update |
| Engagement scores | engagement:{contact_id} | 15 minutes | Batch update |
Cache Patterns
The platform uses several caching patterns depending on the data access characteristics. For account and audience settings, a write-through pattern ensures that writes update both the cache and database simultaneously, preventing stale reads. For template HTML, an immutable cache pattern stores templates by version number, so new versions create new cache entries without invalidating old ones. For segment size estimates, a TTL-based pattern allows the cache to expire naturally, with background refresh jobs keeping popular segments warm.
The most interesting caching challenge is the real-time campaign metrics counter. During a campaign send, the metrics update at thousands of events per second, but the dashboard polls for updates every 5 seconds. Writing every event to PostgreSQL immediately would create an unacceptable write load. Instead, metrics are accumulated in Redis hash counters using atomic INCRBY operations, and a periodic flush job writes the accumulated counts to PostgreSQL every 30 seconds. This approach reduces PostgreSQL write load by approximately 99% while providing near-real-time visibility into campaign performance.
Cache Eviction Strategy
Redis is configured with an allkeys-lru (Least Recently Used) eviction policy with a maximum memory limit of 75% of available RAM. The 25% headroom prevents out-of-memory errors during traffic spikes. Critical cache keys (like throttle counters and session data) are marked as "noevict" to prevent their eviction under memory pressure. The platform monitors cache hit rates through Redis INFO metrics and alerts when hit rates drop below 85%, indicating that the cache size may need to be increased or the TTL strategy adjusted.
22. Multi-Region Design
As Mailchimp serves a global user base with GDPR and data residency requirements, the platform must operate across multiple geographic regions. Multi-region deployment introduces challenges around data replication, latency optimization, regional failover, and compliance with local regulations. The architecture must ensure that users in any region receive sub-second response times while maintaining data consistency across regions.
Regional Architecture
Data Residency Strategy
Data residency determines where a user's data is physically stored. The platform classifies data into three categories: profile data (contact PII, merge fields), behavioral data (opens, clicks, engagement events), and operational data (campaign configurations, templates, automation workflows). Profile data must remain in the user's designated region. Behavioral data is stored in the same region as the profile data for consistency. Operational data is replicated globally for low-latency access from any region.
When a user signs up, the platform determines their region based on their IP geolocation and the billing address provided during plan purchase. The user's data is then pinned to that region, and all subsequent operations on their data are routed to the regional infrastructure through GeoDNS. The routing layer uses a consistent hashing algorithm based on account_id to ensure that all requests from a given account are served by the same regional cluster.
Cross-Region Replication
While profile data must remain in the home region, certain data types require cross-region replication for operational reasons. Campaign send logs are replicated to all regions so that global analytics queries can be served locally. Template content is replicated to all regions so that email rendering can happen in the recipient's closest region (reducing latency for personalization). Automation execution state is replicated to the home region only, as automation triggers are processed in the contact's home region.
Regional Send Infrastructure
The send infrastructure is deployed in each region to ensure that emails originate from IPs geographically close to the recipients. US-bound emails send from US IPs, EU-bound emails from EU IPs, and so on. This improves deliverability because ISPs prefer to receive emails from IPs in the same region as the sender domain, and reduces latency for tracking pixel requests and click redirects.
| Region | IP Ranges | SMTP Infrastructure | Capacity |
|---|---|---|---|
| US (us-east-1) | /24 blocks (256 IPs x 3) | Own infrastructure + SendGrid | 8B emails/month |
| EU (eu-west-1) | /24 blocks (256 IPs x 2) | Own infrastructure + Mailgun | 4B emails/month |
| APAC (ap-southeast-1) | /25 blocks (128 IPs x 1) | Relay provider | 2B emails/month |
The multi-region architecture also supports compliance with data sovereignty requirements beyond GDPR, such as Brazil's LGPD, India's DPDP Act, and China's PIPL, as the platform expands to new markets. Each new region follows the same deployment pattern: dedicated infrastructure, regional data storage, and controlled replication for operational data only.
23. Cost Estimation
Understanding the cost structure of a Mailchimp-like platform is essential for architectural decision-making. Many design choices — whether to build own sending infrastructure or use a relay provider, whether to use managed databases or self-host, whether to run analytics on Elasticsearch or a purpose-built data warehouse — are ultimately driven by cost-per-email and cost-per-contact economics.
Monthly Infrastructure Cost Breakdown
| Category | Component | Monthly Cost | Notes |
|---|---|---|---|
| Email Sending | SMTP relay fees (14B emails) | $1,400,000 | $0.10 per 1,000 emails |
| Email Sending | Dedicated IPs (5,000 IPs) | $300,000 | $60/IP/month average |
| Email Sending | Send worker compute (500 instances) | $800,000 | c5.2xlarge equivalent |
| Compute | Application servers (200 instances) | $500,000 | m5.xlarge equivalent |
| Compute | Background workers (100 instances) | $200,000 | c5.xlarge equivalent |
| Database | PostgreSQL cluster (50 nodes) | $400,000 | r5.4xlarge with provisioned IOPS |
| Cache | Redis cluster (20 nodes) | $100,000 | r6g.xlarge instances |
| Search | Elasticsearch cluster (30 nodes) | $300,000 | i3.2xlarge instances |
| Storage | S3 (55TB) | $15,000 | $0.023/GB/month |
| Storage | EBS volumes | $100,000 | gp3 and io2 volumes |
| Network | Data transfer | $200,000 | Cross-region and internet |
| CDN | CloudFront (static assets) | $50,000 | Dashboard + template previews |
| Monitoring | Datadog + PagerDuty | $50,000 | APM, logs, alerts |
| Total Monthly Infrastructure | $4,415,000 | ||
Cost Per Email Analysis
At $4.4M monthly infrastructure cost and 14B emails sent, the fully-loaded cost per email is approximately $0.0003 (0.03 cents). The sending relay alone accounts for 32% of total costs. This economic reality drives the build-vs-buy decision: building proprietary sending infrastructure can reduce per-email costs by 60-80% compared to relay providers, but requires a team of 10-20 infrastructure engineers and significant operational investment.
Revenue Per Email Economics
Mailchimp generates approximately $1.3B in annual revenue, or ~$108M/month. Against $4.4M in infrastructure costs, this yields a gross margin of approximately 96%. The platform's pricing model charges based on audience size and send volume: the Standard plan ($20/month for 500 contacts) scales to $1,295/month for 50,000 contacts. The revenue per email averages approximately $0.000008, meaning the infrastructure cost per email ($0.0003) is roughly 37x lower than the revenue per email — a healthy margin that funds R&D, sales, and marketing.
Cost Optimization Strategies
- Reserved instances: 70% of compute is on 1-year reserved instances, reducing EC2 costs by 40%.
- Spot instances: Background workers and batch analytics run on spot instances, reducing those costs by 70%.
- Storage tiering: Analytics data older than 12 months is moved to S3 Glacier, reducing storage costs by 90%.
- Read replicas: Heavy analytical queries are routed to dedicated read replicas, preventing contention with transactional workloads.
- Compression: Event data is compressed using Snappy before storage, reducing storage volume by 60%.
24. Interview Q&A
The following questions cover the most commonly asked system design interview topics related to email marketing platforms. Each answer highlights the key architectural concepts and tradeoffs that demonstrate senior-level understanding.
Q1: How would you design the email sending pipeline to handle 14 billion emails per month?
The sending pipeline uses a three-tier architecture: a scheduler that manages campaign timing and timezone distribution, a throttling layer that enforces per-ISP and per-IP rate limits, and a pool of SMTP send workers that establish connections and transmit emails. Campaigns are decomposed into individual send jobs partitioned by recipient domain in Kafka, ensuring that sends to each ISP are processed by dedicated consumer groups with independent rate limits. The throttle manager uses a token bucket algorithm in Redis, with adaptive rate reduction triggered by bounce handler feedback.
Q2: How would you design the drag-and-drop email builder?
The builder uses a block-based document model where emails are serialized as JSON trees of typed blocks (text, image, button, columns). The frontend renders these blocks in a React-based editor with drag-and-drop reordering. A separate rendering pipeline converts the block tree to email-safe HTML using table-based layouts, inlines all CSS, and adds Outlook VML fallbacks. The block tree and rendered HTML are stored separately — the block tree for editor re-opening and the HTML for campaign sending. Auto-save persists the block tree to Redis every 10 seconds.
Q3: How would you handle email deliverability at scale?
Deliverability requires a three-layer defense. First, authentication: every sending domain must have valid SPF, DKIM, and DMARC records, and the platform monitors DNS to detect misconfigurations. Second, reputation: IPs are grouped into warm-up, shared, and dedicated tiers, with automated IP removal triggered when bounce or complaint rates exceed thresholds. Third, throttling: the sending pipeline enforces per-domain rate limits using an adaptive token bucket algorithm that responds to real-time bounce signals within 30 seconds. Additionally, list hygiene is enforced through automated email validation at import time and periodic cleaning of disengaged contacts.
Q4: How would you design the segmentation engine to filter millions of contacts in real time?
The segmentation engine translates JSON condition trees into SQL queries against the PostgreSQL contacts table. Performance is achieved through three strategies: (1) denormalized engagement fields on the contact record that are updated by a batch job every 15 minutes, avoiding expensive JOINs with the events table; (2) materialized segment results for frequently-used segments, refreshed every 5-15 minutes; and (3) Elasticsearch for complex behavioral queries that require full-text search or aggregation. The query planner uses field cardinality statistics and partition pruning to optimize query plans.
Q5: How would you design the automation workflow engine?
The automation engine is built as an event-driven state machine. Each subscriber in a workflow is tracked by an execution record storing the current step and timestamp. Triggers subscribe to Kafka events (contact-created, email-opened) and create execution records when conditions are met. Delays use Kafka's delayed message delivery feature with timestamp-based partitioning. Conditions evaluate subscriber data at execution time and route to appropriate branches. The engine scales by partitioning execution state by automation_id, allowing different partitions to be processed by independent consumer groups.
Q6: How would you handle GDPR data erasure requests?
Data erasure requires a cascading anonymization process across multiple tables. When an erasure is requested, the system: (1) anonymizes the email address in the contacts table, (2) replaces email references in campaign send logs with hashes, (3) anonymizes engagement events while preserving aggregate metrics, (4) removes the contact from all active segments and automations, and (5) records the erasure in an audit log for compliance reporting. The entire process must complete within the legally mandated timeframe and is orchestrated as a saga with compensating transactions for each step.
Q7: How would you design the A/B testing framework?
A/B testing splits the audience deterministically using a hash of contact_id modulo the number of variants, ensuring consistent assignment even if the contact list changes. Test variants are sent simultaneously to the sample groups, and engagement is measured over a configurable window. Winner selection uses a two-proportion z-test with 95% confidence level. If no winner is found, the default variant is sent to the remainder. For send-time testing, the system measures engagement across time windows and applies Bayesian optimization for ongoing optimization.
Q8: How would you handle the thundering herd problem when restarting cache nodes?
The thundering herd occurs when cold caches cause thousands of simultaneous database queries. The mitigation strategy has three components: (1) cache warming — a pre-deployment process that pre-populates the top 10,000 most-accessed keys; (2) request coalescing — when multiple requests for the same uncached key arrive simultaneously, only one database query is executed and the result is shared; (3) staggered restarts — cache nodes are restarted one at a time with a 30-second delay between nodes, allowing each node to warm before the next restart.
Q9: How would you implement Timewarp (timezone-based send optimization)?
Timewarp groups recipients by their timezone (derived from IP geolocation or stored on the contact record), calculates the equivalent UTC send time for each timezone group (e.g., 9 AM ET = 1 PM UTC, 9 AM PT = 4 PM UTC), and creates separate send batches for each timezone group with staggered UTC timestamps. The scheduler picks up each batch at its designated UTC time. The key challenge is handling timezone edge cases: DST transitions, contacts with ambiguous timezone data, and accounts that span multiple timezones.
Q10: How would you design the analytics pipeline to handle 500 million events per day?
The analytics pipeline uses a lambda architecture with two parallel paths. The real-time path ingests events through Kafka Streams, updating Redis counters for live dashboard metrics with 30-second granularity. The batch path uses scheduled Spark jobs to aggregate events into hourly rollups stored in TimescaleDB. Both paths write to Elasticsearch for ad-hoc querying. Event deduplication uses a bloom filter in Redis to handle duplicate tracking pixel loads. The pipeline is designed for at-least-once delivery, with idempotent event processors ensuring that duplicate events do not inflate metrics.
Q11: How would you prevent a single abusive account from affecting platform-wide deliverability?
Multi-layer abuse prevention starts at signup with email validation and account verification. During operation, automated monitoring tracks per-account metrics: bounce rate, complaint rate, engagement rate, and sending velocity. Accounts exceeding any threshold are automatically throttled, then escalated to manual review. The sending pipeline enforces per-account send limits using Redis token buckets. Additionally, the IP allocation service ensures that abusive accounts' sends are distributed across the platform's IP pool rather than concentrated on specific IPs, limiting reputation damage to any single IP.
25. Full C# Implementation
The following C# implementation demonstrates the core components of an email marketing platform: the campaign service, email builder, segmentation engine, deliverability checker, and automation workflow engine. This is a production-grade implementation that illustrates the architectural patterns discussed throughout this article.
Campaign Service
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace EmailMarketingPlatform.Services
{
public class CampaignService
{
private readonly ICampaignRepository _repository;
private readonly IAudienceService _audienceService;
private readonly ISegmentationEngine _segmentationEngine;
private readonly ITemplateRenderer _templateRenderer;
private readonly IKafkaProducer _kafkaProducer;
private readonly IClock _clock;
public CampaignService(
ICampaignRepository repository,
IAudienceService audienceService,
ISegmentationEngine segmentationEngine,
ITemplateRenderer templateRenderer,
IKafkaProducer kafkaProducer,
IClock clock)
{
_repository = repository;
_audienceService = audienceService;
_segmentationEngine = segmentationEngine;
_templateRenderer = templateRenderer;
_kafkaProducer = kafkaProducer;
_clock = clock;
}
public async Task<Campaign> CreateCampaignAsync(CreateCampaignRequest request)
{
var campaign = new Campaign
{
Id = Guid.NewGuid(),
AccountId = request.AccountId,
AudienceId = request.AudienceId,
TemplateId = request.TemplateId,
Title = request.Title,
SubjectLine = request.SubjectLine,
PreviewText = request.PreviewText,
FromName = request.FromName,
ReplyTo = request.ReplyTo,
Type = request.Type,
Status = CampaignStatus.Draft,
Settings = request.Settings,
Recipients = request.Recipients,
CreatedAt = _clock.UtcNow,
UpdatedAt = _clock.UtcNow
};
await _repository.SaveCampaignAsync(campaign);
await _kafkaProducer.PublishAsync("campaign-events", new CampaignEvent
{
EventType = "campaign.created",
CampaignId = campaign.Id,
AccountId = campaign.AccountId,
Timestamp = _clock.UtcNow
});
return campaign;
}
public async Task<CampaignSendResult> SendCampaignAsync(
Guid campaignId, Guid accountId)
{
var campaign = await _repository.GetCampaignAsync(campaignId);
if (campaign == null)
throw new CampaignNotFoundException(campaignId);
if (campaign.AccountId != accountId)
throw new UnauthorizedAccessException();
if (campaign.Status != CampaignStatus.Draft &&
campaign.Status != CampaignStatus.Scheduled)
throw new InvalidCampaignStateException(
$"Cannot send campaign in {campaign.Status} status");
var audience = await _audienceService.GetAudienceAsync(campaign.AudienceId);
var recipientCount = campaign.Recipients.SegmentId.HasValue
? await _segmentationEngine.GetSegmentSizeAsync(
campaign.Recipients.SegmentId.Value)
: audience.ContactCount;
campaign.Status = CampaignStatus.Sending;
campaign.SentAt = _clock.UtcNow;
await _repository.SaveCampaignAsync(campaign);
await _kafkaProducer.PublishAsync("campaign-send-jobs", new CampaignSendJob
{
CampaignId = campaign.Id,
AccountId = accountId,
AudienceId = campaign.AudienceId,
SegmentId = campaign.Recipients.SegmentId,
TemplateId = campaign.TemplateId,
SubjectLine = campaign.SubjectLine,
FromName = campaign.FromName,
ReplyTo = campaign.ReplyTo,
EstimatedRecipients = recipientCount,
ScheduledAt = _clock.UtcNow,
DomainPartitioned = true
});
return new CampaignSendResult
{
CampaignId = campaign.Id,
Status = "queued",
EstimatedRecipients = recipientCount
};
}
public async Task<CampaignReport> GetReportAsync(Guid campaignId)
{
var campaign = await _repository.GetCampaignAsync(campaignId);
var metrics = await _repository.GetCampaignMetricsAsync(campaignId);
return new CampaignReport
{
CampaignId = campaignId,
Title = campaign.Title,
Status = campaign.Status,
SentAt = campaign.SentAt,
EmailsSent = metrics.EmailsSent,
EmailsDelivered = metrics.EmailsDelivered,
OpenRate = metrics.OpenRate,
ClickRate = metrics.ClickRate,
BounceRate = metrics.BounceRate,
UnsubscribeRate = metrics.UnsubscribeRate,
SpamComplaintRate = metrics.SpamComplaintRate,
Revenue = metrics.Revenue,
UniqueOpens = metrics.UniqueOpens,
UniqueClicks = metrics.UniqueClicks
};
}
}
}
Email Builder - Block Renderer
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
namespace EmailMarketingPlatform.Builder
{
public class EmailBlockRenderer
{
private readonly CssInliner _cssInliner;
private readonly OutlookCompatLayer _outlookCompat;
public EmailBlockRenderer(CssInliner cssInliner, OutlookCompatLayer outlookCompat)
{
_cssInliner = cssInliner;
_outlookCompat = outlookCompat;
}
public string RenderToHtml(EmailDocument document, Contact recipient,
Dictionary<string, object> mergeData)
{
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html><head><meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width\">");
sb.AppendLine("</head><body style=\"margin:0;padding:0;background:#f4f4f4\">");
sb.AppendLine("<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\">");
sb.AppendLine("<tr><td align=\"center\">");
sb.AppendLine("<table role=\"presentation\" width=\"600\" cellpadding=\"0\" cellspacing=\"0\" " +
"style=\"background:#ffffff;max-width:600px;width:100%\">");
foreach (var block in document.Blocks)
{
sb.Append(RenderBlock(block, recipient, mergeData));
}
sb.AppendLine("</table></td></tr></table></body></html>");
string html = sb.ToString();
html = _cssInliner.InlineAllStyles(html);
html = _outlookCompat.AddConditionalComments(html);
html = ReplaceMergeFields(html, recipient, mergeData);
return html;
}
private string RenderBlock(EmailBlock block, Contact recipient,
Dictionary<string, object> mergeData)
{
return block.Type switch
{
"heading" => RenderHeading(block),
"text" => RenderText(block),
"image" => RenderImage(block),
"button" => RenderButton(block),
"divider" => RenderDivider(block),
"columns" => RenderColumns(block, recipient, mergeData),
"social" => RenderSocialLinks(block),
"dynamic" => RenderDynamic(block, recipient, mergeData),
_ => "<!-- Unknown block type -->"
};
}
private string RenderHeading(EmailBlock block)
{
var text = block.Properties.GetValueOrDefault("text", "").ToString();
var level = block.Properties.GetValueOrDefault("level", 1);
var style = FormatStyle(block.Style);
return $@"<table role=""presentation"" width=""100%"" cellpadding=""0"" cellspacing=""0"">
<tr><td style=""{style}"">
<h{level} style=""margin:0;font-family:Arial,sans-serif;color:#333333"">{text}</h{level}>
</td></tr></table>";
}
private string RenderText(EmailBlock block)
{
var content = block.Properties.GetValueOrDefault("content", "").ToString();
var style = FormatStyle(block.Style);
return $@"<table role=""presentation"" width=""100%"" cellpadding=""0"" cellspacing=""0"">
<tr><td style=""font-family:Georgia,serif;font-size:16px;line-height:24px;
color:#555555;{style}"">
{content}
</td></tr></table>";
}
private string RenderImage(EmailBlock block)
{
var src = block.Properties.GetValueOrDefault("src", "").ToString();
var alt = block.Properties.GetValueOrDefault("alt", "").ToString();
var width = block.Properties.GetValueOrDefault("width", "100%").ToString();
return $@"<table role=""presentation"" width=""100%"" cellpadding=""0"" cellspacing=""0"">
<tr><td align=""center"" style=""padding:10px 0"">
<img src=""{src}"" alt=""{alt}"" width=""{width}""
style=""display:block;max-width:100%;height:auto;border:0"" />
</td></tr></table>";
}
private string RenderButton(EmailBlock block)
{
var label = block.Properties.GetValueOrDefault("label", "Click Here").ToString();
var url = block.Properties.GetValueOrDefault("url", "#").ToString();
var bgColor = block.Properties.GetValueOrDefault("backgroundColor", "#0088ff").ToString();
var textColor = block.Properties.GetValueOrDefault("textColor", "#ffffff").ToString();
return $@"<table role=""presentation"" width=""100%"" cellpadding=""0"" cellspacing=""0"">
<tr><td align=""center"" style=""padding:20px 0"">
<!--[if mso]><v:roundrect xmlns:v=""urn:schemas-microsoft-com:vml""
href=""{url}"" style=""height:48px;v-text-anchor:middle;width:220px""
arcsize=""10%"" strokecolor=""{bgColor}"" fillcolor=""{bgColor}"">
<w:anchorlock/><center style=""color:{textColor};font-family:Arial,sans-serif;
font-size:16px;font-weight:bold"">{label}</center></v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href=""{url}"" style=""background-color:{bgColor};color:{textColor};
padding:14px 40px;text-decoration:none;border-radius:6px;
display:inline-block;font-family:Arial,sans-serif;font-size:16px;
font-weight:bold"">{label}</a>
<!--<![endif]-->
</td></tr></table>";
}
private string RenderDivider(EmailBlock block)
{
var color = block.Properties.GetValueOrDefault("color", "#e0e0e0").ToString();
return $@"<table role=""presentation"" width=""100%"" cellpadding=""0"" cellspacing=""0"">
<tr><td style=""padding:10px 40px"">
<hr style=""border:none;border-top:1px solid {color}"" />
</td></tr></table>";
}
private string RenderColumns(EmailBlock block, Contact recipient,
Dictionary<string, object> mergeData)
{
var sb = new StringBuilder();
sb.AppendLine("<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\">");
sb.AppendLine("<tr>");
foreach (var child in block.Children)
{
int width = block.Children.Count == 2 ? 50 :
(100 / block.Children.Count);
sb.AppendLine($"<td width=\"{width}%\" valign=\"top\" style=\"padding:10px\">");
sb.Append(RenderBlock(child, recipient, mergeData));
sb.AppendLine("</td>");
}
sb.AppendLine("</tr></table>");
return sb.ToString();
}
private string RenderSocialLinks(EmailBlock block)
{
var platforms = block.Properties.GetValueOrDefault("platforms",
new List<string> { "facebook", "twitter", "instagram" }) as List<string>;
var sb = new StringBuilder();
sb.AppendLine("<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\">");
sb.AppendLine("<tr><td align=\"center\" style=\"padding:20px\">");
foreach (var platform in platforms)
{
var url = block.Properties.GetValueOrDefault($"{platform}_url", "#").ToString();
sb.AppendLine($"<a href=\"{url}\" style=\"margin:0 10px\">{platform.ToUpper()}</a>");
}
sb.AppendLine("</td></tr></table>");
return sb.ToString();
}
private string RenderDynamic(EmailBlock block, Contact recipient,
Dictionary<string, object> mergeData)
{
var condition = block.Properties["condition"]?.ToString();
var trueContent = block.Properties["trueContent"] as EmailBlock;
var falseContent = block.Properties["falseContent"] as EmailBlock;
bool result = EvaluateCondition(condition, recipient, mergeData);
var activeBlock = result ? trueContent : falseContent;
return activeBlock != null ? RenderBlock(activeBlock, recipient, mergeData) : "";
}
private bool EvaluateCondition(string condition, Contact recipient,
Dictionary<string, object> mergeData)
{
if (string.IsNullOrEmpty(condition)) return true;
var evaluator = new ConditionEvaluator();
return evaluator.Evaluate(condition, recipient, mergeData);
}
private string ReplaceMergeFields(string html, Contact recipient,
Dictionary<string, object> mergeData)
{
var pattern = @"\*\|(\w+)\|\*";
return Regex.Replace(html, pattern, match =>
{
var fieldName = match.Groups[1].Value;
if (mergeData.TryGetValue(fieldName, out var value))
return value?.ToString() ?? "";
if (fieldName == "EMAIL") return recipient.EmailAddress;
if (fieldName == "FIRST_NAME") return recipient.FirstName ?? "";
if (fieldName == "LAST_NAME") return recipient.LastName ?? "";
return "";
});
}
private string FormatStyle(BlockStyle style)
{
var parts = new List<string>();
if (!string.IsNullOrEmpty(style.BackgroundColor))
parts.Add($"background-color:{style.BackgroundColor}");
if (!string.IsNullOrEmpty(style.Padding))
parts.Add($"padding:{style.Padding}");
if (!string.IsNullOrEmpty(style.TextAlign))
parts.Add($"text-align:{style.TextAlign}");
if (!string.IsNullOrEmpty(style.BorderRadius))
parts.Add($"border-radius:{style.BorderRadius}");
return string.Join(";", parts);
}
}
}
Segmentation Engine
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace EmailMarketingPlatform.Segmentation
{
public class SegmentationEngine
{
private readonly IDatabaseConnection _db;
private readonly ICacheService _cache;
private readonly IElasticSearchClient _searchClient;
public SegmentationEngine(IDatabaseConnection db, ICacheService cache,
IElasticSearchClient searchClient)
{
_db = db;
_cache = cache;
_searchClient = searchClient;
}
public async Task<int> GetSegmentSizeAsync(Guid segmentId)
{
var cacheKey = $"segment:{segmentId}:size";
var cached = await _cache.GetAsync<int?>(cacheKey);
if (cached.HasValue) return cached.Value;
var segment = await _db.QuerySingleAsync<Segment>(
"SELECT * FROM segments WHERE id = @Id", new { Id = segmentId });
string sql = BuildSegmentQuery(segment, countOnly: true);
int count = await _db.QuerySingleAsync<int>(sql, segment.Parameters);
await _cache.SetAsync(cacheKey, count, TimeSpan.FromMinutes(15));
return count;
}
public async Task<List<Guid>> GetSegmentMembersAsync(Guid segmentId,
int offset = 0, int limit = 1000)
{
var segment = await _db.QuerySingleAsync<Segment>(
"SELECT * FROM segments WHERE id = @Id", new { Id = segmentId });
if (segment.IsStatic)
{
return (await _db.QueryAsync<Guid>(
"SELECT contact_id FROM segment_members " +
"WHERE segment_id = @SegmentId ORDER BY contact_id " +
"OFFSET @Offset LIMIT @Limit",
new { SegmentId = segmentId, Offset = offset, Limit = limit }
)).ToList();
}
string sql = BuildSegmentQuery(segment, countOnly: false);
sql += $" ORDER BY c.id OFFSET {offset} LIMIT {limit}";
return (await _db.QueryAsync<Guid>(sql, segment.Parameters)).ToList();
}
private string BuildSegmentQuery(Segment segment, bool countOnly)
{
var sb = new StringBuilder();
var parameters = new Dictionary<string, object>();
if (countOnly)
sb.Append("SELECT COUNT(*) FROM contacts c WHERE c.audience_id = @AudienceId");
else
sb.Append("SELECT c.id FROM contacts c WHERE c.audience_id = @AudienceId");
parameters["AudienceId"] = segment.AudienceId;
if (segment.Conditions != null)
{
var whereClause = TranslateConditionTree(
segment.Conditions, "c", parameters);
if (!string.IsNullOrEmpty(whereClause))
sb.Append($" AND ({whereClause})");
}
segment.Parameters = parameters;
return sb.ToString();
}
private string TranslateConditionTree(SegmentRule rule, string tableAlias,
Dictionary<string, object> parameters)
{
if (rule.Condition != null)
{
return TranslateCondition(rule.Condition, tableAlias, parameters);
}
var childClauses = new List<string>();
foreach (var child in rule.Children)
{
var childSql = TranslateConditionTree(child, tableAlias, parameters);
if (!string.IsNullOrEmpty(childSql))
childClauses.Add($"({childSql})");
}
if (childClauses.Count == 0) return "";
string joiner = rule.Logic == "OR" ? " OR " : " AND ";
if (rule.Logic == "NOT")
return $"NOT ({string.Join(" AND ", childClauses)})";
return string.Join(joiner, childClauses);
}
private string TranslateCondition(SegmentCondition condition, string tableAlias,
Dictionary<string, object> parameters)
{
string paramName = $"@p{parameters.Count}";
string column = ResolveColumn(condition.Field, tableAlias);
return condition.Operator.ToLower() switch
{
"equals" => $"{column} = {paramName}",
"not_equals" => $"{column} != {paramName}",
"contains" => $"{column} ILIKE {paramName}",
"not_contains" => $"NOT ({column} ILIKE {paramName})",
"starts_with" => $"{column} ILIKE {paramName} || '%'",
"ends_with" => $"{column} ILIKE '%' || {paramName}",
"greater_than" => $"{column} > {paramName}",
"less_than" => $"{column} < {paramName}",
"greater_or_equal" => $"{column} >= {paramName}",
"less_or_equal" => $"{column} <= {paramName}",
"before" => $"{column} < {paramName}",
"after" => $"{column} > {paramName}",
"in" => $"{column} = ANY({paramName})",
"not_in" => $"NOT ({column} = ANY({paramName}))",
"is_empty" => $"({column} IS NULL OR {column} = '')",
"is_not_empty" => $"({column} IS NOT NULL AND {column} != '')",
_ => throw new NotSupportedException(
$"Operator '{condition.Operator}' is not supported")
};
}
private string ResolveColumn(string field, string tableAlias)
{
if (field.StartsWith("merge_fields."))
{
string key = field.Replace("merge_fields.", "");
return $"{tableAlias}.merge_fields->'{key}'";
}
if (field.StartsWith("location."))
{
string key = field.Replace("location.", "");
return $"{tableAlias}.location->'{key}'";
}
if (field == "tags")
return $"{tableAlias}.id";
if (field == "engagement_score")
return $"{tableAlias}.engagement_score";
if (field == "status")
return $"{tableAlias}.status";
if (field == "signup_timestamp")
return $"{tableAlias}.signup_timestamp";
if (field == "email_address")
return $"{tableAlias}.email_address";
return $"{tableAlias}.merge_fields->>'{field}'";
}
}
}
Deliverability Checker
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Mail;
using System.Threading.Tasks;
using DnsClient;
namespace EmailMarketingPlatform.Deliverability
{
public class DeliverabilityChecker
{
private readonly IDnsQueryService _dnsService;
private readonly IBlacklistMonitor _blacklistMonitor;
private readonly ILogger _logger;
public DeliverabilityChecker(IDnsQueryService dnsService,
IBlacklistMonitor blacklistMonitor, ILogger logger)
{
_dnsService = dnsService;
_blacklistMonitor = blacklistMonitor;
_logger = logger;
}
public async Task<DomainAuthResult> CheckDomainAuthenticationAsync(
string domain, string sendingIp)
{
var result = new DomainAuthResult { Domain = domain };
// Check SPF
result.SpfRecord = await _dnsService.GetTxtRecordsAsync(domain);
result.SpfPass = ValidateSpf(result.SpfRecord, sendingIp);
// Check DKIM
var dkimSelector = "default";
var dkimDomain = $"{dkimSelector}._domainkey.{domain}";
result.DkimKey = await _dnsService.GetTxtRecordsAsync(dkimDomain);
result.DkimPass = result.DkimKey.Count > 0;
// Check DMARC
result.DmarcRecord = await _dnsService.GetTxtRecordsAsync(
$"_dmarc.{domain}");
result.DmarcPolicy = ParseDmarcPolicy(result.DmarcRecord);
// Check MX records
result.MxRecords = await _dnsService.GetMxRecordsAsync(domain);
result.HasMxRecords = result.MxRecords.Count > 0;
// Check if sending IP is blacklisted
result.BlacklistStatus = await _blacklistMonitor
.CheckIpAsync(IPAddress.Parse(sendingIp));
result.OverallScore = CalculateScore(result);
return result;
}
public async Task<ContactValidationResult> ValidateContactAsync(
string emailAddress)
{
var result = new ContactValidationResult
{
EmailAddress = emailAddress,
IsValid = false
};
// Step 1: Format validation
if (!IsValidEmailFormat(emailAddress))
{
result.Errors.Add("Invalid email format");
result.Severity = ValidationSeverity.Critical;
return result;
}
// Step 2: Domain validation
var domain = emailAddress.Split('@')[1];
var mxRecords = await _dnsService.GetMxRecordsAsync(domain);
if (mxRecords.Count == 0)
{
result.Errors.Add("Domain has no MX records");
result.Severity = ValidationSeverity.Critical;
return result;
}
// Step 3: Disposable email check
if (IsDisposableDomain(domain))
{
result.Errors.Add("Disposable email address detected");
result.Severity = ValidationSeverity.High;
return result;
}
// Step 4: Role account check
var localPart = emailAddress.Split('@')[0];
if (IsRoleAccount(localPart))
{
result.Warnings.Add("This appears to be a role-based email address");
result.Severity = ValidationSeverity.Medium;
}
// Step 5: SMTP verification
try
{
var mxHost = mxRecords[0].Exchange;
using var client = new SmtpClient(mxHost, 25);
client.Timeout = 5000;
client.EnableSsl = false;
var testMessage = new MailMessage(
"verify@test.com", emailAddress, "Test", "Test");
// Note: In production, we use raw SMTP RCPT TO
// without actually sending
result.SmtpVerified = true;
}
catch (SmtpException ex)
{
result.Errors.Add($"SMTP verification failed: {ex.Message}");
result.Severity = ValidationSeverity.High;
}
result.IsValid = !result.Errors.Any(e =>
e.Severity == ValidationSeverity.Critical ||
e.Severity == ValidationSeverity.High);
return result;
}
public async Task<SendingRecommendation> GetSendingRecommendationAsync(
string domain, int volume)
{
var result = await CheckDomainAuthenticationAsync(domain, "0.0.0.0");
var recommendation = new SendingRecommendation { Domain = domain };
if (!result.SpfPass)
{
recommendation.Actions.Add("Configure SPF record before sending");
recommendation.MaxRecommendedVolume = 0;
}
else if (!result.DkimPass)
{
recommendation.Actions.Add("Configure DKIM signing before sending");
recommendation.MaxRecommendedVolume = 1000;
}
else if (result.DmarcPolicy == "reject")
{
recommendation.Actions.Add("DMARC policy is reject — verify alignment");
recommendation.MaxRecommendedVolume = 5000;
}
else if (result.BlacklistStatus.Listed)
{
recommendation.Actions.Add(
$"IP is blacklisted on {result.BlacklistStatus.Blacklists}");
recommendation.MaxRecommendedVolume = 0;
}
else
{
recommendation.MaxRecommendedVolume = volume;
recommendation.Actions.Add("Domain authentication looks good");
}
return recommendation;
}
private bool ValidateSpf(List<string> txtRecords, string sendingIp)
{
foreach (var record in txtRecords)
{
if (!record.StartsWith("v=spf1")) continue;
var parts = record.Split(' ');
foreach (var part in parts)
{
if (part == "include:_spf.google.com" &&
IsGoogleIp(sendingIp))
return true;
if (part.StartsWith("ip4:") &&
sendingIp.StartsWith(part.Replace("ip4:", "").Split('/')[0]))
return true;
if (part == "~all" || part == "-all") continue;
}
}
return false;
}
private string ParseDmarcPolicy(List<string> records)
{
foreach (var record in records)
{
if (!record.StartsWith("v=DMARC1")) continue;
var parts = record.Split(';');
foreach (var part in parts)
{
var trimmed = part.Trim();
if (trimmed.StartsWith("p="))
return trimmed.Substring(2);
}
}
return "none";
}
private bool IsValidEmailFormat(string email)
{
try
{
var addr = new MailAddress(email);
return addr.Address == email;
}
catch { return false; }
}
private bool IsDisposableDomain(string domain)
{
var disposableDomains = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"tempmail.com", "throwaway.email", "guerrillamail.com",
"mailinator.com", "yopmail.com", "trashmail.com",
"10minutemail.com", "temp-mail.org", "fakeinbox.com"
};
return disposableDomains.Contains(domain);
}
private bool IsRoleAccount(string localPart)
{
var roleAccounts = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"admin", "info", "support", "sales", "contact",
"webmaster", "postmaster", "hostmaster", "abuse", "noreply"
};
return roleAccounts.Contains(localPart);
}
private bool IsGoogleIp(string ip)
{
return ip.StartsWith("209.85.") || ip.StartsWith("172.217.") ||
ip.StartsWith("74.125.") || ip.StartsWith("64.233.");
}
private double CalculateScore(DomainAuthResult result)
{
double score = 0;
if (result.SpfPass) score += 25;
if (result.DkimPass) score += 25;
if (result.DmarcPolicy != "none") score += 20;
if (result.HasMxRecords) score += 15;
if (!result.BlacklistStatus.Listed) score += 15;
return score;
}
}
}
Automation Workflow Engine
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace EmailMarketingPlatform.Automation
{
public class AutomationEngine
{
private readonly IAutomationRepository _repository;
private readonly IKafkaProducer _kafkaProducer;
private readonly ISegmentationEngine _segmentationEngine;
private readonly IClock _clock;
public AutomationEngine(
IAutomationRepository repository,
IKafkaProducer kafkaProducer,
ISegmentationEngine segmentationEngine,
IClock clock)
{
_repository = repository;
_kafkaProducer = kafkaProducer;
_segmentationEngine = segmentationEngine;
_clock = clock;
}
public async Task<Automation> CreateAutomationAsync(
CreateAutomationRequest request)
{
var automation = new Automation
{
Id = Guid.NewGuid(),
AccountId = request.AccountId,
AudienceId = request.AudienceId,
Name = request.Name,
Status = AutomationStatus.Paused,
TriggerConfig = request.TriggerConfig,
WorkflowSteps = request.WorkflowSteps,
TotalEntries = 0,
TotalExits = 0,
CreatedAt = _clock.UtcNow
};
await _repository.SaveAutomationAsync(automation);
return automation;
}
public async Task StartAutomationAsync(Guid automationId)
{
var automation = await _repository.GetAutomationAsync(automationId);
if (automation == null)
throw new AutomationNotFoundException(automationId);
automation.Status = AutomationStatus.Started;
await _repository.SaveAutomationAsync(automation);
await _kafkaProducer.PublishAsync("automation-events", new AutomationEvent
{
EventType = "automation.started",
AutomationId = automationId,
Timestamp = _clock.UtcNow
});
}
public async Task<AutomationExecutionResult> ProcessTriggerAsync(
Guid automationId, string triggerType, Guid contactId)
{
var automation = await _repository.GetAutomationAsync(automationId);
if (automation?.Status != AutomationStatus.Started)
return new AutomationExecutionResult { Processed = false };
// Check entry criteria
if (!await EvaluateEntryCriteriaAsync(automation, contactId))
return new AutomationExecutionResult { Processed = false };
// Create execution record
var execution = new AutomationExecution
{
Id = Guid.NewGuid(),
AutomationId = automationId,
ContactId = contactId,
CurrentStepIndex = 0,
Status = ExecutionStatus.Active,
EnteredAt = _clock.UtcNow,
LastProcessedAt = _clock.UtcNow
};
await _repository.SaveExecutionAsync(execution);
automation.TotalEntries++;
await _repository.SaveAutomationAsync(automation);
// Process the first step
await ProcessStepAsync(execution, automation);
return new AutomationExecutionResult
{
Processed = true,
ExecutionId = execution.Id
};
}
public async Task ProcessStepAsync(AutomationExecution execution,
Automation automation)
{
var steps = automation.WorkflowSteps;
if (execution.CurrentStepIndex >= steps.Count)
{
execution.Status = ExecutionStatus.Completed;
execution.CompletedAt = _clock.UtcNow;
await _repository.SaveExecutionAsync(execution);
automation.TotalExits++;
await _repository.SaveAutomationAsync(automation);
return;
}
var currentStep = steps[execution.CurrentStepIndex];
switch (currentStep.Type)
{
case StepType.SendEmail:
await ProcessSendEmailStep(execution, currentStep);
execution.CurrentStepIndex++;
execution.LastProcessedAt = _clock.UtcNow;
await _repository.SaveExecutionAsync(execution);
// Recursively process next step
await ProcessStepAsync(execution, automation);
break;
case StepType.Delay:
execution.Status = ExecutionStatus.Waiting;
execution.NextProcessAt = _clock.UtcNow
.Add(currentStep.DelayDuration);
await _repository.SaveExecutionAsync(execution);
// Publish delayed event to Kafka
await _kafkaProducer.PublishDelayedAsync(
"automation-delayed",
new AutomationDelayedEvent
{
ExecutionId = execution.Id,
AutomationId = automation.Id,
ProcessAfter = execution.NextProcessAt
},
execution.NextProcessAt);
break;
case StepType.Condition:
var contact = await _repository
.GetContactAsync(execution.ContactId);
bool conditionMet = await EvaluateConditionAsync(
currentStep.Condition, contact);
var nextStepIndex = conditionMet
? currentStep.TrueBranchIndex
: currentStep.FalseBranchIndex;
execution.CurrentStepIndex = nextStepIndex;
execution.LastProcessedAt = _clock.UtcNow;
await _repository.SaveExecutionAsync(execution);
await ProcessStepAsync(execution, automation);
break;
case StepType.TagContact:
await _repository.AddTagAsync(execution.ContactId,
currentStep.TagName);
execution.CurrentStepIndex++;
execution.LastProcessedAt = _clock.UtcNow;
await _repository.SaveExecutionAsync(execution);
await ProcessStepAsync(execution, automation);
break;
case StepType.End:
execution.Status = ExecutionStatus.Completed;
execution.CompletedAt = _clock.UtcNow;
await _repository.SaveExecutionAsync(execution);
automation.TotalExits++;
await _repository.SaveAutomationAsync(automation);
break;
}
}
public async Task<AutomationReport> GetReportAsync(Guid automationId)
{
var automation = await _repository.GetAutomationAsync(automationId);
var executions = await _repository
.GetExecutionsAsync(automationId);
var stepStats = await _repository
.GetStepStatsAsync(automationId);
return new AutomationReport
{
AutomationId = automationId,
Name = automation.Name,
Status = automation.Status,
TotalEntries = automation.TotalEntries,
TotalExits = automation.TotalExits,
ActiveSubscribers = executions.Count(e =>
e.Status == ExecutionStatus.Active ||
e.Status == ExecutionStatus.Waiting),
ConversionRate = automation.TotalEntries > 0
? (double)automation.TotalExits / automation.TotalEntries * 100
: 0,
StepStatistics = stepStats.Select(s => new StepStat
{
StepName = s.StepName,
Entrants = s.Entrants,
Exits = s.Exits,
ConversionRate = s.Entrants > 0
? (double)(s.Entrants - s.Exits) / s.Entrants * 100
: 0
}).ToList()
};
}
private async Task<bool> EvaluateEntryCriteriaAsync(
Automation automation, Guid contactId)
{
var trigger = automation.TriggerConfig;
var contact = await _repository.GetContactAsync(contactId);
if (contact == null) return false;
return trigger.Type switch
{
"signup" => true,
"tag_added" => contact.Tags.Contains(trigger.TagName),
"purchase" => await HasRecentPurchaseAsync(
contactId, trigger.LookbackDays),
"email_opened" => await HasOpenedEmailAsync(
contactId, trigger.CampaignId),
"link_clicked" => await HasClickedLinkAsync(
contactId, trigger.CampaignId, trigger.Url),
_ => false
};
}
private async Task<bool> EvaluateConditionAsync(
AutomationCondition condition, Contact contact)
{
if (condition == null) return true;
return condition.Type switch
{
"opened_email" => await HasOpenedEmailAsync(
contact.Id, condition.CampaignId),
"clicked_link" => await HasClickedLinkAsync(
contact.Id, condition.CampaignId, condition.Url),
"tag_exists" => contact.Tags.Contains(condition.TagName),
"merge_field" => EvaluateMergeFieldCondition(
condition, contact),
_ => true
};
}
private bool EvaluateMergeFieldCondition(AutomationCondition condition,
Contact contact)
{
var fieldValue = contact.MergeFields
.GetValueOrDefault(condition.FieldName)?.ToString();
return condition.Operator switch
{
"equals" => fieldValue == condition.ExpectedValue,
"not_equals" => fieldValue != condition.ExpectedValue,
"contains" => fieldValue?.Contains(condition.ExpectedValue) == true,
"greater_than" => double.TryParse(fieldValue, out var val) &&
val > double.Parse(condition.ExpectedValue),
_ => false
};
}
private async Task ProcessSendEmailStep(AutomationExecution execution,
AutomationStep step)
{
await _kafkaProducer.PublishAsync("automation-email-jobs",
new AutomationEmailJob
{
ExecutionId = execution.Id,
ContactId = execution.ContactId,
TemplateId = step.TemplateId,
SubjectLine = step.SubjectLine,
StepIndex = execution.CurrentStepIndex
});
}
private async Task<bool> HasRecentPurchaseAsync(Guid contactId, int days)
{
return await _repository.HasEventAsync(contactId, "purchase",
TimeSpan.FromDays(days));
}
private async Task<bool> HasOpenedEmailAsync(Guid contactId, Guid campaignId)
{
return await _repository.HasEventAsync(contactId, "open",
campaignId: campaignId);
}
private async Task<bool> HasClickedLinkAsync(Guid contactId,
Guid campaignId, string url)
{
return await _repository.HasClickEventAsync(
contactId, campaignId, url);
}
}
}
26. Conclusion
Designing an email marketing platform like Mailchimp is one of the most comprehensive system design exercises in the SaaS domain. It touches every layer of the modern technology stack: visual editors with block-based document models, real-time analytics pipelines processing billions of events, event-driven automation engines with distributed timer systems, and email sending infrastructure that must maintain delicate relationships with dozens of ISPs across the globe.
The key architectural insights from this design are: First, the sending pipeline must be completely decoupled from the user-facing application to ensure reliable, on-time delivery regardless of foreground traffic. Second, the data model must support both point lookups (contact retrieval) and complex analytical queries (segmentation) simultaneously, requiring a polyglot persistence strategy. Third, deliverability is not a feature — it is a survival requirement that permeates every component, from list hygiene at import time to per-domain throttling at send time. Fourth, compliance with GDPR, CAN-SPAM, and other regulations must be baked into the architecture, not bolted on as an afterthought.
The cost analysis reveals that email sending is the dominant infrastructure expense, with relay fees alone consuming $1.4M/month at Mailchimp's scale. This economic reality drives many platforms to build proprietary sending infrastructure as they grow, trading operational complexity for 60-80% cost reductions. For startups, the entry cost is remarkably low — under $10K/month for managed infrastructure and a relay provider — making it feasible to launch an email marketing platform with a small team.
For system design interviews, the email platform is an excellent case study because it requires you to reason about batch processing and real-time serving simultaneously, design for both throughput and latency, handle compliance constraints that affect data modeling, and make pragmatic tradeoffs between building and buying. The most important signals of senior-level thinking are: decoupling the send pipeline from the application, designing the segmentation engine for both real-time and pre-computed evaluation, implementing adaptive throttling with feedback loops, and understanding the economic drivers behind infrastructure decisions.
As email marketing continues to evolve, the platforms that succeed will be those that combine powerful automation capabilities with deep data intelligence. Machine learning models that predict optimal send times, AI-generated content that personalizes at scale, and predictive segmentation that identifies likely converters before they convert — these are the capabilities that will define the next generation of email marketing infrastructure. The foundational architecture described in this article provides the platform upon which these advanced features can be built.