system-design83 min read

How to Design a Simple & High-Deliverability Email Platform like MailerLite — A Senior+ Guide | Ayodhyya

How to Design a Simple & High-Deliverability Email Platform like MailerLite — A Senior+ Guide

Building a clean, fast email platform with high deliverability, automation, and website builder for 650K+ users

Published: July 3, 2024 Reading Time: ~35 min Ayodhyya — System Design Series

1. Introduction — Why MailerLite Dominates Simplicity

In the crowded landscape of email marketing platforms, MailerLite has carved out a remarkable niche by doing something deceptively simple — making email marketing accessible, affordable, and deliverable. With over 650,000 active users spanning 200+ countries, MailerLite processes billions of emails per month while maintaining deliverability rates that rival enterprise-grade platforms costing five times more. The platform starts at just $10/month for up to 1,000 subscribers, making it the go-to choice for small businesses, creators, and growing startups who refuse to compromise on quality.

What makes MailerLite fundamentally different from competitors like Mailchimp, ConvertKit, or ActiveCampaign is its unwavering commitment to simplicity without sacrificing power. The platform offers a clean, intuitive drag-and-drop email editor, a visual automation builder that anyone can understand, a full landing page and website builder, and advanced subscriber management — all wrapped in an interface that feels like it was designed for humans, not power users. This is not an accident. It is the result of deliberate architectural decisions that prioritize developer experience on the backend so that user experience on the frontend can be buttery smooth.

From a system design perspective, MailerLite presents a fascinating challenge. You need to build a platform that handles massive concurrency during campaign sends, maintains sub-second page loads for the editor, guarantees high deliverability through intelligent IP management, and scales website hosting for hundreds of thousands of custom sites — all while keeping operational costs low enough to offer a free tier. This is not your typical CRUD application. This is a distributed system that touches SMTP servers across the globe, manages millions of subscriber records in real time, and renders dynamic content for website visitors at CDN speed.

In this deep-dive system design article, we will reverse-engineer every major subsystem of MailerLite from the ground up. We will start with requirements gathering and capacity estimation, build out the complete data model and API layer, design the high-level architecture with detailed Mermaid diagrams, and implement core services in production-grade C#. We will cover the email editor's block-based architecture, the automation engine's state machine, the website builder's rendering pipeline, and the deliverability monitor's IP reputation scoring. By the end, you will have a comprehensive blueprint for building an email platform that can genuinely compete with MailerLite.

Who is this guide for? Senior+ engineers preparing for system design interviews, architects evaluating email platform builds, and technical founders planning to enter the email SaaS space. We assume familiarity with distributed systems, message queues, and relational databases.

MailerLite's journey from a small Lithuanian startup in 2010 to a global email powerhouse is a testament to the idea that simplicity is the ultimate sophistication. The company bootstrapped itself to profitability without raising venture capital, grew to 200+ employees, and built infrastructure that handles peak loads during Black Friday campaigns when millions of emails are sent within minutes. Their engineering blog reveals a stack built on Elixir for concurrency-critical paths, PostgreSQL for data integrity, Redis for caching, and a microservices architecture that allows independent scaling of the email sending engine, the editor, the automation system, and the website hosting platform.

The email marketing industry itself is projected to reach $17.9 billion by 2027, growing at a CAGR of 13.7%. Within this market, the affordable and simple segment is the fastest-growing category. Small businesses and creators do not need 400 features they will never use. They need a platform that sends emails that actually land in the inbox, provides clean analytics, and gets out of their way. MailerLite understood this early and built their entire architecture around three pillars: deliverability, simplicity, and affordability.

As we proceed through this design, every architectural decision we make will be evaluated against these three pillars. We will choose technologies that reduce operational complexity, design systems that optimize for inbox placement over raw throughput, and build abstractions that make the platform extensible without becoming complicated. Let us begin by understanding exactly what we need to build.

2. Functional & Non-Functional Requirements

Functional Requirements

Before writing a single line of architecture, we need to enumerate every feature the platform must support. MailerLite's feature set can be decomposed into seven major functional domains, each containing multiple subsystems that interact with one another in specific ways.

Domain 1 — Email Campaigns

  • Campaign Creation: Users must be able to create email campaigns using a visual drag-and-drop editor with pre-built blocks (text, image, button, divider, social links, video thumbnail, product card, countdown timer).
  • Email Templates: A library of 80+ responsive email templates organized by category (newsletter, promotion, welcome, announcement) that users can customize and save as reusable templates.
  • Plain-Text Mode: A fallback plain-text editor for users who want to send simple text emails without HTML formatting.
  • Schedule & Send: Campaigns can be scheduled for a future date/time or sent immediately. The system must handle timezone-aware scheduling for global audiences.
  • Send to Segments: Campaigns can target entire lists, specific segments, or manually selected subscribers.
  • Recurring Campaigns: Weekly digest or monthly newsletter campaigns that automatically regenerate and send on a schedule.

Domain 2 — Automation & Workflows

  • Visual Workflow Builder: A drag-and-drop automation builder where users connect triggers, conditions, delays, and actions in a visual flowchart.
  • Triggers: Subscriber joins a list, subscriber is tagged, form is submitted, date-based (birthday), e-commerce event (purchase, cart abandonment).
  • Conditions & Branching: If/else logic based on subscriber attributes, engagement history, custom fields, or link clicks.
  • Actions: Send email, wait delay, add/remove tag, update custom field, move to another automation, send webhook, notify admin.
  • Entry & Exit: Controls for who can enter an automation, how often they can re-enter, and conditions for automatic exit.

Domain 3 — Subscriber Management

  • Lists & Groups: Organize subscribers into lists and groups with support for a subscriber belonging to multiple lists simultaneously.
  • Tags & Custom Fields: Flexible tagging system and user-defined custom fields (text, number, date, dropdown) for granular subscriber profiles.
  • Segments: Dynamic segments based on conditions like opened last campaign or custom field country = USA that auto-update as subscriber data changes.
  • Import/Export: CSV import with field mapping, copy-paste import, API import, and full CSV/Excel export.
  • Cleanup Tools: Auto-remove bounced addresses, inactive subscribers (not opened in X days), and duplicate detection.

Domain 4 — Landing Pages & Websites

  • Landing Page Builder: Drag-and-drop page builder with blocks for text, images, forms, countdown timers, and product showcases.
  • Website Builder: Multi-page website creation with navigation, about pages, blog posts, and contact forms.
  • Custom Domains: Users can connect their own domains with automatic SSL certificate provisioning via Let's Encrypt.
  • SEO Settings: Meta titles, descriptions, Open Graph tags, custom slugs, and sitemap generation for each page.
  • Analytics: Page views, unique visitors, conversion rates, and traffic sources for each landing page and website.

Domain 5 — Forms & Pop-Ups

  • Embedded Forms: Inline, floating bar, and full-page subscription forms with customizable styles.
  • Pop-Ups & Slides: Exit-intent, timed, scroll-based, and click-triggered pop-ups and slide-in boxes.
  • Notification Bars: Top or bottom sticky bars for announcements and promotions.
  • Integration with Landing Pages: Forms can be connected to automations, lists, and tags directly.

Domain 6 — E-Commerce

  • Product Blocks: Email editor blocks that pull product data from connected stores (Shopify, WooCommerce).
  • Payment Collection: Native payment collection via Stripe for digital products, donations, and subscriptions.
  • Revenue Tracking: Track which campaigns and automations generate revenue.

Domain 7 — Analytics & Reporting

  • Campaign Analytics: Open rates, click rates, bounce rates, unsubscribe rates, spam complaints, and revenue per campaign.
  • Real-Time Dashboard: Live counters showing emails sent, opens, and clicks during an active campaign.
  • Subscriber Growth: Charts showing subscriber acquisition over time with source attribution.
  • A/B Testing: Split tests for subject lines, content variants, and send times with automatic winner selection.

Non-Functional Requirements

RequirementTargetRationale
Deliverability Rate> 98% inbox placementCore value proposition; directly impacts customer retention
Email Send Latency< 5 minutes for lists up to 100KUsers expect near-instant campaign delivery
API Response Timep99 < 200msEditor and dashboard must feel instant
Uptime SLA99.95% (approx 4.38 hours downtime/year)Enterprise customers require reliability
Data DurabilityZero data loss for subscriber recordsSubscriber data is the customer's most valuable asset
Concurrent Users50K+ simultaneous dashboard sessionsPeak usage during campaign send windows
Website Builder Performancep95 < 500ms TTFB on hosted sitesSEO and user experience depend on fast page loads
GDPR ComplianceFull compliance with EU data regulationsMajority of users are EU-based

These requirements shape every architectural decision in the following sections. The deliverability requirement alone drives our entire email sending infrastructure design, from IP warming to bounce processing. The latency requirements dictate our caching strategy and database indexing approach. Let us now estimate the scale of the system we need to build.

3. Capacity Estimation & Scale Numbers

Accurate capacity estimation is the foundation of good system design. Without knowing the scale of data flowing through the system, we cannot make informed decisions about database sharding, caching layers, message queue configurations, or infrastructure provisioning. Let us build a detailed model of MailerLite's operational scale based on publicly available data and reasonable industry estimates.

MetricDaily EstimateMonthly EstimateNotes
Active User Accounts650,000650,000Registered accounts across all tiers
Paid Subscribers~130,000~130,000Assuming 20% paid conversion rate
Total Managed Subscribers~650 millionAverage 5,000 subscribers per paid account
Campaigns Created~150,000~4.5 millionPower users send daily; casual users weekly
Emails Sent~500 million~15 billionAverage 10 emails per subscriber per month
Peak Hourly Throughput~80 millionBlack Friday peak; 10x normal hourly rate
Landing Page Views~30 million~900 millionAverage 1,000 page views per landing page per month
Website Builder Sites~80,000~80,000~12% of users create a website
API Calls~2 billion~60 billionIntegrations, webhooks, dashboard API calls
Subscriber Records (DB)~650 million rowsCore subscriber table with all custom fields
Event Log Rows~5 billion~150 billionOpens, clicks, bounces, unsubscribes per event
Storage (Email Content)~50 GB~1.5 TBHTML email templates and rendered versions
Storage (Analytics)~200 GB~6 TBEvent data stored in columnar format for analytics
Cache Size (Redis)~30 GBHot subscriber data, session tokens, rate limiters

Write Path Analysis

The write path is where email platforms face their most critical scaling challenges. During a campaign send to 100,000 subscribers, the system must generate 100,000 personalized email variants, queue them for delivery, process bounces and complaints in real time, and update analytics counters — all without degrading the experience for users simultaneously working in the editor or dashboard.

We model the write throughput as follows: if 150,000 campaigns are created daily and each campaign generates an average of 2,000 email sends, we have 300 million email generation events per day, or approximately 3,500 emails per second at average load. During peak periods, this can spike to 25,000 emails per second. Each email send triggers multiple downstream events: a delivery record write, a bounce or open event write (asynchronous), a counter increment on the campaign analytics aggregate, and potentially a webhook delivery to the user's external system.

The total write amplification factor is approximately 8x, meaning 3,500 emails per second generates roughly 28,000 write operations per second across all databases. This informs our decision to use a write-optimized storage engine for event data, separate from the transactional PostgreSQL database that stores subscriber and campaign metadata. We will use Apache Kafka as the event backbone to absorb write spikes and provide backpressure-free decoupling between the email sending pipeline and the analytics processing pipeline.

Read Path Analysis

The read path is dominated by two workloads: the editor experience (sub-second page loads for campaign editing, subscriber browsing, and analytics dashboards) and the website builder (serving hosted websites with CDN-grade performance). The editor read path must serve subscriber lists with filtering, campaign content with embedded images, and analytics with time-series aggregations — all within 200ms at p99.

We estimate approximately 50,000 concurrent dashboard sessions at peak, each generating roughly 10 API calls per minute for data refresh, totaling 5,000,000 API calls per minute or approximately 83,000 API calls per second. The website builder adds another 35,000 requests per second for hosted site page loads. The combined read load of 118,000 requests per second is well within the capacity of a properly configured PostgreSQL read replica cluster backed by Redis caching for hot data.

Storage Projections

Over a five-year horizon, we project the following storage requirements. The core subscriber database with 650 million records at an average row size of 500 bytes requires approximately 325 GB of raw storage, growing at roughly 10% annually as users add more subscribers. The event log database is the largest consumer: 150 billion rows per month at 200 bytes each requires approximately 30 TB per month in raw storage. We will use TimescaleDB or ClickHouse for this workload, with automatic partitioning and 90-day hot retention followed by cold archival to object storage. The total five-year storage projection across all systems is approximately 500 TB, which is manageable with modern cloud storage economics at roughly $0.02 per GB per month.

Key Insight: The event logging volume (150 billion rows/month) is 200x larger than the subscriber metadata. This asymmetry drives our decision to separate the OLTP (subscriber management) and OLAP (analytics) workloads into different database systems entirely.

Bandwidth estimation follows naturally from the request volume. At 118,000 read requests per second with an average response size of 10 KB, we need approximately 1.2 GB/s of outbound bandwidth from our application servers, plus 400 GB/s of email delivery bandwidth from our sending infrastructure. The sending bandwidth is handled by a combination of dedicated SMTP relay servers and cloud-based email sending services, which we will detail in the deliverability section.

4. Core Data Model Design

The data model is the skeleton of the entire system. Every feature, API endpoint, and query pattern we design will ultimately map back to the entities and relationships defined here. MailerLite's data model must balance flexibility (custom fields, dynamic segments, variable automation paths) with performance (billions of event writes, sub-second subscriber lookups, complex analytics queries). Let us build it entity by entity.

Account & User Hierarchy

MailerLite operates a multi-tenant architecture where each account represents a business or creator. An account has one or more users with different permission levels. The hierarchy is: Organization → Account → Users → Lists → Subscribers → Campaigns. Understanding this hierarchy is critical because it determines data isolation boundaries, billing aggregation points, and permission enforcement paths.

erDiagram ORGANIZATION ||--o{ ACCOUNT : contains ACCOUNT ||--o{ USER : has ACCOUNT ||--o{ SUBSCRIBER_LIST : owns ACCOUNT ||--o{ CAMPAIGN : creates ACCOUNT ||--o{ AUTOMATION : builds ACCOUNT ||--o{ LANDING_PAGE : designs SUBSCRIBER_LIST ||--o{ SUBSCRIBER_ENTRY : contains SUBSCRIBER_ENTRY }o--|| SUBSCRIBER : references CAMPAIGN ||--o{ CAMPAIGN_RECIPIENT : sends_to CAMPAIGN_RECIPIENT }o--|| SUBSCRIBER : targets CAMPAIGN ||--o{ EMAIL_EVENT : generates AUTOMATION ||--o{ AUTOMATION_NODE : contains AUTOMATION_NODE ||--o{ AUTOMATION_EXECUTION : triggers AUTOMATION_EXECUTION }o--|| SUBSCRIBER : runs_for

Subscriber Entity

The subscriber is the central entity of the platform. Each subscriber record must store email address, status (active, unsubscribed, bounced, complained, cleaned), subscription date, last activity timestamp, and a flexible map of custom fields. The subscriber belongs to one or more lists and can have zero or more tags attached. The design must support fast lookups by email address (for deduplication), by list membership (for campaign targeting), and by tag/field values (for segmentation).

SQL
CREATE TABLE subscribers (
    id              BIGSERIAL PRIMARY KEY,
    organization_id BIGINT NOT NULL REFERENCES organizations(id),
    email           VARCHAR(320) NOT NULL,
    status          VARCHAR(20) NOT NULL DEFAULT 'active',
    subscribed_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    unsubscribed_at TIMESTAMPTZ,
    last_opened_at  TIMESTAMPTZ,
    last_clicked_at TIMESTAMPTZ,
    source          VARCHAR(50),
    custom_fields   JSONB DEFAULT '{}',
    metadata        JSONB DEFAULT '{}',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT unique_email_per_org UNIQUE (organization_id, email)
);

CREATE INDEX idx_subscribers_org_status ON subscribers(organization_id, status);
CREATE INDEX idx_subscribers_email ON subscribers(email);
CREATE INDEX idx_subscribers_custom_fields ON subscribers USING GIN(custom_fields);
CREATE INDEX idx_subscribers_last_activity ON subscribers(organization_id, last_opened_at);

The custom_fields column uses PostgreSQL's JSONB type to store user-defined fields without requiring schema migrations. The GIN index on this column enables efficient queries like find all subscribers where custom_fields->>'country' = 'USA'. For high-cardinality custom fields that are queried frequently, we create separate indexed columns in a subscriber_custom_fields junction table that acts as a materialized view of the JSONB data.

Campaign Entity

A campaign represents a single email blast to a group of subscribers. It stores the campaign name, subject line, preview text, HTML content, plain-text fallback, sender information (from name, from email, reply-to), targeting criteria (list IDs, segment IDs, excluded segment IDs), scheduling information (send_at timestamp, timezone), and status (draft, scheduled, sending, sent, paused, cancelled). The campaign also stores pre-computed aggregate statistics that are updated asynchronously as email events are processed.

Automation Entity

Automations are the most complex entity in the system. An automation consists of a directed acyclic graph (DAG) of nodes, where each node represents a step in the workflow. Node types include Trigger, Delay, Condition (with branches), Send Email, Update Subscriber, Webhook, and Exit. Each node stores its configuration as a JSON payload and references downstream nodes by ID. The automation also tracks its current state: active, paused, or draft, along with statistics about how many subscribers are currently flowing through each node.

Event Model

Email events (sent, delivered, opened, clicked, bounced, complained, unsubscribed) are the highest-volume data in the system. Each event record stores the event type, timestamp, campaign ID, subscriber ID, recipient email, and event-specific metadata (bounce type, click URL, user agent string for opens, IP address). Events are append-only and never updated or deleted in the hot path. They are processed through a partitioned table structure with monthly partitions to enable efficient range queries and data lifecycle management.

EntityRecord CountAvg Row SizeHot RetentionArchive
accounts650K2 KBPermanent
subscribers650M500 BPermanentInactive after 2 years
campaigns50M8 KB (content ref)PermanentContent to S3 after 1 year
email_events500B+200 B90 days hotClickHouse cold / S3 archive
automations5M10 KBPermanent
automation_executions10B+150 B30 days hotS3 Parquet after 30 days
landing_pages2M50 KB (content)PermanentCDN cached
websites80K200 KB (content)PermanentCDN cached
forms3M5 KBPermanent
page_views30B+300 B30 days hotClickHouse / S3

The separation of hot and cold data is fundamental to cost control. At the volumes we are dealing with, keeping all event data in a high-performance OLTP database would be prohibitively expensive. The tiered storage approach — PostgreSQL for hot operational data, ClickHouse for analytical queries, and S3 for long-term archival — allows us to optimize each layer for its specific access pattern while keeping total storage costs manageable.

5. API Design & Endpoints

A well-designed API is the contract between the frontend application and the backend services. MailerLite's API must support three distinct client types: the web dashboard (React SPA), the mobile app, and third-party integrations via a public REST API. The API design follows RESTful conventions with consistent resource naming, standard HTTP methods, pagination via cursor-based tokens, and comprehensive error responses with machine-readable error codes.

Core API Endpoints

MethodEndpointDescriptionRate Limit
POST/api/v2/campaignsCreate a new campaign draft100/min
PUT/api/v2/campaigns/{id}Update campaign content or settings100/min
POST/api/v2/campaigns/{id}/scheduleSchedule campaign for future send10/min
POST/api/v2/campaigns/{id}/sendSend campaign immediately10/min
GET/api/v2/campaigns/{id}/analyticsGet campaign performance metrics300/min
GET/api/v2/subscribersList subscribers with filtering300/min
POST/api/v2/subscribersAdd a new subscriber300/min
PUT/api/v2/subscribers/{id}Update subscriber fields300/min
POST/api/v2/subscribers/importImport subscribers from CSV5/min
GET/api/v2/subscribers/{id}/eventsGet subscriber event history300/min
POST/api/v2/automationsCreate automation workflow50/min
PUT/api/v2/automations/{id}Update automation nodes50/min
POST/api/v2/automations/{id}/activateActivate automation10/min
GET/api/v2/automations/{id}/statsGet automation analytics100/min
POST/api/v2/lists/{id}/segmentsCreate a dynamic segment50/min
GET/api/v2/lists/{id}/segments/{sid}Get segment subscriber count300/min
POST/api/v2/landing-pagesCreate landing page50/min
GET/api/v2/forms/{id}/submissionsGet form submission data100/min
GET/api/v2/analytics/overviewAccount-wide analytics summary60/min
POST/api/v2/webhooksRegister webhook endpoint20/min

API Request & Response Patterns

Every API request must include a valid API key in the Authorization header. The API key is scoped to a specific account and carries the permission level of the user who generated it. Write operations require an API key with write permissions; read operations can use either read-only or full-access keys. All responses follow a consistent envelope format with a data field for success responses and an errors array for failure responses.

{
  "data": {
    "id": "camp_8x7k2m9p",
    "name": "July Newsletter",
    "subject": "What's new in July",
    "status": "draft",
    "created_at": "2026-07-01T10:30:00Z",
    "recipient_count": null,
    "analytics": null
  },
  "meta": {
    "request_id": "req_a1b2c3d4",
    "rate_limit_remaining": 97
  }
}

Segment Query DSL

One of the most powerful API features is the segment query DSL that allows users to define dynamic subscriber segments using a JSON-based query language. This DSL is used both by the API and the visual segment builder in the frontend. The DSL supports nested conditions with AND/OR logic, comparisons against subscriber fields and event history, and temporal operators for time-based conditions.

{
  "conditions": {
    "operator": "AND",
    "rules": [
      {
        "field": "status",
        "operator": "equals",
        "value": "active"
      },
      {
        "operator": "OR",
        "rules": [
          {
            "field": "custom_fields.country",
            "operator": "equals",
            "value": "USA"
          },
          {
            "field": "custom_fields.country",
            "operator": "equals",
            "value": "Canada"
          }
        ]
      },
      {
        "field": "events.opened",
        "operator": "within_days",
        "value": 30
      },
      {
        "field": "custom_fields.plan",
        "operator": "equals",
        "value": "premium"
      }
    ]
  }
}

This DSL is compiled into optimized SQL queries at segment evaluation time. The compiler applies predicate pushdown, uses appropriate indexes, and caches the compiled query plan for repeated evaluations. Dynamic segments are re-evaluated every 15 minutes via a background job, and the resulting subscriber set is cached in Redis for fast campaign targeting.

Webhook System

Users can register webhook endpoints to receive real-time notifications when specific events occur (subscriber subscribed, campaign sent, form submitted). Webhooks are delivered via HTTP POST with a JSON payload signed with HMAC-SHA256 for verification. Failed deliveries are retried with exponential backoff (3 attempts over 24 hours). Each webhook delivery is logged with the full request and response for debugging purposes.

The API layer also implements comprehensive input validation, request size limits (1 MB for most endpoints, 50 MB for file uploads), idempotency keys for write operations to prevent duplicate processing, and request tracing via distributed tracing headers that propagate across all backend services.

6. High-Level System Architecture

The MailerLite architecture is decomposed into seven major subsystems, each responsible for a specific domain and communicating with others through well-defined interfaces. This decomposition follows Domain-Driven Design principles, where each bounded context has its own data store, its own business logic, and its own API surface. The subsystems are: the Web Application (React frontend), the API Gateway, the Campaign Service, the Automation Engine, the Email Delivery Pipeline, the Website Builder, and the Analytics Platform.

graph TB subgraph "Client Layer" WEB["React Web App"] MOBILE["Mobile App"] API_CLIENT["3rd Party Integrations"] end subgraph "Edge Layer" CDN["CloudFront CDN"] LB["Load Balancer"] WAF["Web Application Firewall"] end subgraph "API Gateway" GW["API Gateway
Auth, Rate Limit, Route"] end subgraph "Application Services" CAMP_SVC["Campaign Service
.NET 8"] AUTO_SVC["Automation Engine
.NET 8 + Hangfire"] SUB_SVC["Subscriber Service
.NET 8"] EDITOR_SVC["Editor Service
Block Rendering"] FORM_SVC["Form Service
Embed and Pop-up"] WEB_BUILDER["Website Builder
Page Rendering"] ECOM_SVC["E-Commerce Service
Product Blocks"] end subgraph "Email Pipeline" SMTP_RELAY["SMTP Relay Pool
IP Rotation"] DELIVERY["Delivery Engine
Bounce and Complaint"] WARMUP["IP Warmup Manager"] AUTH_CHECK["Authentication Checker
SPF, DKIM, DMARC"] end subgraph "Data Layer" PG_MAIN[("PostgreSQL
Primary + Replicas")] REDIS["Redis Cluster
Cache + Sessions"] KAFKA["Kafka Cluster
Event Streaming"] CH[("ClickHouse
Analytics")] S3["S3
Media + Archive"] end subgraph "Background Jobs" HANGFIRE["Hangfire
Job Scheduler"] WORKER["Background Workers
Send, Process, Aggregate"] end WEB --> CDN MOBILE --> CDN API_CLIENT --> CDN CDN --> LB LB --> WAF WAF --> GW GW --> CAMP_SVC GW --> AUTO_SVC GW --> SUB_SVC GW --> EDITOR_SVC GW --> FORM_SVC GW --> WEB_BUILDER GW --> ECOM_SVC CAMP_SVC --> PG_MAIN CAMP_SVC --> REDIS CAMP_SVC --> KAFKA SUB_SVC --> PG_MAIN SUB_SVC --> REDIS AUTO_SVC --> PG_MAIN AUTO_SVC --> REDIS AUTO_SVC --> KAFKA DELIVERY --> SMTP_RELAY DELIVERY --> KAFKA KAFKA --> WORKER WORKER --> CH WORKER --> PG_MAIN HANGFIRE --> PG_MAIN WEB_BUILDER --> S3 EDITOR_SVC --> S3 WARMUP --> SMTP_RELAY AUTH_CHECK --> SMTP_RELAY CAMP_SVC --> DELIVERY AUTO_SVC --> DELIVERY

Request Flow — Sending a Campaign

When a user clicks Send Campaign in the dashboard, the request flows through the following path. First, the React frontend sends a POST request to the API Gateway, which authenticates the user, checks rate limits, and routes the request to the Campaign Service. The Campaign Service validates the campaign configuration, locks the campaign record to prevent concurrent modifications, and enqueues a Send Campaign job in Hangfire with the campaign ID as the payload.

The Hangfire worker picks up the job and begins the send process. It fetches the target subscriber list (or segment), personalizes the email content for each subscriber (merging custom fields into the template), and publishes individual Send Email events to a Kafka topic. The Delivery Engine consumer reads from this topic, selects an appropriate sending IP based on the warmup schedule and reputation scores, authenticates the email (SPF, DKIM, DMARC), and transmits the email via SMTP to the recipient's mail server.

As email events flow back (delivered, opened, clicked, bounced), they are published to another Kafka topic and consumed by two parallel workers: the Analytics Worker, which aggregates metrics into ClickHouse, and the Subscriber Activity Worker, which updates subscriber-level fields in PostgreSQL (last_opened_at, last_clicked_at, status changes for bounces and complaints). The entire pipeline from send to inbox typically completes in under 30 seconds for non-rate-limited recipients.

Request Flow — Automation Trigger

When a subscriber fills out a form that is connected to an automation, the Form Service receives the submission, adds the subscriber to the target list, and publishes a Subscriber Joined List event to Kafka. The Automation Engine consumes this event, checks if any automations have a List Joined trigger matching this list, evaluates entry conditions to prevent duplicate entries, and creates an Automation Execution record that represents this subscriber's journey through the workflow. The execution starts at the first node and advances through the DAG according to the configured delays and conditions.

Each time an execution reaches a Send Email node, it follows the same email delivery pipeline as a campaign send. When it reaches a Delay node, the execution is suspended and rescheduled via Hangfire to resume after the configured delay period. When it reaches a Condition node, the engine evaluates the condition against the subscriber's current attributes and routes the execution down the appropriate branch. This design allows automations with thousands of subscribers flowing through them simultaneously, each at different points in the workflow, without any performance degradation.

Architecture Win: By using Kafka as the event backbone, we achieve complete decoupling between the email sending pipeline and the analytics processing pipeline. This means a spike in email opens during a campaign send does not affect the delivery pipeline's ability to send new emails.

7. Clean Email Editor — Block-Based Design

The email editor is the most user-facing component of MailerLite and the primary reason many users choose the platform over competitors. It must be intuitive enough for a first-time user to create a professional email in under five minutes, yet powerful enough to support complex layouts with multiple columns, product grids, and dynamic content. The editor achieves this through a block-based architecture that treats every element of an email as an independent, configurable, and reorderable component.

Block System Architecture

The editor is built as a React application using a block-based content model inspired by WordPress Gutenberg but optimized for email HTML output. Each block type is a self-contained React component that knows how to render itself in the editor canvas, how to expose its configuration in a sidebar panel, and how to export itself as email-compatible HTML. The block types include: Text, Image, Button, Divider, Spacer, Social Links, Video (thumbnail with play overlay), Header, Columns (2, 3, or 4 column layouts), Product (pulls from connected e-commerce store), Countdown Timer, and HTML (raw code block for advanced users).

graph LR subgraph "Editor Components" TB["Toolbar Block
Undo/Redo, Device Toggle"] PB["Palette Block
Block Library Sidebar"] CB["Canvas Block
Email Preview and Edit"] PP["Properties Panel
Block Settings"] end subgraph "Block Engine" REG["Block Registry
Type Definitions"] STATE["Editor State
Redux Store"] RENDER["Render Engine
React to Email HTML"] VALID["Validator
Email Compatibility"] end subgraph "Output Pipeline" INLINE["CSS Inliner
Styles to Inline"] RESPONSIVE["Responsive Wrapper
Mobile Media Queries"] EXPORT["HTML Export
Email Client Compatible"] end TB --> STATE PB --> REG CB --> RENDER PP --> STATE STATE --> RENDER RENDER --> INLINE INLINE --> RESPONSIVE RESPONSIVE --> EXPORT REG --> RENDER VALID --> RENDER

When a user drags a block from the palette onto the canvas, the editor state (managed via Redux) is updated to insert the new block at the target position with default configuration values. The canvas re-renders to show the new block in its default state. When the user clicks on the block, the properties panel updates to show the configuration options specific to that block type. All changes are debounced and auto-saved to the server every 3 seconds, with a visual indicator showing save status.

Email HTML Rendering

Email HTML is notoriously difficult to produce because email clients (Gmail, Outlook, Yahoo Mail, Apple Mail) each have their own rendering engine with varying levels of HTML and CSS support. The editor's render engine solves this by generating table-based HTML layouts that are universally supported across all major email clients. Every block generates a nested table structure with inline styles, ensuring consistent rendering regardless of the recipient's email client.

The rendering pipeline applies several transformations before exporting the final HTML. First, all CSS classes are converted to inline styles because many email clients strip style blocks. Second, responsive design is achieved through @media queries wrapped in MSO conditionals for Outlook compatibility. Third, image URLs are resolved to CDN-hosted URLs with appropriate sizing parameters. Fourth, merge tags (personalization variables like {{first_name}}) are inserted as template placeholders that the delivery engine replaces at send time.

Mobile Preview & Testing

The editor includes a built-in mobile preview mode that simulates how the email will render on mobile devices. The preview uses responsive breakpoints at 480px and 600px widths, which are the most common mobile email viewport sizes. Users can toggle between desktop, tablet, and mobile views with a single click. Additionally, the editor provides a Send Test Email feature that sends the current draft to a specified email address, allowing users to verify rendering in their actual email client before sending to the full list.

Template Management

Templates are stored as serialized block structures in the database, not as raw HTML. This allows templates to be updated with new block types or improved rendering logic without breaking existing templates. When a user saves a campaign as a template, the editor serializes the current block state as a JSON document and stores it in the templates table. When a user opens a template to create a new campaign, the editor deserializes the JSON and hydrates the block components. This architecture also enables version history — every template save creates a new version, and users can roll back to any previous version.

JSON
{
  "template_id": "tmpl_x9k2m4",
  "name": "Weekly Newsletter",
  "version": 12,
  "blocks": [
    {
      "type": "header",
      "id": "blk_a1b2",
      "config": {
        "text": "Weekly Newsletter",
        "level": 1,
        "align": "center",
        "color": "#1a1a1a",
        "font_family": "Arial",
        "font_size": 28
      }
    },
    {
      "type": "image",
      "id": "blk_c3d4",
      "config": {
        "url": "https://cdn.mailerlite.com/banners/header-v3.jpg",
        "alt": "Newsletter Banner",
        "width": 600,
        "link_url": "",
        "border_radius": 0
      }
    },
    {
      "type": "columns",
      "id": "blk_e5f6",
      "config": {
        "columns": 2,
        "gap": 20,
        "children": [
          {
            "blocks": [
              {
                "type": "text",
                "id": "blk_g7h8",
                "config": {
                  "content": "This week we cover...",
                  "color": "#333333",
                  "font_size": 16,
                  "line_height": 1.6
                }
              }
            ]
          },
          {
            "blocks": [
              {
                "type": "button",
                "id": "blk_i9j0",
                "config": {
                  "text": "Read More",
                  "url": "https://example.com",
                  "background_color": "#0088ff",
                  "text_color": "#ffffff",
                  "border_radius": 6,
                  "alignment": "center"
                }
              }
            ]
          }
        ]
      }
    },
    {
      "type": "divider",
      "id": "blk_k1l2",
      "config": {
        "color": "#e5e7eb",
        "thickness": 1,
        "style": "solid",
        "spacing_top": 20,
        "spacing_bottom": 20
      }
    },
    {
      "type": "footer",
      "id": "blk_m3n4",
      "config": {
        "text": "2026 Ayodhyya. Unsubscribe | Manage Preferences",
        "align": "center",
        "color": "#999999",
        "font_size": 12
      }
    }
  ],
  "settings": {
    "bg_color": "#ffffff",
    "content_width": 600,
    "preheader_text": "",
    "utm_params": {
      "source": "newsletter",
      "medium": "email"
    }
  }
}

The block-based template format is the secret weapon that allows MailerLite to offer a clean, simple editor while other platforms struggle with clunky WYSIWYG interfaces. By abstracting email HTML into discrete blocks, the platform can validate, optimize, and render email content consistently across all contexts — the editor, the preview, the test send, and the final campaign delivery.

8. Automation Engine & Visual Workflows

The automation engine is the most architecturally complex component of the platform. It must evaluate trigger events in real time, execute multi-step workflows with delays spanning hours or days, handle branching logic based on subscriber attributes and behavior, and maintain accurate execution state for millions of concurrent subscriber journeys — all while ensuring that no email is sent twice and no subscriber is stuck in a workflow indefinitely.

State Machine Model

Each automation is modeled as a directed acyclic graph (DAG) where nodes represent workflow steps and edges represent transitions between steps. The Automation Engine operates as a distributed state machine that processes events and advances subscriber executions through the graph. The state transitions are: Created → Active → Running (per subscriber) → Suspended (at delay nodes) → Completed or Exited.

stateDiagram-v2 [*] --> TriggerFired: Event received TriggerFired --> EvaluatingEntry: Check entry conditions EvaluatingEntry --> InWorkflow: Entry conditions met EvaluatingEntry --> [*]: Entry conditions not met InWorkflow --> SendEmailNode: Current node = Send Email InWorkflow --> DelayNode: Current node = Delay InWorkflow --> ConditionNode: Current node = Condition InWorkflow --> UpdateSubscriberNode: Current node = Update Subscriber InWorkflow --> WebhookNode: Current node = Webhook InWorkflow --> ExitNode: Current node = Exit SendEmailNode --> EmailQueued: Email dispatched EmailQueued --> AdvanceNext: Delivery confirmed DelayNode --> Suspended: Delay set Suspended --> AdvanceNext: Delay elapsed ConditionNode --> ConditionTrue: Condition true ConditionNode --> ConditionFalse: Condition false ConditionTrue --> AdvanceNext: Take TRUE branch ConditionFalse --> AdvanceNext: Take FALSE branch UpdateSubscriberNode --> AdvanceNext: Subscriber updated WebhookNode --> WebhookSent: Webhook dispatched WebhookSent --> AdvanceNext: Webhook complete ExitNode --> [*]: Execution completed AdvanceNext --> InWorkflow: Next node exists AdvanceNext --> [*]: No next node completed

Execution Model

When a trigger event fires (e.g., subscriber joined list), the engine creates an Execution record that represents one subscriber's journey through the automation. The execution stores the subscriber ID, the automation ID, the current node ID, the status (running, suspended, completed, exited), and a context object that accumulates state as the subscriber progresses through the workflow (e.g., which emails were sent, which branches were taken, what timestamps were recorded).

For nodes that require asynchronous processing (delays, webhooks, email sends), the execution is serialized and stored in the database with a scheduled_at timestamp indicating when it should resume. A background poller runs every 30 seconds, queries for executions where scheduled_at has passed and status is suspended, and enqueues them for processing. This polling-based approach is more resilient than timer-based scheduling because it survives process restarts and does not require distributed coordination.

Trigger System

Triggers are event listeners that watch for specific occurrences and start new automation executions. The trigger system is implemented as a Kafka consumer group that subscribes to event topics and matches events against registered automation triggers. When a matching trigger is found, the engine checks the automation's entry conditions and creates a new execution if the subscriber qualifies.

The available trigger types and their event sources are: List Joined (subscriber added to a list), Tag Added (specific tag applied to subscriber), Form Submitted (subscriber completed a form), Date-Based (cron job checks daily for subscribers matching a date field), E-Commerce: Purchase (webhook from Shopify/WooCommerce), E-Commerce: Cart Abandoned (webhook with cart data), Link Clicked (subscriber clicked a specific tracked link in a previous email), Campaign Opened (subscriber opened a specific campaign), and API Trigger (external system fires an event via API).

Throttling & Rate Limiting

Automation executions must be throttled to prevent overwhelming recipients with too many emails from the same sender. The engine enforces per-subscriber rate limits (maximum 1 email per hour from automations unless overridden), per-account rate limits (maximum 10,000 emails per hour from automations), and per-recipient domain limits (maximum 500 emails per hour to @gmail.com addresses to protect sender reputation). When a throttling limit is hit, the execution is suspended with a retry-after timestamp and re-evaluated when the limit window resets.

Trigger TypeEvent SourceEvaluation FrequencyMax Concurrent Executions
List JoinedKafka: subscriber.eventsReal-timeUnlimited
Tag AddedKafka: subscriber.eventsReal-timeUnlimited
Form SubmittedKafka: form.eventsReal-timeUnlimited
Date-BasedCron: daily at 00:05 UTCDaily batch10,000/hour
E-CommerceWebhook: store.eventsReal-timeUnlimited
Link ClickedKafka: click.eventsReal-timeUnlimited
API TriggerREST: POST /triggersReal-time1,000/hour

The automation engine is designed for reliability above all else. Every state transition is persisted to the database before the next transition is processed. If the engine crashes mid-execution, the execution resumes from the last persisted state. Idempotency keys on every event ensure that duplicate events (which are inevitable in distributed systems) do not cause duplicate email sends. The engine processes approximately 50 million automation events per day at current scale, with each event requiring an average of 3 database reads and 2 database writes.

9. Landing Page & Website Builder

MailerLite's website builder extends the platform from an email-only tool to a complete online presence solution. Users can create standalone landing pages for lead capture, multi-page websites with navigation and blog functionality, and connect custom domains with automatic SSL provisioning. The builder shares the same block-based architecture as the email editor but generates responsive web HTML instead of email-compatible HTML.

Rendering Architecture

The website builder uses a server-side rendering (SSR) approach optimized for performance. When a visitor requests a page on a user's hosted website, the request is handled by a dedicated Website Rendering Service that loads the page structure from a Redis cache (populated on page publish), resolves any dynamic blocks (countdown timers, embedded forms, product feeds), applies the site's theme CSS, and returns the fully rendered HTML. The rendered page is then cached at the CDN edge for 5 minutes (configurable), ensuring that subsequent visitors get near-instant page loads.

graph TB VISITOR["Visitor Browser"] --> CDN["CloudFront CDN"] CDN --> CACHE_CHECK{"Cache Hit?"} CACHE_CHECK -->|Hit| CDN_SERVE["Serve Cached HTML"] CACHE_CHECK -->|Miss| RENDERER["Website Renderer
.NET 8"] RENDERER --> REDIS_CACHE["Redis
Page Structure Cache"] RENDERER --> DB["PostgreSQL
Page Content"] RENDERER --> BLOCKS["Block Resolver
Dynamic Content"] BLOCKS --> PRODUCT_API["Product Feed
from E-Commerce"] BLOCKS --> FORM_EMBED["Form Embed
Subscription Forms"] BLOCKS --> COUNTDOWN["Countdown
Timer Script"] RENDERER --> THEME["Theme Engine
CSS and Fonts"] THEME --> S3["S3
Theme Assets"] RENDERER --> HTML_OUT["Rendered HTML
Response"] HTML_OUT --> CDN CDN_SERVE --> VISITOR

Custom Domain & SSL

When a user connects a custom domain to their MailerLite website, the platform must configure DNS records (CNAME pointing to our CDN), provision an SSL certificate via Let's Encrypt, update the web server configuration to serve the correct site for the domain, and propagate these changes globally within 15 minutes. This process is orchestrated by a Domain Provisioning Service that watches for domain connection events and executes the configuration steps in sequence with rollback capability if any step fails.

The SSL certificate provisioning uses the ACME protocol with HTTP-01 challenge validation. The challenge file is served through the CDN, and the certificate is renewed automatically 30 days before expiration. For enterprise users who bring their own certificates, the platform accepts uploaded certificates and private keys, validates them, and configures them on the appropriate CDN distribution.

Theme System

Themes define the visual styling of websites — colors, fonts, spacing, and layout options. The theme system supports global style variables (primary color, heading font, body font, border radius) that cascade to all blocks on all pages of a site. Users can switch themes at any time, and all pages update their styling immediately while preserving content. Themes are stored as JSON configuration files in S3, with compiled CSS that is minified and served through the CDN.

SEO & Performance

Every landing page and website page includes comprehensive SEO support: customizable meta titles and descriptions, Open Graph tags for social sharing, canonical URLs, auto-generated XML sitemaps, and structured data markup (JSON-LD) for rich snippets. The rendering pipeline ensures that all SEO-relevant content is server-side rendered and available in the initial HTML response, not loaded via JavaScript. Page speed optimization includes automatic image compression and WebP conversion, lazy loading for below-the-fold images, critical CSS inlining, and deferred JavaScript loading. The target is a Lighthouse Performance score of 90+ for all hosted pages.

Analytics for hosted pages are collected via a lightweight JavaScript snippet (under 2 KB gzipped) that tracks page views, scroll depth, form submissions, and outbound link clicks. The analytics data flows through a tracking pixel endpoint into Kafka, where it is processed by the same analytics pipeline used for email event data. This unified analytics approach allows users to see the complete journey from website visit to email subscription to campaign engagement in a single dashboard view.

10. High-Deliverability Architecture

Deliverability is not a feature — it is the foundation upon which the entire platform's value is built. An email marketing platform that cannot get emails into the inbox is worthless, regardless of how beautiful its editor is or how powerful its automation engine is. MailerLite's deliverability architecture is a multi-layered system that manages sender reputation, authenticates email, monitors inbox placement, and enforces strict list hygiene policies. This section details every component of that system.

IP Rotation & Warming

The platform maintains a pool of dedicated sending IP addresses organized into reputation tiers. New IPs start in a cold tier and are gradually warmed up by sending increasing volumes of mail over 2-4 weeks, starting with highly engaged subscribers (recent openers and clickers) who are most likely to generate positive engagement signals. As the IP accumulates positive reputation with major ISPs (Gmail, Outlook, Yahoo), it is promoted to higher tiers that handle larger campaign volumes.

graph LR subgraph "IP Reputation Tiers" COLD["Cold Tier
0-5K emails/day
Warmup IPs"] WARM["Warm Tier
5K-50K emails/day
Growing reputation"] HOT["Hot Tier
50K-200K emails/day
Established reputation"] DEDICATED["Dedicated IPs
For Enterprise
Custom volume"] end subgraph "Reputation Signals" BOUNCE["Bounce Rate
Target less than 2%"] COMPLAINT["Complaint Rate
Target less than 0.1%"] ENGAGE["Engagement Rate
Open plus Click"] SPF_PASS["SPF/DKIM/DMARC
Authentication"] end COLD -->|Bounce less than 2% Complaint less than 0.1%| WARM WARM -->|30 days clean Engagement above 15%| HOT HOT -->|Enterprise request Dedicated warming| DEDICATED DEDICATED -->|Reputation drop Back to HOT| HOT BOUNCE -.-> COLD COMPLAINT -.-> COLD ENGAGE -.-> WARM SPF_PASS -.-> HOT

Email Authentication

Every email sent through the platform must pass three authentication checks before leaving the sending infrastructure. SPF (Sender Policy Framework) is configured on all sending domains to authorize our IP addresses. DKIM (DomainKeys Identified Mail) signs every email with a 2048-bit RSA key, and the signature is verified before delivery. DMARC (Domain-based Message Authentication, Reporting & Conformance) policies are configured at the p=quarantine level, meaning unauthenticated mail is quarantined rather than rejected, giving us time to fix any authentication failures.

For users who send from their own domains, the platform provides a guided DNS setup wizard that walks them through adding the required SPF, DKIM, and DMARC records. The wizard includes an automated DNS verification step that checks the records are correctly configured before allowing the user to send their first campaign. This proactive approach prevents the most common cause of deliverability failure: misconfigured DNS records.

Bounce & Complaint Processing

Bounce processing is a real-time pipeline that monitors for both hard bounces (invalid email addresses) and soft bounces (full mailboxes, temporary server issues). Hard bounces are processed immediately: the subscriber is marked as bounced and excluded from all future sends. Soft bounces are tracked with a decay function — after 3 consecutive soft bounces over 7 days, the subscriber is treated as a hard bounce. The bounce processing pipeline consumes from a dedicated Kafka topic and updates subscriber status within 60 seconds of bounce receipt.

Complaint processing is even more critical. When a recipient clicks Mark as Spam in their email client, the feedback loop notification is sent to our abuse mailbox. The complaint processor parses these notifications, immediately marks the subscriber as complained, removes them from all future sends, and flags the source list for review. If an account's complaint rate exceeds 0.1% over a 7-day rolling window, the account is placed on sending probation with mandatory approval before each campaign send.

Deliverability Monitoring Dashboard

The platform maintains real-time deliverability metrics that are visible to both internal operations teams and account owners. The internal dashboard tracks per-IP metrics (bounce rate, complaint rate, throughput, queue depth), per-domain metrics (inbox placement rate by ISP), and per-account metrics (engagement trends, list quality scores). The user-facing dashboard shows inbox placement estimates, engagement trends, and actionable recommendations for improving deliverability (e.g., Clean 2,341 inactive subscribers to improve your inbox rate by an estimated 3%).

MetricTargetAlert ThresholdAction on Breach
Bounce Rateless than 2%above 3%Pause sending, review list
Complaint Rateless than 0.1%above 0.08%Flag account, require list cleanup
Inbox Placement Rateabove 95%below 90%Investigate IP reputation, adjust routing
Open Rate (platform avg)above 20%below 12%Recommend subject line improvements
SPF/DKIM Pass Rate100%below 99.5%Immediate investigation, DNS check
DMARC Alignment100%below 99%Review domain configuration
IP Pool HealthAll IPs warmAny IP cold above 30 daysAccelerate warmup or decommission

The deliverability system also integrates with third-party inbox placement testing tools like Mail-Tester and GlockApps. Before major campaign sends, the platform can automatically send test messages to seed addresses across all major ISPs and report back the inbox placement results. This proactive testing catches deliverability issues before they affect real subscribers, providing a safety net that smaller platforms typically lack.

11. Subscriber Management & Segmentation

Subscriber management is the operational backbone of any email platform. Users need to import thousands of subscribers from various sources, organize them into meaningful groups, track their engagement over time, and clean up invalid or inactive records. MailerLite's subscriber management system is built for scale and flexibility, handling imports of millions of subscribers while supporting real-time segmentation that updates automatically as subscriber data changes.

Import Pipeline

Subscriber import is a multi-stage pipeline that handles file parsing, field mapping, deduplication, validation, and list assignment. When a user uploads a CSV file, the import service first parses the file and presents a preview of the data with auto-detected column mappings. The user confirms or adjusts the mappings (e.g., Column 3 = First Name, Column 7 = Custom Field: Company), selects the target list, and chooses a deduplication strategy (skip duplicates, update existing, or create separate records).

Once the import is confirmed, the pipeline processes the file asynchronously. Each row is validated (email format check, required fields check), deduplicated against existing subscribers, and batched into groups of 1,000 for efficient database insertion. The import progress is streamed to the frontend via Server-Sent Events (SSE), showing real-time progress with counts of imported, updated, skipped, and failed records. For very large imports (1M+ subscribers), the pipeline can take 10-30 minutes but operates entirely in the background without blocking the user's ability to use the dashboard.

Segmentation Engine

Segments are dynamic subscriber groups defined by a set of conditions. Unlike static groups (where membership is manually assigned), segments automatically include or exclude subscribers as their data changes. The segmentation engine evaluates segment conditions against subscriber records and produces a materialized subscriber set that is cached for fast campaign targeting.

graph TB subgraph "Segment Evaluation Pipeline" CRON["Cron Job
Every 15 minutes"] SEGMENTS["Load All Active
Segment Definitions"] COMPILE["Query Compiler
DSL to SQL"] EVALUATE["Evaluate Segments
Execute SQL Queries"] RESULT["Result Sets
Subscriber ID Lists"] CACHE["Redis Cache
Segment to Subscriber Set"] NOTIFY["Notify Campaign Service
Updated Counts"] end CRON --> SEGMENTS SEGMENTS --> COMPILE COMPILE --> EVALUATE EVALUATE --> RESULT RESULT --> CACHE RESULT --> NOTIFY

Segment conditions support a rich set of operators including: equals, not equals, contains, starts with, greater than, less than, is empty, is not empty, is in list, is not in list, within last N days, before date, after date, between dates, and custom SQL expressions for power users. Segments can be nested with AND/OR logic to create complex targeting criteria. The query compiler generates optimized PostgreSQL queries with appropriate index usage and caches query plans for repeated evaluations.

Tag management is simpler than segmentation but equally important. Tags are flat labels applied to subscribers for organizational purposes. A subscriber can have unlimited tags, and tags can be used in both segment conditions and automation triggers. Tag operations (add, remove, bulk apply, bulk remove) are processed asynchronously via a job queue to avoid locking subscriber records during bulk operations. The tag system also supports auto-tagging rules that automatically apply tags based on subscriber behavior (e.g., tag as VIP if opened more than 10 campaigns in the last 30 days).

Subscriber Profile View

Each subscriber has a comprehensive profile view that aggregates all data associated with that subscriber. The profile shows: basic information (email, name, status, subscription date), custom fields, list memberships, tags, engagement timeline (every email sent, opened, clicked, bounced), automation participation (which automations the subscriber is currently in or has completed), and form submission history. The profile is assembled from multiple data sources (PostgreSQL for metadata, ClickHouse for event history) and cached in Redis for fast access.

12. Form & Pop-Up Builder

Forms are the primary mechanism for subscriber acquisition. MailerLite offers a versatile form builder that supports embedded forms (inline on a webpage), pop-up forms (triggered by time, scroll, or exit intent), slide-in boxes (corner-appearing notifications), and notification bars (sticky top or bottom bars). Each form type shares the same visual editor and subscriber management backend but has different display logic and triggering mechanisms.

Embed Architecture

When a user creates an embedded form, the platform generates a small JavaScript snippet (similar to Google Analytics) that the user copies and pastes into their website's HTML. This snippet, typically under 5 KB, loads asynchronously and handles the complete form lifecycle: rendering the form based on the configured design, managing form state (open/closed, validation), submitting data to the MailerLite API, and tracking submission events for analytics.

The embed snippet is served from the CDN and cached aggressively. It supports lazy loading (the form does not initialize until the user scrolls to the form's position on the page), smart rendering (the form appearance respects the host page's font and color scheme unless overridden), and mobile optimization (pop-ups are converted to inline forms on mobile devices to avoid Google's mobile interstitial penalty).

Pop-Up Trigger System

Pop-up forms use a trigger system that determines when and how the form is displayed. The trigger options include: Time Delay (show after N seconds on page), Scroll Depth (show after scrolling N% of the page), Exit Intent (show when the mouse moves toward the browser's close button), Click Trigger (show when a specific element is clicked), Page Views (show after N page views within the session), Returning Visitor (show only to visitors who have been to the site before), and Custom Code (triggered programmatically via a JavaScript function call). The trigger system also includes frequency capping to prevent showing the same pop-up too often to the same visitor, configurable from once per session to once per 30 days.

The form builder includes real-time preview that shows exactly how the form will appear on both desktop and mobile. Users can customize every visual aspect (colors, fonts, borders, animations, overlay opacity) and configure the post-submission behavior (show thank you message, redirect to URL, trigger an automation, close after N seconds). All form submissions are tracked in the analytics pipeline, providing conversion rate data that ties back to traffic sources.

13. E-Commerce & Payment Features

MailerLite's e-commerce features bridge the gap between email marketing and online selling. The platform supports product blocks in emails that pull real-time data from connected stores, native payment collection via Stripe for selling digital products and collecting donations, and revenue tracking that attributes sales to specific campaigns and automations.

Product Block System

Product blocks in the email editor connect to a product data source (Shopify, WooCommerce, or a manually curated product catalog) and display product information dynamically. When the email is sent, the product block resolves the latest product data (name, image, price, availability) from the connected store's API and renders the current information. This ensures that product blocks always show accurate prices and availability, even if the product data changed between email creation and send time.

The product data sync runs every 30 minutes for connected stores, pulling product catalogs into a local cache. This avoids making API calls to external stores at email send time, which would add latency and create a dependency on external service availability. The cached product data is stored in Redis with a 30-minute TTL, and the email rendering pipeline reads from this cache when personalizing product blocks for each recipient.

Payment Collection

Native payment collection allows users to sell digital products (e-books, templates, courses, software licenses) directly through MailerLite without needing a separate e-commerce platform. The user configures a product with a name, description, price, and delivery mechanism (file download or access URL), then embeds a payment form in an email or landing page. When a subscriber pays via Stripe, the platform processes the payment, delivers the digital product, records the transaction for analytics, and optionally adds the buyer to a specific subscriber list for post-purchase automation.

The payment processing flow uses Stripe Checkout for PCI compliance (card data never touches MailerLite's servers), webhooks for payment confirmation and dispute handling, and a digital product delivery system that generates unique, time-limited download links for each purchase. Revenue tracking attributes each sale to the specific campaign or landing page that generated it, providing ROI data that helps users understand which marketing efforts are most profitable.

14. A/B Testing Framework

A/B testing is a critical feature for optimizing campaign performance. MailerLite supports three types of split tests: subject line testing, content testing, and send time testing. The A/B testing framework is designed to be statistically rigorous while remaining simple enough for non-technical users to set up and interpret.

Test Configuration

When a user creates an A/B test, they define two or more variants (maximum 4), a sample size (percentage of the total recipient list to test with, default 20%), a primary metric (opens, clicks, or conversions), and a test duration (how long to wait before selecting a winner, default 4 hours). The platform then randomly assigns the sample subscribers to variants, sends the test campaigns, collects engagement data, and after the test duration expires, declares a winner based on statistical significance (p-value less than 0.05 using a two-proportion z-test) and automatically sends the winning variant to the remaining subscribers.

graph TB CREATE["User Creates
A/B Test"] --> CONFIGURE["Configure Variants
A, B, and optionally C, D"] CONFIGURE --> SAMPLE["Select Sample
20% of List"] SAMPLE --> SPLIT["Random Split
50/50 or 33/33/33"] SPLIT --> SEND_TEST["Send Test Campaigns
to Sample"] SEND_TEST --> COLLECT["Collect Metrics
Opens, Clicks"] COLLECT --> WAIT["Wait Test Duration
Default 4 Hours"] WAIT --> EVALUATE["Statistical Evaluation
Z-Test at p less than 0.05"] EVALUATE --> WINNER{"Winner
Found?"} WINNER -->|Yes| AUTO_SEND["Auto-Send Winner
to Remaining 80%"] WINNER -->|No| BEST_PERFORMER["Send Best Performer
by Raw Numbers"] AUTO_SEND --> REPORT["Generate Test Report
with Confidence Interval"] BEST_PERFORMER --> REPORT

Subject Line Testing

Subject line testing is the most commonly used A/B test type. Users can test up to 4 subject line variants, with the platform randomly assigning each variant to an equal subset of the test sample. The test uses open rate as the primary metric because subject lines primarily affect whether an email is opened. The winner is determined by the variant with the highest open rate that is statistically significantly different from the runner-up. If no variant reaches statistical significance, the platform recommends the variant with the highest raw open rate and notes the lack of significance in the test report.

Content Testing

Content testing allows users to test different email body designs, CTAs, or content layouts. The test sends different HTML variants to the sample subscribers and measures click-through rate as the primary metric. Content tests require larger sample sizes than subject line tests because click rate is a lower-frequency event than open rate. The platform recommends a minimum sample size of 5,000 subscribers per variant for content tests to achieve reliable statistical power.

Send Time Testing

Send time testing identifies the optimal send time for a specific audience. The platform divides the sample into groups and sends the same campaign at different times (e.g., 8 AM, 12 PM, 4 PM, 8 PM in the recipient's local timezone). The test measures open rate and click rate across time slots and recommends the time slot that generated the highest engagement. This test is particularly valuable because optimal send times vary significantly across industries, audience demographics, and geographic regions.

15. Analytics & Reporting Pipeline

The analytics pipeline processes billions of email events per month and transforms them into actionable insights for both platform operators and individual users. The pipeline is built on a lambda architecture that combines real-time stream processing for live dashboards with batch processing for historical reports and trend analysis.

Real-Time Stream Processing

Real-time analytics are powered by Kafka Streams consumers that process email events as they arrive. When an open event arrives, the stream processor increments the campaign's open counter, updates the subscriber's last_opened_at timestamp, and publishes a real-time update to a WebSocket channel that the dashboard is subscribed to. This allows the emails sent / opens / clicks counters on the campaign analytics page to update in real time as a campaign send progresses.

The real-time pipeline handles approximately 50,000 events per second at peak and must maintain end-to-end latency under 2 seconds from event receipt to dashboard update. This is achieved through a combination of Kafka partitioning by campaign ID (ensuring all events for a campaign are processed in order by the same consumer), Redis counters for atomic increment operations (avoiding database round-trips for counter updates), and WebSocket push notifications to connected dashboards.

Batch Processing

Batch processing runs on a daily schedule and produces aggregated reports, trend analyses, and cross-campaign comparisons. The batch pipeline reads from the ClickHouse analytical database, applies window functions and aggregations, and writes summary tables that power the analytics dashboard's historical views. Batch processing also handles subscriber growth charts, revenue attribution reports, and engagement trend analysis over configurable time periods.

Analytics FeatureData SourceProcessingRefresh Rate
Live Campaign CountersKafka to RedisStream (real-time)Sub-second
Campaign Performance ReportClickHouseBatch + Cache5 minutes
Subscriber Growth ChartPostgreSQL to ClickHouseDaily batchDaily
Revenue AttributionClickHouseDaily batchDaily
Engagement Trends (30-day)ClickHouseHourly batchHourly
Segment Size CountsPostgreSQLCached query15 minutes
Landing Page AnalyticsClickHouseBatchHourly
A/B Test ResultsClickHouseNear real-time5 minutes

The analytics architecture uses ClickHouse as the primary analytical database because of its columnar storage format, which provides 100-1000x faster aggregation performance compared to row-oriented databases for analytical queries. ClickHouse also supports materialized views that pre-aggregate data at different granularities (hourly, daily, weekly), making dashboard queries extremely fast regardless of the time range selected. The daily batch pipeline writes raw events to ClickHouse, and the materialized views automatically update the pre-aggregated tables.

Revenue Tracking

Revenue tracking connects email campaign performance to actual business outcomes. For users with connected e-commerce stores (Shopify, WooCommerce), the platform tracks which campaigns and automations generated product purchases, calculates revenue attribution using a last-touch model, and displays revenue-per-campaign and revenue-per-subscriber metrics in the analytics dashboard. For users selling digital products through MailerLite's native payment system, revenue tracking is automatic and provides granular data on conversion rates, average order value, and customer lifetime value.

16. Third-Party Integrations

Integrations transform MailerLite from a standalone email platform into a connected hub within the user's marketing technology stack. The integration architecture supports three patterns: webhook-based (real-time event delivery), polling-based (periodic data sync), and OAuth-based (authorized API access to third-party services). The platform currently supports 90+ integrations, with the most popular being Zapier, Shopify, WordPress, Stripe, and WooCommerce.

Integration Categories

CategoryPopular IntegrationsSync DirectionTrigger Events
E-CommerceShopify, WooCommerce, BigCommerceBidirectionalPurchase, cart abandon, product update
CRMHubSpot, Salesforce, PipedriveBidirectionalContact created/updated, deal stage change
AutomationZapier, Make, n8nBidirectionalAll subscriber and campaign events
CMSWordPress, Webflow, SquarespaceSite to MailerLiteForm submission, page visit
PaymentsStripe, PayPalPayment to MailerLitePayment, refund, subscription change
AnalyticsGoogle Analytics, MixpanelMailerLite to AnalyticsCampaign events, revenue
MembershipTeachable, ThinkificBidirectionalCourse enrollment, completion
SocialFacebook, InstagramMailerLite to SocialCampaign publish, form submit

Zapier Integration Architecture

The Zapier integration is the most widely used because it unlocks thousands of potential connections without building dedicated integrations. MailerLite provides Zapier triggers (new subscriber, subscriber updated, campaign sent, form submitted) and actions (add subscriber, update subscriber, create campaign, send email to subscriber). The Zapier integration uses OAuth 2.0 for authentication and implements webhook-based triggers for real-time event delivery. When a Zapier trigger event occurs, the platform publishes the event data to a dedicated webhook endpoint that Zapier polls or receives via push, depending on the Zapier plan.

The integration platform also includes a developer API portal with comprehensive documentation, SDKs for popular languages (Python, Node.js, Ruby, PHP, C#), and a sandbox environment for testing integrations. The API portal tracks integration usage, provides error diagnostics, and offers rate limit management tools for high-volume integrations.

17. Database Design & Schema

The database layer is the most critical infrastructure component because data integrity is non-negotiable. Subscriber records, campaign configurations, and automation states must never be lost or corrupted. The database design uses PostgreSQL as the primary OLTP database with read replicas for scaling read workloads, ClickHouse for analytical queries, and Redis for caching and session management. Let us examine the detailed schema for the most performance-critical tables.

Campaign Metrics Schema

Campaign metrics are stored in an aggregate table that is updated asynchronously as email events are processed. This avoids the need to count events at query time, which would be prohibitively expensive at our scale. The aggregate table stores pre-computed counts for each campaign, updated atomically via UPSERT operations triggered by the event processing pipeline.

SQL
CREATE TABLE campaign_metrics (
    campaign_id     BIGINT PRIMARY KEY REFERENCES campaigns(id),
    recipient_count INT NOT NULL DEFAULT 0,
    sent_count      INT NOT NULL DEFAULT 0,
    delivered_count INT NOT NULL DEFAULT 0,
    open_count      INT NOT NULL DEFAULT 0,
    unique_opens    INT NOT NULL DEFAULT 0,
    click_count     INT NOT NULL DEFAULT 0,
    unique_clicks   INT NOT NULL DEFAULT 0,
    bounce_count    INT NOT NULL DEFAULT 0,
    hard_bounces    INT NOT NULL DEFAULT 0,
    soft_bounces    INT NOT NULL DEFAULT 0,
    unsubscribes    INT NOT NULL DEFAULT 0,
    complaints      INT NOT NULL DEFAULT 0,
    forwards        INT NOT NULL DEFAULT 0,
    revenue_cents   BIGINT NOT NULL DEFAULT 0,
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE campaign_events (
    id          BIGSERIAL PRIMARY KEY,
    campaign_id BIGINT NOT NULL,
    subscriber_id BIGINT NOT NULL,
    event_type  VARCHAR(20) NOT NULL,
    event_data  JSONB DEFAULT '{}',
    user_agent  VARCHAR(500),
    ip_address  INET,
    link_url    TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);

CREATE TABLE campaign_events_2026_07 PARTITION OF campaign_events
    FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');

CREATE INDEX idx_events_campaign_type ON campaign_events(campaign_id, event_type);
CREATE INDEX idx_events_subscriber ON campaign_events(subscriber_id, created_at);
CREATE INDEX idx_events_created ON campaign_events(created_at);

Automation State Schema

Automation state must be durable and queryable. Each automation execution stores the complete path the subscriber has taken through the workflow, including timestamps for each step. This data serves dual purposes: it enables the automation engine to resume suspended executions, and it provides the data for the automation analytics dashboard that shows how many subscribers are at each step of the workflow.

SQL
CREATE TABLE automation_executions (
    id              BIGSERIAL PRIMARY KEY,
    automation_id   BIGINT NOT NULL REFERENCES automations(id),
    subscriber_id   BIGINT NOT NULL REFERENCES subscribers(id),
    current_node_id VARCHAR(50) NOT NULL,
    status          VARCHAR(20) NOT NULL DEFAULT 'running',
    context         JSONB DEFAULT '{}',
    path_log        JSONB DEFAULT '[]',
    scheduled_at    TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_auto_exec_status ON automation_executions(status, scheduled_at);
CREATE INDEX idx_auto_exec_automation ON automation_executions(automation_id, status);
CREATE INDEX idx_auto_exec_subscriber ON automation_executions(subscriber_id);
CREATE INDEX idx_auto_exec_scheduled ON automation_executions(scheduled_at)
    WHERE status = 'suspended';

Subscriber Timeline Schema

The subscriber timeline is a chronological feed of all interactions a subscriber has had with the platform. It combines email events, form submissions, automation participation, and profile changes into a unified view. This timeline is assembled at query time by joining data from PostgreSQL (profile changes) and ClickHouse (email events), with the result cached in Redis for 5 minutes to avoid repeated expensive joins.

SQL
CREATE TABLE subscriber_lists (
    id            BIGSERIAL PRIMARY KEY,
    subscriber_id BIGINT NOT NULL REFERENCES subscribers(id),
    list_id       BIGINT NOT NULL REFERENCES subscriber_lists_meta(id),
    status        VARCHAR(20) NOT NULL DEFAULT 'active',
    subscribed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    unsubscribed_at TIMESTAMPTZ,
    source        VARCHAR(50),
    UNIQUE(subscriber_id, list_id)
);

CREATE TABLE subscriber_tags (
    subscriber_id BIGINT NOT NULL REFERENCES subscribers(id),
    tag_id        BIGINT NOT NULL REFERENCES tags(id),
    applied_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    applied_by    BIGINT REFERENCES users(id),
    PRIMARY KEY(subscriber_id, tag_id)
);

CREATE TABLE subscriber_segments_materialized (
    segment_id  BIGINT NOT NULL REFERENCES segments(id),
    subscriber_id BIGINT NOT NULL REFERENCES subscribers(id),
    computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY(segment_id, subscriber_id)
);

CREATE INDEX idx_seg_materialized_subscriber ON subscriber_segments_materialized(subscriber_id);
CREATE INDEX idx_seg_materialized_segment ON subscriber_segments_materialized(segment_id);

The materialized segment table is refreshed every 15 minutes by the segmentation engine. This pre-computation allows campaign targeting queries to be simple SELECT subscriber_id FROM subscriber_segments_materialized WHERE segment_id = X queries that execute in milliseconds, even for segments with millions of matching subscribers. Without this materialization, each campaign send would need to evaluate complex segment conditions against the full subscriber table, which would take minutes and cause lock contention with concurrent writes.

18. Caching Strategy

Caching is essential for maintaining sub-second API response times while serving 50,000+ concurrent dashboard users. The caching architecture uses a multi-tier approach with different cache layers optimized for different access patterns. The primary cache store is Redis Cluster, organized into logical namespaces for different data types, with TTL policies that balance freshness against performance.

Cache Tiers & Policies

Cache LayerStorageTTLInvalidationSize Estimate
Subscriber ProfileRedis Hash5 minutesOn subscriber update15 GB (hot 5%)
Segment ResultsRedis Set15 minutesOn segment re-evaluation8 GB
Campaign MetricsRedis Hash30 secondsOn event processing2 GB
Email TemplateRedis String1 hourOn template update5 GB
Session DataRedis Hash24 hoursOn logout1 GB
Rate Limit CountersRedis Sorted SetSliding windowAuto-expire500 MB
Page Structure (Websites)Redis String5 minutesOn page publish3 GB
Product Catalog CacheRedis Hash30 minutesOn store sync2 GB
CDN Edge CacheCloudFront5 minutesOn content update (invalidation)N/A (edge)

Cache-Aside Pattern

All cache lookups follow the cache-aside (lazy loading) pattern. The application first checks the cache for the requested data. On a cache hit, the data is returned directly from Redis without touching the database. On a cache miss, the application queries the database, populates the cache with the result (set with appropriate TTL), and returns the data to the caller. This pattern ensures that the cache only stores data that has been recently requested, avoiding memory waste on rarely accessed records.

For write-heavy paths (event processing, subscriber updates), we use a write-through pattern where the application writes to both the database and the cache simultaneously. This ensures that the cache is always consistent with the database for recently written data. The cache-aside pattern is combined with a stale-while-revalidate strategy for data that is acceptable to serve slightly stale (e.g., campaign metrics counters), where the cache returns the existing value immediately and triggers an asynchronous refresh in the background.

Cache Stampede Prevention

When a popular cache key expires and multiple concurrent requests try to populate it simultaneously, a cache stampede can overwhelm the database. This is prevented using a distributed lock (Redlock algorithm) that ensures only one request populates the cache while other requests wait or receive the stale value. The lock timeout is set to 5 seconds, after which the next request in line acquires the lock and retries the population. Additionally, probabilistic early expiration (XFetch) is used for high-traffic cache keys, where requests before the actual expiration time have a small probability of triggering a background refresh, spreading the load over time.

19. Multi-Region Deployment

MailerLite serves a global audience across 200+ countries, with significant user concentrations in North America, Europe, and Southeast Asia. A multi-region deployment strategy ensures low-latency access for all users, data residency compliance for EU users (GDPR), and high availability through geographic redundancy. The platform operates three primary regions: US-East (primary), EU-West (Frankfurt), and APAC (Singapore).

Architecture

graph TB subgraph "US-East Primary" US_APP["Application Services"] US_DB[("PostgreSQL Primary")] US_REDIS["Redis Cluster"] US_KAFKA["Kafka Cluster"] US_CLICK[("ClickHouse")] US_SMTP["SMTP Pool US IPs"] end subgraph "EU-West Frankfurt" EU_APP["Application Services"] EU_DB[("PostgreSQL Replica")] EU_REDIS["Redis Cluster"] EU_KAFKA["Kafka MirrorMaker"] EU_CLICK[("ClickHouse Replica")] EU_SMTP["SMTP Pool EU IPs"] end subgraph "APAC Singapore" AP_APP["Application Services"] AP_DB[("PostgreSQL Replica")] AP_REDIS["Redis Cluster"] AP_SMTP["SMTP Pool APAC IPs"] end US_DB -->|Async Replication| EU_DB US_DB -->|Async Replication| AP_DB US_KAFKA -->|MirrorMaker 2| EU_KAFKA EU_APP --> EU_DB EU_APP --> EU_REDIS EU_APP --> EU_KAFKA EU_APP --> EU_CLICK EU_APP --> EU_SMTP AP_APP --> AP_DB AP_APP --> AP_REDIS AP_APP --> AP_SMTP US_APP --> US_DB US_APP --> US_REDIS US_APP --> US_KAFKA US_APP --> US_CLICK US_APP --> US_SMTP

Data Routing

User account data is routed to the region closest to the account's primary location (determined during signup). Subscriber data is co-located with the account data in the same region. Email sending is routed to the SMTP pool in the recipient's geographic region when possible (e.g., emails to .de addresses are sent from EU SMTP servers), which improves deliverability by using IPs that are geographically close to the recipient's mail server and appear more legitimate to spam filters.

Cross-region replication is asynchronous with a typical lag of under 500 milliseconds. For disaster recovery scenarios, the platform can promote any read replica to primary within 5 minutes, achieving an RPO (Recovery Point Objective) of under 1 second and an RTO (Recovery Time Objective) of under 5 minutes. The EU region operates with a degree of data independence to comply with GDPR requirements, including the ability to process subscriber data entirely within EU borders without transferring personal data to US servers.

20. Cost Estimation

Understanding the infrastructure cost structure is essential for a platform that competes on affordability. MailerLite's $10/month pricing for up to 1,000 subscribers must cover infrastructure, engineering, support, and still generate a profit. This requires extremely efficient resource utilization and intelligent cost optimization at every layer of the stack.

ComponentSpecificationMonthly Cost (USD)Notes
PostgreSQL (Primary + 3 Replicas)r6g.2xlarge x 4$4,800Reserved instances, 1-year term
Redis Cluster (6 nodes)r6g.xlarge x 6$2,400Reserved instances, 1-year term
Kafka (6 brokers)m6g.2xlarge x 6$3,600Reserved instances, 1-year term
ClickHouse Cluster (3 nodes)r6g.4xlarge x 3$4,800High-memory for analytics
Application Servers (20)c6g.xlarge x 20$4,200.NET 8 services, auto-scaled
SMTP Relay Servers (10)m6g.large x 10$1,400Dedicated sending infrastructure
CloudFront CDN100 TB/month transfer$8,500Websites, landing pages, assets
S3 Storage50 TB total$1,200Media, templates, archives
ElastiCache Data Transfer5 TB/month$400Inter-AZ traffic
Kafka Storage20 TB EBS$1,600gp3 volumes
Monitoring (Datadog)Full stack$3,000APM, logs, metrics, alerts
DNS (Route 53)80K hosted zones$800Custom domains for websites
SSL Certificates80K active$0Let's Encrypt (free)
Load Balancers (3 regions)ALB x 3$600Application load balancers
Bandwidth (inter-region)10 TB/month$1,000Cross-region replication
Total Infrastructure$38,300
Engineering Team (15 engineers)Avg $12K/month$180,000Salaries, benefits, tools
Support Team (10 agents)Avg $5K/month$50,00024/7 chat and email support
Total Monthly Operating Cost$268,300

Revenue vs. Cost Analysis

With 650,000 accounts (20% paid at an average of $50/month), the monthly revenue is approximately $6,500,000. Against total operating costs of $268,300, this yields a gross margin of approximately 96%. This extremely high margin is typical of mature SaaS platforms because the marginal cost of serving one additional user is nearly zero — the infrastructure costs are largely fixed. This margin funds continued product development, marketing, and the free tier that drives user acquisition.

The per-email cost is particularly important for understanding scalability. At 15 billion emails per month with an infrastructure cost of $38,300, the per-email infrastructure cost is approximately $0.0000025, or a quarter of a cent per thousand emails. This is well below the industry average and is achieved through efficient use of reserved instances, high utilization rates, and the batched processing architecture that avoids expensive per-email database operations.

Key Cost Optimization: The largest cost savings come from three architectural decisions: (1) using Kafka to batch event writes instead of writing each event individually to the database, (2) pre-computing analytics aggregates instead of computing them at query time, and (3) using ClickHouse for analytics instead of running analytical queries on the OLTP database. Together, these decisions reduce infrastructure costs by approximately 70% compared to a naive implementation.

21. Interview Q&A

Q1: How would you handle sending 100 million emails in a single campaign without overwhelming the system?

Answer: The campaign send is decomposed into three phases. First, the Campaign Service fetches the target subscriber set and generates personalized email content in batches of 10,000. Each batch is written to a Kafka topic as individual Send Email events. Second, the Delivery Engine consumes from Kafka at a controlled rate (configurable per-IP throughput), selects an appropriate sending IP based on reputation tier and warmup status, authenticates the email (SPF/DKIM), and transmits via SMTP. Third, the Analytics Pipeline consumes delivery events and updates aggregate metrics. This decoupled architecture allows the system to absorb the initial burst of 100 million events into Kafka while the sending infrastructure processes them at a sustainable rate of approximately 5-10 million per hour, completing the full send in 10-20 hours.

Q2: How do you ensure that the same subscriber never receives duplicate emails from an automation?

Answer: Three mechanisms prevent duplicate sends. First, each automation execution has an idempotency key composed of (automation_id, subscriber_id, node_id). Before sending an email, the system checks if a record with this key already exists in the execution log table. Second, the automation execution uses optimistic locking with a version field — concurrent updates to the same execution are serialized at the database level. Third, the Kafka producer uses idempotent delivery with a deduplication window of 5 minutes, catching any message-level duplicates that might arise from Kafka producer retries. These three layers provide defense-in-depth against duplicate sends.

Q3: How would you design the segment evaluation engine to handle segments with millions of matching subscribers?

Answer: The segment evaluation engine uses a materialized approach. Every 15 minutes, a batch job evaluates all active segment definitions, generates optimized SQL queries, executes them against the PostgreSQL database, and writes the resulting subscriber ID sets to a materialized table (subscriber_segments_materialized). When a campaign targets a segment, it simply reads from the materialized table instead of evaluating the segment conditions in real time. The materialized table is indexed on both segment_id and subscriber_id for fast lookups. For very large segments (millions of subscribers), the campaign send reads subscriber IDs in cursor-based batches of 10,000 to avoid loading the entire result set into memory.

Q4: How do you handle the email editor's auto-save without creating excessive database writes?

Answer: The editor frontend debounces all changes and triggers an auto-save after 3 seconds of inactivity. Each auto-save sends the complete block state as a single UPDATE to the campaigns table (using the JSONB content column). The backend writes the update to PostgreSQL and invalidates the cached template in Redis. To avoid excessive writes, we implement client-side throttling (maximum 1 save per 3 seconds) and server-side rate limiting (maximum 20 saves per minute per campaign). Additionally, we use a dirty flag on the frontend — if the user has not made any actual changes since the last save, no save request is sent. This reduces the typical auto-save volume by approximately 60%.

Q5: How would you design the IP warming system for new sending IPs?

Answer: The IP warming system follows a structured ramp-up schedule over 2-4 weeks. New IPs start by sending to the most engaged subscribers (opened in the last 7 days) at a volume of 500 emails/day. The volume doubles every 3-4 days as long as key metrics stay within thresholds (bounce rate less than 2%, complaint rate less than 0.1%). If any threshold is breached, the warming schedule pauses for 48 hours and resumes at the previous volume. The warmup manager monitors ISP-specific feedback — Gmail Postmaster Tools data, Microsoft SNDS, and Yahoo FBL — to detect reputation issues early. Once an IP reaches 50,000 emails/day with clean metrics, it is promoted from the warm tier to the hot tier and becomes eligible for general campaign sending.

Q6: How do you handle subscriber import for a CSV file with 5 million rows?

Answer: Large imports are processed through a multi-stage async pipeline. The CSV file is first uploaded to S3 (presigned URL for direct browser-to-S3 upload, avoiding application server memory limits). The import service then reads the file from S3 line by line (streaming, not loading the entire file into memory), validates each row (email format, required fields), deduplicates against existing subscribers using a Bloom filter for fast negative checks and database lookups for positive matches, and batches valid rows into groups of 1,000 for bulk INSERT operations. Progress is streamed to the frontend via SSE. For 5 million rows, the import typically completes in 15-25 minutes. Failed rows are collected in a downloadable error file that the user can fix and re-import.

Q7: How do you ensure GDPR compliance for subscriber data across multiple regions?

Answer: GDPR compliance is achieved through several architectural measures. First, subscriber data is stored in the region closest to the account owner (determined at signup), and EU accounts have their data processed entirely within the EU region. Second, the platform implements full right-to-erasure (Right to be Forgotten) — when a subscriber requests deletion, the system removes their record from PostgreSQL, ClickHouse (anonymized), Redis (evicted), and S3 (file deletion). Third, all data processing activities are logged in an audit trail that maps to GDPR's lawful basis requirements. Fourth, the consent management system tracks when and how each subscriber consented to receive emails, providing the documentation needed to respond to regulatory inquiries.

Q8: How would you design the real-time campaign analytics dashboard?

Answer: The real-time dashboard uses a push-based architecture. The frontend connects to the backend via WebSocket. When email events arrive in Kafka, the stream processor updates Redis counters (atomic INCR operations) and pushes the updated counters to the WebSocket channel for the relevant campaign. The WebSocket hub maintains a subscription map of (campaign_id to connected_clients) and broadcasts counter updates to all connected dashboards for that campaign. The Redis counters are authoritative for real-time display, while ClickHouse serves as the source of truth for the analytics report page that shows historical data. This split ensures that the real-time path has zero database dependency and can handle the 50,000 events/second throughput requirement.

Q9: How do you handle email personalization for campaigns with merge tags?

Answer: Email personalization is a two-phase process. First, when the campaign is queued for sending, the system identifies all merge tags in the email content (e.g., {{first_name}}, {{company}}, {{custom_field}}). These are compiled into a personalization plan. Second, as each email is prepared for delivery, the system reads the subscriber's record from a cached subscriber profile (Redis), resolves each merge tag to the corresponding field value, and generates the personalized HTML. If a merge tag's value is empty, a configurable default is used (e.g., "there" instead of the first name, resulting in "Hi there" instead of "Hi John"). The personalization step adds approximately 1-2 milliseconds per email due to the Redis cache hit, which is negligible in the overall send pipeline.

Q10: How would you handle a sudden traffic spike from a viral landing page?

Answer: The website builder's rendering infrastructure is designed for burst traffic. Landing pages are served from CloudFront CDN with a 5-minute TTL, so viral traffic primarily hits the CDN edge, not the origin servers. For cache misses, the Website Rendering Service auto-scales from 5 to 50 instances within 2 minutes using Kubernetes HPA (Horizontal Pod Autoscaler) based on CPU and request queue metrics. The Redis cache layer absorbs the initial cache-miss burst, and once the CDN cache is warm, subsequent requests are served at CDN speed. For form submissions from viral pages, the Form Service uses a Kafka-backed async submission pipeline that absorbs submission bursts without overwhelming the database. The system can handle 100,000+ concurrent visitors on a single landing page without degradation.

Q11: Explain the difference between how MailerLite handles email delivery versus a simple SMTP server.

Answer: A simple SMTP server is a dumb pipe — it accepts an email and tries to deliver it to the recipient's MX server. MailerLite's delivery pipeline is an intelligent system that adds multiple layers of optimization. It rotates across a pool of IPs based on reputation scores, applies per-domain throttling to avoid triggering rate limits at major ISPs, authenticates with SPF/DKIM/DMARC before sending, monitors delivery status in real time through bounce and complaint feedback loops, and automatically suppresses problematic addresses to protect sender reputation. Additionally, it handles link wrapping for click tracking, image proxying for open tracking, and HTML normalization to ensure consistent rendering across email clients. These layers transform raw SMTP delivery into a high-deliverability system that achieves 98%+ inbox placement.

Q12: How would you migrate the platform from a monolithic architecture to microservices without downtime?

Answer: The migration follows the Strangler Fig pattern. We identify the first service to extract (typically the most independently scalable component, like the Email Delivery Pipeline), deploy it as a separate service alongside the monolith, and route traffic to it via an API gateway that can split traffic between the monolith and the new service. We run both systems in parallel during the transition period, comparing outputs to verify correctness. Once the new service is proven stable, we gradually shift 100% of traffic to it and remove the corresponding code from the monolith. We repeat this process for each service extraction. Database migration uses the shared database pattern initially (both the monolith and the new service read/write to the same database tables), then gradually migrates to a dedicated database per service with data synchronization via CDC (Change Data Capture) using Debezium.

22. Full C# Implementation

This section provides production-grade C# implementations for four core services: CampaignService (campaign lifecycle management), AutomationEngine (workflow execution), DeliverabilityMonitor (IP reputation and bounce processing), and SubscriberService (subscriber CRUD with segmentation). The implementations demonstrate the patterns discussed throughout this article, including async processing, caching, event publishing, and error handling.

CampaignService — Campaign Lifecycle Management

C#
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Npgsql;
using StackExchange.Redis;
using Confluent.Kafka;

namespace MailerLite.Core.Services
{
    public enum CampaignStatus
    {
        Draft, Scheduled, Sending, Sent,
        Paused, Cancelled, Failed
    }

    public class Campaign
    {
        public long Id { get; set; }
        public long OrganizationId { get; set; }
        public string Name { get; set; } = string.Empty;
        public string Subject { get; set; } = string.Empty;
        public string PreviewText { get; set; } = string.Empty;
        public string HtmlContent { get; set; } = string.Empty;
        public string PlainTextContent { get; set; } = string.Empty;
        public string FromName { get; set; } = string.Empty;
        public string FromEmail { get; set; } = string.Empty;
        public string ReplyTo { get; set; } = string.Empty;
        public CampaignStatus Status { get; set; }
        public List<long> TargetListIds { get; set; } = new();
        public List<long> TargetSegmentIds { get; set; } = new();
        public List<long> ExcludeSegmentIds { get; set; } = new();
        public DateTime? ScheduledAt { get; set; }
        public string? TimeZone { get; set; }
        public DateTime CreatedAt { get; set; }
        public DateTime UpdatedAt { get; set; }
        public int Version { get; set; }
    }

    public interface ICampaignService
    {
        Task<Campaign> CreateCampaignAsync(
            long orgId, string name, string subject,
            string fromName, string fromEmail);
        Task<CampaignScheduleResult> ScheduleCampaignAsync(
            long orgId, long campaignId,
            DateTime scheduledAt, string timeZone);
        Task<CampaignSendResult> SendCampaignAsync(
            long orgId, long campaignId);
    }

    public class CampaignService : ICampaignService
    {
        private readonly string _connectionString;
        private readonly IConnectionMultiplexer _redis;
        private readonly IProducer<string, string> _kafkaProducer;
        private readonly ILogger<CampaignService> _logger;
        private readonly IDeliverabilityMonitor _monitor;
        private readonly ISubscriberService _subscribers;

        private const string CachePrefix = "campaign:";
        private const string SendTopic = "campaign.send";
        private const int MaxScheduledPerDay = 10;

        public CampaignService(
            string connectionString,
            IConnectionMultiplexer redis,
            IProducer<string, string> kafkaProducer,
            ILogger<CampaignService> logger,
            IDeliverabilityMonitor monitor,
            ISubscriberService subscribers)
        {
            _connectionString = connectionString;
            _redis = redis;
            _kafkaProducer = kafkaProducer;
            _logger = logger;
            _monitor = monitor;
            _subscribers = subscribers;
        }

        public async Task<Campaign> CreateCampaignAsync(
            long orgId, string name, string subject,
            string fromName, string fromEmail)
        {
            if (string.IsNullOrWhiteSpace(name))
                throw new ValidationException("Name required.");
            if (string.IsNullOrWhiteSpace(subject))
                throw new ValidationException("Subject required.");

            await using var conn =
                new NpgsqlConnection(_connectionString);
            await conn.OpenAsync();

            const string sql = @"
                INSERT INTO campaigns
                    (organization_id, name, subject,
                     from_name, from_email, status,
                     created_at, updated_at, version)
                VALUES
                    (@orgId, @name, @subject,
                     @fromName, @fromEmail, 'draft',
                     NOW(), NOW(), 1)
                RETURNING id, created_at";

            await using var cmd =
                new NpgsqlCommand(sql, conn);
            cmd.Parameters.AddWithValue("@orgId", orgId);
            cmd.Parameters.AddWithValue("@name", name);
            cmd.Parameters.AddWithValue("@subject", subject);
            cmd.Parameters.AddWithValue("@fromName", fromName);
            cmd.Parameters.AddWithValue("@fromEmail", fromEmail);

            await using var reader =
                await cmd.ExecuteReaderAsync();
            await reader.ReadAsync();

            return new Campaign
            {
                Id = reader.GetInt64(0),
                OrganizationId = orgId,
                Name = name,
                Subject = subject,
                FromName = fromName,
                FromEmail = fromEmail,
                Status = CampaignStatus.Draft,
                CreatedAt = reader.GetDateTime(1),
                Version = 1
            };
        }

        public async Task<CampaignScheduleResult>
            ScheduleCampaignAsync(
                long orgId, long campaignId,
                DateTime scheduledAt, string timeZone)
        {
            if (scheduledAt <= DateTime.UtcNow)
                throw new ValidationException(
                    "Must be in the future.");

            var dailyCount =
                await GetDailyCountAsync(orgId, scheduledAt.Date);
            if (dailyCount >= MaxScheduledPerDay)
                throw new RateLimitException(
                    "Max campaigns per day reached.");

            var recipients =
                await _subscribers.GetTargetRecipientCountAsync(
                    orgId, new List<long>(),
                    new List<long>(), new List<long>());

            await using var conn =
                new NpgsqlConnection(_connectionString);
            await conn.OpenAsync();
            await using var tx =
                await conn.BeginTransactionAsync();

            try
            {
                const string sql = @"
                    UPDATE campaigns
                    SET status = 'scheduled',
                        scheduled_at = @scheduledAt,
                        timezone = @tz,
                        updated_at = NOW(),
                        version = version + 1
                    WHERE id = @cId
                      AND organization_id = @orgId
                    RETURNING id";

                var cmd =
                    new NpgsqlCommand(sql, conn, tx);
                cmd.Parameters.AddWithValue(
                    "@cId", campaignId);
                cmd.Parameters.AddWithValue("@orgId", orgId);
                cmd.Parameters.AddWithValue(
                    "@scheduledAt", scheduledAt);
                cmd.Parameters.AddWithValue(
                    "@tz", timeZone ?? "UTC");

                var updated =
                    await cmd.ExecuteScalarAsync();
                if (updated == null)
                    throw new ConcurrencyException(
                        "Campaign was modified.");

                const string mSql = @"
                    INSERT INTO campaign_metrics
                        (campaign_id, recipient_count,
                         updated_at)
                    VALUES (@cId, @rc, NOW())
                    ON CONFLICT (campaign_id) DO UPDATE
                    SET recipient_count = @rc,
                        updated_at = NOW()";

                var mCmd =
                    new NpgsqlCommand(mSql, conn, tx);
                mCmd.Parameters.AddWithValue(
                    "@cId", campaignId);
                mCmd.Parameters.AddWithValue("@rc", recipients);
                await mCmd.ExecuteNonQueryAsync();

                await tx.CommitAsync();
                await InvalidateCacheAsync(campaignId);

                return new CampaignScheduleResult
                {
                    CampaignId = campaignId,
                    ScheduledAt = scheduledAt,
                    RecipientCount = recipients
                };
            }
            catch
            {
                await tx.RollbackAsync();
                throw;
            }
        }

        public async Task<CampaignSendResult>
            SendCampaignAsync(long orgId, long campaignId)
        {
            var canSend =
                await _monitor.CanSendCampaignAsync(orgId);
            if (!canSend)
                throw new DeliverabilityBlockedException(
                    "Blocked by deliverability monitor.");

            var ips =
                await _monitor.GetAvailableSendingIpsAsync();
            if (ips.Count == 0)
                throw new InfrastructureException(
                    "No sending IPs available.");

            var recipients =
                await _subscribers.GetTargetRecipientCountAsync(
                    orgId, new List<long>(),
                    new List<long>(), new List<long>());

            await using var conn =
                new NpgsqlConnection(_connectionString);
            await conn.OpenAsync();

            const string sql = @"
                UPDATE campaigns
                SET status = 'sending',
                    updated_at = NOW(),
                    version = version + 1
                WHERE id = @cId
                  AND organization_id = @orgId
                RETURNING id";

            await using var cmd =
                new NpgsqlCommand(sql, conn);
            cmd.Parameters.AddWithValue("@cId", campaignId);
            cmd.Parameters.AddWithValue("@orgId", orgId);

            var updated = await cmd.ExecuteScalarAsync();
            if (updated == null)
                throw new ConcurrencyException(
                    "Campaign was modified.");

            var batches =
                (int)Math.Ceiling(recipients / 10000.0);
            for (int i = 0; i < batches; i++)
            {
                var evt = new
                {
                    CampaignId = campaignId,
                    OrgId = orgId,
                    Batch = i,
                    Size = 10000,
                    Ip = ips[i % ips.Count],
                    Ts = DateTime.UtcNow
                };
                var json = JsonSerializer.Serialize(evt);
                await _kafkaProducer.ProduceAsync(
                    SendTopic,
                    new Message<string, string>
                    {
                        Key = campaignId.ToString(),
                        Value = json
                    });
            }

            await InvalidateCacheAsync(campaignId);
            return new CampaignSendResult
            {
                CampaignId = campaignId,
                RecipientCount = recipients,
                BatchCount = batches
            };
        }

        private async Task<int> GetDailyCountAsync(
            long orgId, DateTime date)
        {
            var db = _redis.GetDatabase();
            var key =
                $"{CachePrefix}sched:{orgId}:{date:yyyy-MM-dd}";
            var c = await db.StringGetAsync(key);
            if (c.HasValue) return (int)c;

            await using var conn =
                new NpgsqlConnection(_connectionString);
            await conn.OpenAsync();

            const string sql = @"
                SELECT COUNT(*) FROM campaigns
                WHERE organization_id = @orgId
                  AND status = 'scheduled'
                  AND scheduled_at::date = @d";

            await using var cmd =
                new NpgsqlCommand(sql, conn);
            cmd.Parameters.AddWithValue("@orgId", orgId);
            cmd.Parameters.AddWithValue("@d", date);

            var count = Convert.ToInt32(
                await cmd.ExecuteScalarAsync());
            await db.StringSetAsync(
                key, count, TimeSpan.FromMinutes(5));
            return count;
        }

        private async Task InvalidateCacheAsync(long cId)
        {
            var db = _redis.GetDatabase();
            await db.KeyDeleteAsync($"{CachePrefix}{cId}");
        }
    }

    public class CampaignScheduleResult
    {
        public long CampaignId { get; set; }
        public DateTime ScheduledAt { get; set; }
        public int RecipientCount { get; set; }
    }

    public class CampaignSendResult
    {
        public long CampaignId { get; set; }
        public int RecipientCount { get; set; }
        public int BatchCount { get; set; }
    }
}

AutomationEngine — Workflow Execution

C#
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Npgsql;
using StackExchange.Redis;
using Confluent.Kafka;

namespace MailerLite.Core.Automation
{
    public enum NodeStatus
    {
        Running, Suspended, Completed, Exited, Failed
    }

    public enum NodeType
    {
        Trigger, Delay, Condition,
        SendEmail, UpdateSubscriber,
        Webhook, Exit
    }

    public class AutomationNode
    {
        public string NodeId { get; set; } = string.Empty;
        public NodeType Type { get; set; }
        public string? NextNodeId { get; set; }
        public string? TrueBranch { get; set; }
        public string? FalseBranch { get; set; }
        public JsonElement Config { get; set; }
    }

    public class AutomationExecution
    {
        public long Id { get; set; }
        public long AutomationId { get; set; }
        public long SubscriberId { get; set; }
        public string CurrentNodeId { get; set; } = "";
        public NodeStatus Status { get; set; }
        public DateTime? ScheduledAt { get; set; }
        public int Version { get; set; }
    }

    public interface IAutomationEngine
    {
        Task<AutomationExecution> StartExecutionAsync(
            long autoId, long subId, string trigger);
        Task<AutomationExecution> AdvanceAsync(
            long execId);
        Task ProcessSuspendedBatchAsync(
            int batchSize = 100);
    }

    public class AutomationEngine : IAutomationEngine
    {
        private readonly string _cs;
        private readonly IConnectionMultiplexer _redis;
        private readonly IProducer<string, string> _kafka;
        private readonly ILogger<AutomationEngine> _log;
        private const string EmailTopic = "email.send";

        public AutomationEngine(
            string cs,
            IConnectionMultiplexer redis,
            IProducer<string, string> kafka,
            ILogger<AutomationEngine> log)
        {
            _cs = cs;
            _redis = redis;
            _kafka = kafka;
            _log = log;
        }

        public async Task<AutomationExecution>
            StartExecutionAsync(
                long autoId, long subId, string trigger)
        {
            var entryOk =
                await CheckEntryConditionsAsync(
                    autoId, subId);
            if (!entryOk) return null;

            var firstNode =
                await GetFirstNodeAsync(autoId);
            if (firstNode == null) return null;

            await using var conn =
                new NpgsqlConnection(_cs);
            await conn.OpenAsync();

            var pathLog = JsonSerializer.Serialize(
                new[] {
                    new {
                        node_id = firstNode.NodeId,
                        ts = DateTime.UtcNow,
                        event = trigger
                    }
                });

            const string sql = @"
                INSERT INTO automation_executions
                    (automation_id, subscriber_id,
                     current_node_id, status,
                     context, path_log, version)
                VALUES
                    (@aId, @sId, @nId, 'running',
                     @ctx, @path, 1)
                RETURNING id";

            await using var cmd =
                new NpgsqlCommand(sql, conn);
            cmd.Parameters.AddWithValue("@aId", autoId);
            cmd.Parameters.AddWithValue("@sId", subId);
            cmd.Parameters.AddWithValue(
                "@nId", firstNode.NodeId);
            cmd.Parameters.AddWithValue("@ctx",
                JsonSerializer.Serialize(
                    new { trigger }));
            cmd.Parameters.AddWithValue("@path", pathLog);

            var id = (long)(await cmd.ExecuteScalarAsync());
            _log.LogInformation(
                "Auto exec started: {Id}, auto {A}, sub {S}",
                id, autoId, subId);

            return new AutomationExecution
            {
                Id = id,
                AutomationId = autoId,
                SubscriberId = subId,
                CurrentNodeId = firstNode.NodeId,
                Status = NodeStatus.Running
            };
        }

        public async Task<AutomationExecution>
            AdvanceAsync(long execId)
        {
            var exec = await GetExecAsync(execId);
            if (exec == null ||
                exec.Status != NodeStatus.Running)
                return exec;

            var node = await GetNodeAsync(
                exec.AutomationId, exec.CurrentNodeId);
            if (node == null)
            {
                await CompleteAsync(execId);
                return exec;
            }

            switch (node.Type)
            {
                case NodeType.SendEmail:
                    return await HandleEmail(
                        exec, node);
                case NodeType.Delay:
                    return await HandleDelay(
                        exec, node);
                case NodeType.Condition:
                    return await HandleCondition(
                        exec, node);
                case NodeType.UpdateSubscriber:
                    return await HandleUpdate(
                        exec, node);
                case NodeType.Exit:
                    await CompleteAsync(execId);
                    return exec;
                default:
                    if (!string.IsNullOrEmpty(
                        node.NextNodeId))
                    {
                        await MoveToNodeAsync(
                            execId, node.NextNodeId);
                        exec.CurrentNodeId =
                            node.NextNodeId;
                    }
                    else
                    {
                        await CompleteAsync(execId);
                        exec.Status = NodeStatus.Completed;
                    }
                    return exec;
            }
        }

        private async Task<AutomationExecution>
            HandleEmail(
                AutomationExecution exec,
                AutomationNode node)
        {
            var campaignId = node.Config
                .GetProperty("campaign_id").GetInt64();
            var evt = new
            {
                CampaignId = campaignId,
                SubscriberId = exec.SubscriberId,
                ExecId = exec.Id,
                NodeId = exec.CurrentNodeId,
                Ts = DateTime.UtcNow
            };
            await _kafka.ProduceAsync(EmailTopic,
                new Message<string, string>
                {
                    Key = exec.SubscriberId.ToString(),
                    Value = JsonSerializer.Serialize(evt)
                });

            await LogStepAsync(
                exec.Id, exec.CurrentNodeId, "email_sent");

            if (!string.IsNullOrEmpty(node.NextNodeId))
            {
                await MoveToNodeAsync(
                    exec.Id, node.NextNodeId);
                exec.CurrentNodeId = node.NextNodeId;
            }
            else
            {
                await CompleteAsync(exec.Id);
                exec.Status = NodeStatus.Completed;
            }
            return exec;
        }

        private async Task<AutomationExecution>
            HandleDelay(
                AutomationExecution exec,
                AutomationNode node)
        {
            var mins = node.Config
                .GetProperty("delay_minutes").GetInt32();
            var schedAt =
                DateTime.UtcNow.AddMinutes(mins);

            await using var conn =
                new NpgsqlConnection(_cs);
            await conn.OpenAsync();

            const string sql = @"
                UPDATE automation_executions
                SET status = 'suspended',
                    scheduled_at = @sAt,
                    updated_at = NOW(),
                    version = version + 1
                WHERE id = @eId AND version = @v";

            await using var cmd =
                new NpgsqlCommand(sql, conn);
            cmd.Parameters.AddWithValue("@eId", exec.Id);
            cmd.Parameters.AddWithValue("@sAt", schedAt);
            cmd.Parameters.AddWithValue("@v", exec.Version);
            await cmd.ExecuteNonQueryAsync();

            exec.Status = NodeStatus.Suspended;
            exec.ScheduledAt = schedAt;
            return exec;
        }

        private async Task<AutomationExecution>
            HandleCondition(
                AutomationExecution exec,
                AutomationNode node)
        {
            var result = await EvalConditionAsync(
                exec.SubscriberId, node.Config);

            var next = result
                ? node.TrueBranch
                : node.FalseBranch;

            await LogStepAsync(exec.Id,
                exec.CurrentNodeId,
                result ? "true" : "false");

            if (!string.IsNullOrEmpty(next))
            {
                await MoveToNodeAsync(exec.Id, next);
                exec.CurrentNodeId = next;
            }
            else
            {
                await CompleteAsync(exec.Id);
                exec.Status = NodeStatus.Completed;
            }
            return exec;
        }

        private async Task<AutomationExecution>
            HandleUpdate(
                AutomationExecution exec,
                AutomationNode node)
        {
            var action = node.Config
                .GetProperty("action").GetString();
            var value = node.Config
                .GetProperty("value").GetString();

            _log.LogInformation(
                "Auto update: exec {E}, action {A}, val {V}",
                exec.Id, action, value);

            await LogStepAsync(exec.Id,
                exec.CurrentNodeId,
                $"update_{action}");

            if (!string.IsNullOrEmpty(node.NextNodeId))
            {
                await MoveToNodeAsync(
                    exec.Id, node.NextNodeId);
                exec.CurrentNodeId = node.NextNodeId;
            }
            else
            {
                await CompleteAsync(exec.Id);
                exec.Status = NodeStatus.Completed;
            }
            return exec;
        }

        public async Task ProcessSuspendedBatchAsync(
            int batchSize = 100)
        {
            await using var conn =
                new NpgsqlConnection(_cs);
            await conn.OpenAsync();

            const string sql = @"
                SELECT id FROM automation_executions
                WHERE status = 'suspended'
                  AND scheduled_at <= NOW()
                ORDER BY scheduled_at
                LIMIT @limit";

            await using var cmd =
                new NpgsqlCommand(sql, conn);
            cmd.Parameters.AddWithValue("@limit", batchSize);

            var ids = new List<long>();
            await using var reader =
                await cmd.ExecuteReaderAsync();
            while (await reader.ReadAsync())
                ids.Add(reader.GetInt64(0));

            foreach (var id in ids)
            {
                try
                {
                    await AdvanceAsync(id);
                }
                catch (Exception ex)
                {
                    _log.LogError(ex,
                        "Failed exec {Id}", id);
                }
            }
        }

        private async Task<bool>
            CheckEntryConditionsAsync(
                long autoId, long subId) => true;

        private async Task<bool>
            EvalConditionAsync(
                long subId, JsonElement cfg) => true;

        private async Task<AutomationNode>
            GetFirstNodeAsync(long autoId)
        {
            return new AutomationNode
            {
                NodeId = "node_1",
                Type = NodeType.SendEmail
            };
        }

        private async Task<AutomationNode>
            GetNodeAsync(long autoId, string nodeId)
        {
            return new AutomationNode
            {
                NodeId = nodeId,
                Type = NodeType.SendEmail,
                NextNodeId = null
            };
        }

        private async Task<AutomationExecution>
            GetExecAsync(long execId)
        {
            return new AutomationExecution
            {
                Id = execId,
                Status = NodeStatus.Running,
                CurrentNodeId = "node_1"
            };
        }

        private async Task MoveToNodeAsync(
            long execId, string nodeId)
        {
            var db = _redis.GetDatabase();
            await db.KeyDeleteAsync($"ae:{execId}");
        }

        private async Task CompleteAsync(long execId)
        {
            await using var conn =
                new NpgsqlConnection(_cs);
            await conn.OpenAsync();
            const string sql = @"
                UPDATE automation_executions
                SET status = 'completed',
                    updated_at = NOW()
                WHERE id = @eId";
            await using var cmd =
                new NpgsqlCommand(sql, conn);
            cmd.Parameters.AddWithValue("@eId", execId);
            await cmd.ExecuteNonQueryAsync();
        }

        private async Task LogStepAsync(
            long execId, string nodeId, string evt)
        {
            var db = _redis.GetDatabase();
            await db.ListRightPushAsync(
                $"ae:log:{execId}",
                JsonSerializer.Serialize(new
                {
                    node = nodeId,
                    event = evt,
                    ts = DateTime.UtcNow
                }));
        }
    }
}

DeliverabilityMonitor — IP Reputation & Bounce Processing

C#
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Npgsql;
using StackExchange.Redis;

namespace MailerLite.Core.Deliverability
{
    public enum IpTier { Cold, Warm, Hot, Dedicated }

    public class SendingIp
    {
        public string IpAddress { get; set; } = "";
        public string Domain { get; set; } = "";
        public IpTier Tier { get; set; }
        public int DailyVolume { get; set; }
        public int MaxDailyVolume { get; set; }
        public double BounceRate { get; set; }
        public double ComplaintRate { get; set; }
        public bool IsActive { get; set; } = true;
        public int ConsecutiveCleanDays { get; set; }
    }

    public interface IDeliverabilityMonitor
    {
        Task<bool> CanSendCampaignAsync(long orgId);
        Task<List<string>> GetAvailableSendingIpsAsync();
        Task<void> ProcessBounceAsync(BounceEvent ev);
        Task<void> ProcessComplaintAsync(ComplaintEvent ev);
    }

    public class DeliverabilityMonitor :
        IDeliverabilityMonitor
    {
        private readonly string _cs;
        private readonly IConnectionMultiplexer _redis;
        private readonly ILogger<DeliverabilityMonitor> _log;

        private const double MaxBounce = 0.03;
        private const double MaxComplaint = 0.001;
        private const int SoftBounceLimit = 3;
        private const int BounceWindowDays = 7;

        public DeliverabilityMonitor(
            string cs,
            IConnectionMultiplexer redis,
            ILogger<DeliverabilityMonitor> log)
        {
            _cs = cs;
            _redis = redis;
            _log = log;
        }

        public async Task<bool> CanSendCampaignAsync(
            long orgId)
        {
            var db = _redis.GetDatabase();

            var brKey = $"dm:org:{orgId}:bounce_rate";
            var crKey = $"dm:org:{orgId}:complaint_rate";
            var probKey = $"dm:org:{orgId}:probation";

            var br = await db.StringGetAsync(brKey);
            var cr = await db.StringGetAsync(crKey);
            var prob = await db.StringGetAsync(probKey);

            if (br.HasValue &&
                double.Parse(br) > MaxBounce)
            {
                _log.LogWarning(
                    "Org {O} blocked: bounce rate {R}",
                    orgId, br);
                return false;
            }

            if (cr.HasValue &&
                double.Parse(cr) > MaxComplaint)
            {
                _log.LogWarning(
                    "Org {O} blocked: complaint rate {R}",
                    orgId, cr);
                return false;
            }

            if (prob.HasValue &&
                (string)prob == "1")
            {
                _log.LogWarning(
                    "Org {O} on probation", orgId);
                return false;
            }

            return true;
        }

        public async Task<List<string>>
            GetAvailableSendingIpsAsync()
        {
            var db = _redis.GetDatabase();
            var keys = await db.SetMembersAsync(
                "dm:active_ips");
            var result = new List<string>();

            foreach (var key in keys)
            {
                var ip = (string)key;
                var volKey =
                    $"dm:ip:{ip}:volume:" +
                    $"{DateTime.UtcNow:yyyy-MM-dd}";
                var vol = await db.StringGetAsync(volKey);
                var maxVol = await db.HashGetAsync(
                    $"dm:ip:{ip}:config", "max_daily");

                if (vol.HasValue && maxVol.HasValue &&
                    int.Parse(vol) >= int.Parse(maxVol))
                    continue;

                var br = await db.HashGetAsync(
                    $"dm:ip:{ip}:config", "bounce_rate");
                if (br.HasValue &&
                    double.Parse(br) > MaxBounce)
                    continue;

                result.Add(ip);
            }

            return result
                .OrderBy(x =>
                {
                    var v = db.StringGetAsync(
                        $"dm:ip:{x}:volume:" +
                        $"{DateTime.UtcNow:yyyy-MM-dd}")
                        .Result;
                    return v.HasValue ? (int)v : 0;
                })
                .ToList();
        }

        public async Task<void> ProcessBounceAsync(
            BounceEvent ev)
        {
            var db = _redis.GetDatabase();

            if (ev.IsHardBounce)
            {
                await SetStatusAsync(
                    ev.SubscriberId, "bounced");
                await db.StringIncrementAsync(
                    $"dm:org:{ev.OrgId}:hard_bounces");

                _log.LogInformation(
                    "Hard bounce: sub {S}, email {E}",
                    ev.SubscriberId, ev.Email);
            }
            else
            {
                var key =
                    $"dm:ip:{ev.SendingIp}:" +
                    $"soft:{ev.SubscriberId}";
                var count =
                    await db.StringIncrementAsync(key);
                await db.KeyExpireAsync(key,
                    TimeSpan.FromDays(BounceWindowDays));

                if (count >= SoftBounceLimit)
                {
                    await SetStatusAsync(
                        ev.SubscriberId, "bounced");
                    _log.LogInformation(
                        "Soft bounce limit: sub {S}",
                        ev.SubscriberId);
                }
            }

            await UpdateIpMetricAsync(
                ev.SendingIp, "bounce");
            await RecordEventAsync(ev);
        }

        public async Task<void> ProcessComplaintAsync(
            ComplaintEvent ev)
        {
            await SetStatusAsync(
                ev.SubscriberId, "complained");
            await UpdateIpMetricAsync(
                ev.SendingIp, "complaint");

            var db = _redis.GetDatabase();
            await db.StringIncrementAsync(
                $"dm:org:{ev.OrgId}:complaints");

            var rc = await db.StringGetAsync(
                $"dm:org:{ev.OrgId}:complaints");
            var ts = await db.StringGetAsync(
                $"dm:org:{ev.OrgId}:total_subs");

            if (rc.HasValue && ts.HasValue &&
                int.Parse(ts) > 0)
            {
                var rate = (double)int.Parse(rc) /
                    int.Parse(ts);
                if (rate > MaxComplaint)
                {
                    await db.StringSetAsync(
                        $"dm:org:{ev.OrgId}:probation",
                        "1");
                    _log.LogWarning(
                        "Org {O} on probation: {R:P3}",
                        ev.OrgId, rate);
                }
            }

            _log.LogWarning(
                "Complaint: sub {S}, ip {I}",
                ev.SubscriberId, ev.SendingIp);
        }

        private async Task SetStatusAsync(
            long subId, string status)
        {
            await using var conn =
                new NpgsqlConnection(_cs);
            await conn.OpenAsync();

            const string sql = @"
                UPDATE subscribers
                SET status = @st, updated_at = NOW()
                WHERE id = @sId";
            await using var cmd =
                new NpgsqlCommand(sql, conn);
            cmd.Parameters.AddWithValue("@st", status);
            cmd.Parameters.AddWithValue("@sId", subId);
            await cmd.ExecuteNonQueryAsync();
        }

        private async Task UpdateIpMetricAsync(
            string ip, string type)
        {
            var db = _redis.GetDatabase();
            var day = DateTime.UtcNow.ToString("yyyy-MM-dd");

            await db.StringIncrementAsync(
                $"dm:ip:{ip}:volume:{day}");
            await db.KeyExpireAsync(
                $"dm:ip:{ip}:volume:{day}",
                TimeSpan.FromDays(30));

            var key =
                $"dm:ip:{ip}:{type}s:{day}";
            await db.StringIncrementAsync(key);
            await db.KeyExpireAsync(key,
                TimeSpan.FromDays(30));
        }

        private async Task RecordEventAsync(
            BounceEvent ev)
        {
            await using var conn =
                new NpgsqlConnection(_cs);
            await conn.OpenAsync();

            const string sql = @"
                INSERT INTO bounce_log
                    (subscriber_id, email, sending_ip,
                     bounce_type, created_at)
                VALUES
                    (@sId, @email, @ip,
                     @bt, NOW())";
            await using var cmd =
                new NpgsqlCommand(sql, conn);
            cmd.Parameters.AddWithValue(
                "@sId", ev.SubscriberId);
            cmd.Parameters.AddWithValue("@email", ev.Email);
            cmd.Parameters.AddWithValue("@ip", ev.SendingIp);
            cmd.Parameters.AddWithValue("@bt",
                ev.IsHardBounce ? "hard" : "soft");
            await cmd.ExecuteNonQueryAsync();
        }
    }

    public class BounceEvent
    {
        public long SubscriberId { get; set; }
        public long OrgId { get; set; }
        public string Email { get; set; } = "";
        public string SendingIp { get; set; } = "";
        public bool IsHardBounce { get; set; }
    }

    public class ComplaintEvent
    {
        public long SubscriberId { get; set; }
        public long OrgId { get; set; }
        public string Email { get; set; } = "";
        public string SendingIp { get; set; } = "";
    }
}

SubscriberService — CRUD & Segmentation

C#
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Npgsql;
using StackExchange.Redis;

namespace MailerLite.Core.Subscribers
{
    public enum SubStatus
    {
        Active, Unsubscribed, Bounced,
        Complained, Cleaned
    }

    public class Subscriber
    {
        public long Id { get; set; }
        public long OrgId { get; set; }
        public string Email { get; set; } = "";
        public SubStatus Status { get; set; }
        public DateTime SubscribedAt { get; set; }
        public DateTime? LastOpenedAt { get; set; }
        public DateTime? LastClickedAt { get; set; }
        public string? Source { get; set; }
        public JsonElement CustomFields { get; set; }
        public List<string> Tags { get; set; } = new();
        public List<long> ListIds { get; set; } = new();
    }

    public interface ISubscriberService
    {
        Task<Subscriber> CreateAsync(
            long orgId, string email,
            Dictionary<string, string> fields,
            List<long> listIds, string? source);
        Task<Subscriber> GetByIdAsync(long id);
        Task<Subscriber> GetByEmailAsync(
            long orgId, string email);
        Task<int> GetTargetRecipientCountAsync(
            long orgId,
            List<long> lists,
            List<long> segments,
            List<long> excludeSegs);
        Task<void> AddTagAsync(
            long subId, string tag);
        Task<void> RemoveTagAsync(
            long subId, string tag);
        Task<void> UpdateFieldAsync(
            long subId,
            string field, string value);
    }

    public class SubscriberService :
        ISubscriberService
    {
        private readonly string _cs;
        private readonly IConnectionMultiplexer _redis;
        private readonly ILogger<SubscriberService> _log;
        private const string CachePrefix = "sub:";

        public SubscriberService(
            string cs,
            IConnectionMultiplexer redis,
            ILogger<SubscriberService> log)
        {
            _cs = cs;
            _redis = redis;
            _log = log;
        }

        public async Task<Subscriber> CreateAsync(
            long orgId, string email,
            Dictionary<string, string> fields,
            List<long> listIds, string? source)
        {
            if (string.IsNullOrWhiteSpace(email))
                throw new ValidationException(
                    "Email required.");

            var existing =
                await GetByEmailAsync(orgId, email);
            if (existing != null) return existing;

            await using var conn =
                new NpgsqlConnection(_cs);
            await conn.OpenAsync();
            await using var tx =
                await conn.BeginTransactionAsync();

            try
            {
                var cf = fields != null
                    ? JsonSerializer.Serialize(fields)
                    : "{}";

                const string sql = @"
                    INSERT INTO subscribers
                        (organization_id, email, status,
                         subscribed_at, source,
                         custom_fields, created_at,
                         updated_at)
                    VALUES
                        (@orgId, @email, 'active',
                         NOW(), @src, @cf::jsonb,
                         NOW(), NOW())
                    RETURNING id, subscribed_at";

                await using var cmd =
                    new NpgsqlCommand(sql, conn, tx);
                cmd.Parameters.AddWithValue(
                    "@orgId", orgId);
                cmd.Parameters.AddWithValue(
                    "@email", email.ToLowerInvariant());
                cmd.Parameters.AddWithValue(
                    "@src", source ?? "api");
                cmd.Parameters.AddWithValue("@cf", cf);

                await using var r =
                    await cmd.ExecuteReaderAsync();
                await r.ReadAsync();

                var subId = r.GetInt64(0);
                var subAt = r.GetDateTime(1);

                if (listIds != null)
                {
                    foreach (var lid in listIds)
                    {
                        const string ls = @"
                            INSERT INTO subscriber_lists
                                (subscriber_id, list_id,
                                 status, subscribed_at,
                                 source)
                            VALUES
                                (@sId, @lId, 'active',
                                 NOW(), @src)
                            ON CONFLICT
                                (subscriber_id, list_id)
                            DO NOTHING";

                        await using var lc =
                            new NpgsqlCommand(ls, conn, tx);
                        lc.Parameters.AddWithValue(
                            "@sId", subId);
                        lc.Parameters.AddWithValue(
                            "@lId", lid);
                        lc.Parameters.AddWithValue(
                            "@src", source ?? "api");
                        await lc.ExecuteNonQueryAsync();
                    }
                }

                await tx.CommitAsync();
                await InvalidateCacheAsync(subId);

                return new Subscriber
                {
                    Id = subId,
                    OrgId = orgId,
                    Email = email.ToLowerInvariant(),
                    Status = SubStatus.Active,
                    SubscribedAt = subAt,
                    Source = source,
                    ListIds = listIds ?? new()
                };
            }
            catch
            {
                await tx.RollbackAsync();
                throw;
            }
        }

        public async Task<Subscriber> GetByIdAsync(
            long id)
        {
            var db = _redis.GetDatabase();
            var key = $"{CachePrefix}{id}";
            var cached = await db.StringGetAsync(key);
            if (cached.HasValue)
                return JsonSerializer
                    .Deserialize<Subscriber>(cached);

            await using var conn =
                new NpgsqlConnection(_cs);
            await conn.OpenAsync();

            const string sql = @"
                SELECT id, organization_id, email,
                       status, subscribed_at,
                       last_opened_at, last_clicked_at,
                       source, custom_fields
                FROM subscribers WHERE id = @sId";

            await using var cmd =
                new NpgsqlCommand(sql, conn);
            cmd.Parameters.AddWithValue("@sId", id);
            await using var r =
                await cmd.ExecuteReaderAsync();
            if (!await r.ReadAsync()) return null;

            var sub = new Subscriber
            {
                Id = r.GetInt64(0),
                OrgId = r.GetInt64(1),
                Email = r.GetString(2),
                Status = Enum.Parse<SubStatus>(
                    r.GetString(3), true),
                SubscribedAt = r.GetDateTime(4),
                LastOpenedAt = r.IsDBNull(5)
                    ? null : r.GetDateTime(5),
                LastClickedAt = r.IsDBNull(6)
                    ? null : r.GetDateTime(6),
                Source = r.IsDBNull(7)
                    ? null : r.GetString(7),
                CustomFields = r.IsDBNull(8)
                    ? JsonDocument.Parse("{}").RootElement
                    : JsonSerializer
                        .Deserialize<JsonElement>(
                            r.GetString(8))
            };

            await db.StringSetAsync(
                key,
                JsonSerializer.Serialize(sub),
                TimeSpan.FromMinutes(5));
            return sub;
        }

        public async Task<int>
            GetTargetRecipientCountAsync(
                long orgId,
                List<long> lists,
                List<long> segments,
                List<long> excludeSegs)
        {
            await using var conn =
                new NpgsqlConnection(_cs);
            await conn.OpenAsync();

            var sql = @"
                SELECT COUNT(DISTINCT s.id)
                FROM subscribers s";

            if (lists != null && lists.Count > 0)
                sql += @"
                    INNER JOIN subscriber_lists sl
                      ON sl.subscriber_id = s.id
                      AND sl.list_id = ANY(@lists)";

            if (segments != null && segments.Count > 0)
                sql += @"
                    INNER JOIN
                      subscriber_segments_materialized ssm
                      ON ssm.subscriber_id = s.id
                      AND ssm.segment_id = ANY(@segs)";

            sql += @"
                WHERE s.organization_id = @orgId
                  AND s.status = 'active'";

            if (excludeSegs != null &&
                excludeSegs.Count > 0)
                sql += @"
                AND s.id NOT IN (
                    SELECT subscriber_id
                    FROM subscriber_segments_materialized
                    WHERE segment_id = ANY(@exc)
                )";

            await using var cmd =
                new NpgsqlCommand(sql, conn);
            cmd.Parameters.AddWithValue("@orgId", orgId);
            if (lists != null && lists.Count > 0)
                cmd.Parameters.AddWithValue("@lists", lists);
            if (segments != null && segments.Count > 0)
                cmd.Parameters.AddWithValue("@segs", segments);
            if (excludeSegs != null &&
                excludeSegs.Count > 0)
                cmd.Parameters.AddWithValue(
                    "@exc", excludeSegs);

            return Convert.ToInt32(
                await cmd.ExecuteScalarAsync());
        }

        public async Task<void> AddTagAsync(
            long subId, string tag)
        {
            await using var conn =
                new NpgsqlConnection(_cs);
            await conn.OpenAsync();

            const string tsql = @"
                INSERT INTO tags (name, created_at)
                VALUES (@tag, NOW())
                ON CONFLICT (name) DO NOTHING";
            await using var tc =
                new NpgsqlCommand(tsql, conn);
            tc.Parameters.AddWithValue("@tag", tag);
            await tc.ExecuteNonQueryAsync();

            const string sql = @"
                INSERT INTO subscriber_tags
                    (subscriber_id, tag_id, applied_at)
                SELECT @sId, id, NOW()
                FROM tags WHERE name = @tag
                ON CONFLICT (subscriber_id, tag_id)
                DO NOTHING";
            await using var cmd =
                new NpgsqlCommand(sql, conn);
            cmd.Parameters.AddWithValue("@sId", subId);
            cmd.Parameters.AddWithValue("@tag", tag);
            await cmd.ExecuteNonQueryAsync();
            await InvalidateCacheAsync(subId);
        }

        public async Task<void> RemoveTagAsync(
            long subId, string tag)
        {
            await using var conn =
                new NpgsqlConnection(_cs);
            await conn.OpenAsync();

            const string sql = @"
                DELETE FROM subscriber_tags
                WHERE subscriber_id = @sId
                  AND tag_id = (
                    SELECT id FROM tags
                    WHERE name = @tag)";
            await using var cmd =
                new NpgsqlCommand(sql, conn);
            cmd.Parameters.AddWithValue("@sId", subId);
            cmd.Parameters.AddWithValue("@tag", tag);
            await cmd.ExecuteNonQueryAsync();
            await InvalidateCacheAsync(subId);
        }

        public async Task<void> UpdateFieldAsync(
            long subId, string field, string value)
        {
            await using var conn =
                new NpgsqlConnection(_cs);
            await conn.OpenAsync();

            const string sql = @"
                UPDATE subscribers
                SET custom_fields = jsonb_set(
                        custom_fields,
                        @path,
                        @val::jsonb),
                    updated_at = NOW()
                WHERE id = @sId";
            await using var cmd =
                new NpgsqlCommand(sql, conn);
            cmd.Parameters.AddWithValue("@sId", subId);
            cmd.Parameters.AddWithValue(
                @"""@path""",
                $"{{{field}}}");
            cmd.Parameters.AddWithValue(
                @"""@val""",
                JsonSerializer.Serialize(value));
            await cmd.ExecuteNonQueryAsync();
            await InvalidateCacheAsync(subId);
        }

        public async Task<Subscriber> GetByEmailAsync(
            long orgId, string email)
        {
            await using var conn =
                new NpgsqlConnection(_cs);
            await conn.OpenAsync();

            const string sql = @"
                SELECT id FROM subscribers
                WHERE organization_id = @orgId
                  AND email = @email";
            await using var cmd =
                new NpgsqlCommand(sql, conn);
            cmd.Parameters.AddWithValue("@orgId", orgId);
            cmd.Parameters.AddWithValue(
                "@email", email.ToLowerInvariant());
            var id = await cmd.ExecuteScalarAsync();
            return id != null
                ? await GetByIdAsync((long)id)
                : null;
        }

        private async Task InvalidateCacheAsync(
            long subId)
        {
            var db = _redis.GetDatabase();
            await db.KeyDeleteAsync(
                $"{CachePrefix}{subId}");
        }
    }
}

23. Conclusion

Designing a complete email platform like MailerLite is a masterclass in distributed systems engineering. Throughout this article, we have dissected every major subsystem — from the block-based email editor that generates email-client-compatible HTML, to the automation engine that orchestrates complex multi-step workflows with guaranteed exactly-once delivery semantics, to the deliverability monitor that maintains sender reputation across hundreds of dedicated IPs. The architecture we have designed is not theoretical — it is grounded in the real-world constraints of serving 650,000+ users who depend on their emails landing in the inbox, not the spam folder.

The key architectural insights from this design are: first, that the separation of OLTP (PostgreSQL) and OLAP (ClickHouse) workloads is not optional at this scale — it is the single most impactful decision for both performance and cost. Second, that Kafka as the event backbone provides the decoupling necessary to handle 15 billion events per month without creating cascading failures between the sending and analytics pipelines. Third, that deliverability is not a feature you bolt on after the fact — it must be designed into the system from the very first IP address, the very first email authentication record, and the very first bounce processor.

The C# implementations we covered demonstrate production-ready patterns for campaign lifecycle management, automation execution with distributed state machines, IP reputation monitoring with multi-tier warmup schedules, and subscriber management with efficient caching strategies. These implementations are not stubs — they include proper transaction handling, optimistic locking for concurrency control, Redis caching with intelligent invalidation, and Kafka event publishing for downstream processing. They represent the kind of code you would find in a mature SaaS platform handling millions of dollars in annual recurring revenue.

For system design interview preparation, this article provides a comprehensive reference that covers the breadth and depth expected at the senior+ level. The 12 Q&A pairs in the interview section address the most common questions interviewers ask about email systems, distributed processing, and scaling strategies. Each answer demonstrates the ability to think about trade-offs — consistency vs. availability, latency vs. throughput, cost vs. reliability — which is what distinguishes a senior engineer from a mid-level one.

Looking forward, the email marketing platform of the future will need to incorporate AI-driven subject line optimization, predictive send time optimization based on individual subscriber behavior patterns, advanced deliverability monitoring using machine learning to detect reputation issues before they impact inbox placement, and real-time personalization engines that can modify email content at the moment of delivery based on current subscriber context (location, device, recent activity). The architecture we have designed provides a solid foundation for these enhancements because of its modular service boundaries and event-driven communication patterns — new capabilities can be added as new services without modifying existing ones.

If you are building an email platform, or preparing for a system design interview that involves email infrastructure, I hope this article has given you a thorough understanding of the architectural decisions, data models, API designs, and implementation patterns that make a platform like MailerLite possible. The code is available as a reference — adapt it to your specific technology stack, scale requirements, and business constraints. The principles, however, are universal: separate your workloads, batch your writes, cache your reads, monitor your deliverability, and always design for the subscriber experience.

Key Takeaway: The difference between a good email platform and a great one is not the number of features — it is the reliability and deliverability of the core email sending pipeline. Build that foundation first, make it bulletproof, and everything else becomes a competitive advantage.

Thank you for reading this deep-dive system design article. If you found it valuable, share it with fellow engineers and architects who are building or evaluating email infrastructure. The more we share knowledge about building reliable, deliverable email systems, the better the entire email ecosystem becomes for senders and recipients alike.

© 2026 Ayodhyya — System Design Series. All rights reserved.

Built with care for engineers who design systems that scale.