system-design69 min read

How to Design a Budget Email Marketing Platform like Brevo — A Senior+ Guide | Ayodhyya

How to Design a Budget Email Marketing Platform like Brevo — A Senior+ Guide

Building unlimited-contact email marketing with SMS, WhatsApp, and transactional email at affordable scale

By Ayodhyya Published: October 21, 2024 Reading time: 45 min

1. Introduction — What Is Brevo?

Brevo, formerly known as Sendinblue, has emerged as one of the most compelling budget-friendly alternatives to enterprise email marketing platforms like Mailchimp, HubSpot, and Klaviyo. What makes Brevo particularly attractive to small and medium businesses is its unique pricing model: unlike most competitors who charge per contact, Brevo offers unlimited contact storage across all plans, charging only based on the number of emails sent per month. Plans start as low as $9 per month for the Starter tier and $18 per month for the Business tier, making it accessible to bootstrapped startups and growing enterprises alike.

The platform is not merely an email marketing tool. It has evolved into a comprehensive multi-channel marketing suite that includes email campaigns, SMS marketing, WhatsApp Business integration, transactional email delivery, marketing automation workflows, a built-in CRM, live chat widgets, landing page builders, and detailed analytics dashboards. This breadth of functionality at such a low price point is what makes the system design behind Brevo fascinating from an engineering perspective.

In this article, we will dissect every major component of a Brevo-like platform from a senior engineering standpoint. We will cover the architecture of a multi-channel campaign engine capable of orchestrating email, SMS, and WhatsApp in a single automated workflow. We will explore how to build a transactional email pipeline that can handle millions of API-driven sends with sub-second latency. We will examine the database schema required to manage unlimited contacts while maintaining query performance, and we will discuss how deliverability infrastructure including shared IPs, dedicated IPs, IP warmup, SPF, DKIM, and DMARC fits into the broader system.

We will also tackle the challenging aspects that are often glossed over in surface-level tutorials: how to design a marketing automation engine that supports visual workflow builders with branching logic, how to implement real-time analytics aggregation for campaign metrics, how to handle GDPR compliance and data residency requirements across multiple regions, and how to estimate infrastructure costs at various scales from 100,000 to 100 million emails per month.

The target audience for this guide is engineers operating at the senior level and above who want to understand how to build or scale a marketing automation platform. Whether you are preparing for a system design interview at a MarTech company, building an internal marketing tool for your organization, or simply curious about the architecture of platforms you use daily, this guide provides a thorough and practical walkthrough.

Key Insight: The core innovation of Brevo is decoupling contact storage from pricing. This seemingly simple business decision has profound technical implications — it means the contact management layer must handle virtually unlimited data volume per account while keeping segment queries performant, which in turn drives the entire database architecture.

By the end of this article, you will have a complete blueprint for designing a multi-channel marketing platform that can serve millions of users, send billions of messages per month, and still maintain the deliverability reputation and compliance posture required to operate in the heavily regulated email and messaging ecosystem. We will provide concrete code examples, database schemas, Mermaid architecture diagrams, and cost calculations so that this serves as both a conceptual guide and a practical implementation reference.

The Brevo model proves that you do not need to charge enterprise prices to build enterprise-grade infrastructure. The engineering challenges are real — managing sender reputation at scale, providing sub-second transactional delivery, orchestrating multi-channel campaigns across email, SMS, and WhatsApp simultaneously, and all while keeping infrastructure costs low enough to sustain a $9 per month plan. Let us dive into how it all works.

2. Functional & Non-Functional Requirements

Functional Requirements

Before diving into architecture, we must clearly enumerate what the platform needs to do. A Brevo-like system requires the following core capabilities, organized by priority tier.

CapabilityDescriptionPriority
Campaign BuilderCreate and schedule email, SMS, and WhatsApp campaigns with a visual editorP0
Multi-Channel SendingSend campaigns across email, SMS, and WhatsApp channels simultaneouslyP0
Transactional EmailAPI-driven transactional email delivery for password resets, order confirmations, and receiptsP0
Contact ManagementUnlimited contacts per account with custom attributes, tags, and listsP0
SegmentationDynamic and static segments based on contact attributes and behavioral eventsP1
Marketing AutomationVisual workflow builder with triggers, conditions, delays, and actionsP1
Email Template EditorDrag-and-drop email builder with responsive pre-built templatesP0
Landing Page BuilderDrag-and-drop landing page creation with form embeddingP2
Forms and Pop-upsEmbeddable forms for lead capture with double opt-in supportP1
CRMBasic CRM with deal tracking, pipeline management, and contact scoringP2
Live ChatEmbeddable chat widget for real-time conversations with website visitorsP2
Analytics DashboardCampaign performance dashboards with open rates, click rates, and revenue attributionP1
API AccessRESTful API for programmatic access to all platform featuresP0
WebhooksEvent-driven webhooks for external integrationsP1
A/B TestingSplit testing for subject lines, content, send times, and sender namesP1
GDPR ToolsConsent management, data export, right-to-erasure workflowP0

Non-Functional Requirements

RequirementTargetRationale
Transactional LatencyP99 less than 2 secondsUsers expect near-instant transactional email delivery
Campaign Throughput10M emails per hourLarge campaigns must complete within hours not days
Availability99.95% uptimeTransactional email must never go down
Contact Scale100M contacts per tenantBrevo's unlimited contacts promise requires extreme scale per account
DeliverabilityInbox placement above 90%Low deliverability destroys platform reputation
Data ResidencyEU and US regionsGDPR requires EU data to stay in EU for many customers
ComplianceGDPR, CAN-SPAM, CCPA, LGPDLegal requirements for email marketing platforms
Event RetentionEvent logs for 2 yearsCompliance and analytics requirements
API Rate LimitingPer-account configurablePrevent abuse and ensure fair resource allocation
Design Trade-off: Unlimited contacts per account is the most challenging non-functional requirement. While the business model charges per email sent and not per contact stored, the engineering cost of storing and segmenting hundreds of millions of contacts per account is substantial. This drives decisions in partitioning, indexing, and caching throughout the entire system.

The distinction between transactional and marketing email is also architecturally significant. Transactional emails are API-driven, low-latency, high-priority, and individually triggered by user actions. Marketing emails are batch-oriented, scheduled, and involve millions of recipients processed simultaneously. These two use cases require fundamentally different pipeline architectures, which we will explore in dedicated sections below.

For multi-channel campaigns, the system must also support coordinating timing across channels. For example, a user might receive an email first, and if they do not open it within 24 hours, the system automatically sends an SMS reminder. This requires a stateful orchestration engine that can track campaign execution across multiple channels and make time-based decisions.

3. Capacity Estimation & Back-of-Envelope

Every good system design starts with back-of-the-envelope calculations to understand the scale of the problem and guide architectural decisions. Let us estimate the key metrics for a Brevo-like platform serving 500,000 paying customers.

Email Volume Estimation

MetricValueCalculation
Total customers500,000Blended free and paid accounts
Average emails sent per month per customer25,000Mix of Starter (10K) and Business (40K) plans
Total emails per month12.5 billion500K multiplied by 25K
Average emails per second~4,80012.5B divided by (30 x 24 x 3600)
Peak emails per second~25,0005x average during peak hours (9-11 AM local)
Transactional percentage of total15%Most volume is marketing campaigns
Marketing email percentage85%Bulk campaigns, newsletters, automations

Contact and Storage Estimation

MetricValueNotes
Average contacts per customer50,000Ranges from 1K (small) to 5M (enterprise)
Total contacts stored25 billion500K multiplied by 50K contacts
Average contact record size2 KBEmail, name, attributes, custom fields
Total contact storage50 TB25B multiplied by 2KB
Event logs per month250 billionAverage 20 events per contact per month
Event log size per month50 TBEach event approximately 200 bytes
Template library size500 GBHTML templates with embedded assets

API and Infrastructure Estimation

MetricValue
API requests per second (management plane)5,000
API requests per second (sending API)25,000
Webhook deliveries per second10,000
SMS messages per month500 million
WhatsApp messages per month200 million
Bandwidth (inbound)500 Mbps average
Bandwidth (outbound)2 Gbps average
Redis memory for caches128 GB cluster
Application servers100 plus instances
Database shards50 plus shards
Key Takeaway: The critical scale challenge is not raw throughput — most cloud providers can handle 25K emails per second with proper queueing. The real challenge is maintaining deliverability reputation at that volume, managing per-customer rate limits, and providing real-time analytics across billions of events.

These numbers paint a picture of a system that is fundamentally IO-bound, not compute-bound. The majority of engineering effort goes into optimizing queue throughput, database write performance for event logs, and cache efficiency for contact segmentation queries. The compute requirements for email content rendering and template processing are comparatively modest.

We should also note the extreme variance in per-customer usage patterns. Some customers send 500 emails per month while others send 50 million. The system must handle this without small customers experiencing performance degradation from noisy neighbors on shared infrastructure. This drives the need for sophisticated rate limiting and resource isolation mechanisms.

Storage growth is another important consideration. At 50 TB of event logs per month, the system accumulates 600 TB per year of raw event data. This requires a tiered storage strategy where hot data stays in fast databases for real-time analytics, warm data moves to columnar stores for reporting, and cold data archives to object storage for compliance retention.

4. Core Data Model

The data model of a Brevo-like platform is organized around several key entities with carefully designed relationships. Let us walk through the most important ones.

Account and User Model

Every platform customer has an account with a plan tier, billing information, and one or more users. The account is the top-level billing and configuration entity that governs rate limits, feature access, and sending quotas.

Account
├── AccountId (GUID, PK)
├── CompanyName (VARCHAR 255)
├── PlanTier (ENUM: Free, Starter, Business, Enterprise)
├── EmailsPerMonth (INT - included in plan)
├── EmailsUsedThisMonth (INT - reset monthly)
├── SmsPerMonth (INT - included in plan)
├── SmsUsedThisMonth (INT)
├── SendingDomain (VARCHAR 255)
├── DkimVerified (BOOLEAN)
├── SpfVerified (BOOLEAN)
├── DmarcPolicy (ENUM: None, Quarantine, Reject)
├── DefaultSenderEmail (VARCHAR 255)
├── DefaultSenderName (VARCHAR 100)
├── TimeZone (VARCHAR 50)
├── SubscriptionStartDate (DATETIME)
├── SubscriptionEndDate (DATETIME)
├── CreatedAt (DATETIME)
└── UpdatedAt (DATETIME)

User
├── UserId (GUID, PK)
├── AccountId (FK to Account)
├── Email (VARCHAR 255, UNIQUE)
├── PasswordHash (VARCHAR 512)
├── FullName (VARCHAR 255)
├── Role (ENUM: Owner, Admin, Editor, Viewer)
├── MfaEnabled (BOOLEAN)
├── LastLoginAt (DATETIME)
├── CreatedAt (DATETIME)
└── UpdatedAt (DATETIME)

Contact Model

The contact model is the most heavily queried entity in the system. Each contact belongs to one or more accounts via a junction table and can be associated with multiple lists and segments. The flexible Attributes field uses JSONB to accommodate varying custom fields across different accounts.

Contact
├── ContactId (GUID, PK)
├── AccountId (FK to Account)
├── Email (VARCHAR 255)
├── PhoneNumber (VARCHAR 50, NULLABLE)
├── WhatsAppOptIn (BOOLEAN, DEFAULT false)
├── SmsOptIn (BOOLEAN, DEFAULT false)
├── EmailOptIn (BOOLEAN, DEFAULT true)
├── Status (ENUM: Active, Unsubscribed, Bounced, Blocked)
├── BounceCount (INT, DEFAULT 0)
├── Attributes (JSONB - custom fields)
├── Tags (TEXT[] - array of tags)
├── LeadScore (INT, 0-100)
├── LastActivityAt (DATETIME)
├── OptedInAt (DATETIME)
├── OptedOutAt (DATETIME, NULLABLE)
├── CreatedAt (DATETIME)
├── UpdatedAt (DATETIME)
└── INDEX on (AccountId, Status, Email)

ContactList (junction table)
├── ContactId (FK to Contact)
├── ListId (FK to List)
├── AddedAt (DATETIME)
├── RemovedAt (DATETIME, NULLABLE)
└── PK on (ContactId, ListId)

List
├── ListId (GUID, PK)
├── AccountId (FK to Account)
├── Name (VARCHAR 255)
├── Type (ENUM: Static, Dynamic)
├── SegmentCriteria (JSONB - for dynamic lists)
├── ContactCount (INT - denormalized, periodically synced)
├── CreatedAt (DATETIME)
└── UpdatedAt (DATETIME)

Campaign Model

Campaign
├── CampaignId (GUID, PK)
├── AccountId (FK to Account)
├── Name (VARCHAR 255)
├── Type (ENUM: Email, SMS, WhatsApp, MultiChannel)
├── Status (ENUM: Draft, Scheduled, Sending, Sent, Paused, Cancelled)
├── Subject (VARCHAR 500, for email)
├── PreviewText (VARCHAR 255)
├── SenderName (VARCHAR 100)
├── SenderEmail (VARCHAR 255)
├── ReplyTo (VARCHAR 255)
├── TemplateId (FK to Template, NULLABLE)
├── Content (TEXT - rendered HTML)
├── TargetListIds (UUID[])
├── ExcludeListIds (UUID[])
├── SegmentCriteria (JSONB)
├── AbTestConfig (JSONB - subject line split test)
├── ScheduledAt (DATETIME)
├── StartedAt (DATETIME)
├── CompletedAt (DATETIME)
├── TotalRecipients (INT)
├── TotalSent (INT)
├── TotalDelivered (INT)
├── TotalOpened (INT)
├── TotalUniqueOpened (INT)
├── TotalClicked (INT)
├── TotalUniqueClicked (INT)
├── TotalBounced (INT)
├── TotalUnsubscribed (INT)
├── TotalSpamReports (INT)
├── CreatedAt (DATETIME)
└── UpdatedAt (DATETIME)

Transactional and Message Models

TransactionalEmail
├── MessageId (GUID, PK)
├── AccountId (FK to Account)
├── TemplateId (FK to Template)
├── RecipientEmail (VARCHAR 255)
├── DynamicData (JSONB - template variables)
├── Priority (ENUM: High, Normal, Low)
├── Status (ENUM: Queued, Sending, Delivered, Bounced, Failed)
├── ErrorCode (VARCHAR 50, NULLABLE)
├── ProviderMessageId (VARCHAR 255)
├── ProviderName (VARCHAR 50)
├── OpenCount (INT, DEFAULT 0)
├── ClickCount (INT, DEFAULT 0)
├── BounceType (ENUM: Hard, Soft, NULLABLE)
├── CreatedAt (DATETIME)
├── SentAt (DATETIME, NULLABLE)
├── DeliveredAt (DATETIME, NULLABLE)
└── INDEX on (AccountId, CreatedAt)

SmsMessage
├── SmsId (GUID, PK)
├── AccountId (FK to Account)
├── CampaignId (FK to Campaign, NULLABLE)
├── AutomationId (FK to Automation, NULLABLE)
├── RecipientPhone (VARCHAR 50)
├── Content (TEXT)
├── Status (ENUM: Queued, Sent, Delivered, Failed, Rejected)
├── ErrorCode (VARCHAR 50, NULLABLE)
├── Carrier (VARCHAR 100)
├── Segment (VARCHAR 50)
├── Cost (DECIMAL 10,4)
├── Currency (VARCHAR 3, DEFAULT 'USD')
├── CreatedAt (DATETIME)
├── SentAt (DATETIME, NULLABLE)
└── DeliveredAt (DATETIME, NULLABLE)

WhatsAppMessage
├── WhatsAppId (GUID, PK)
├── AccountId (FK to Account)
├── CampaignId (FK to Campaign, NULLABLE)
├── RecipientPhone (VARCHAR 50)
├── Direction (ENUM: Inbound, Outbound)
├── TemplateName (VARCHAR 255)
├── TemplateLanguage (VARCHAR 10)
├── Parameters (JSONB)
├── MediaUrl (VARCHAR 1000, NULLABLE)
├── MediaType (ENUM: Image, Video, Document, NULLABLE)
├── Status (ENUM: Sent, Delivered, Read, Failed)
├── ErrorCode (VARCHAR 50, NULLABLE)
├── ConversationId (VARCHAR 255)
├── CreatedAt (DATETIME)
├── SentAt (DATETIME, NULLABLE)
└── ReadAt (DATETIME, NULLABLE)
Design Decision: Notice that we store both a denormalized ContactCount on the List entity and a junction table for actual contact-to-list mappings. The denormalized count avoids expensive JOINs for display purposes while the junction table enables accurate segment membership queries. We maintain consistency via background jobs that periodically sync the count.

The JSONB Attributes field on the Contact model deserves special attention. Different customers will have wildly different custom fields — some track company name and revenue, others track purchase history and preferences. Using a flexible JSON column allows each account to define its own schema without requiring DDL changes, while PostgreSQL GIN indexing on JSONB columns maintains query performance even at scale.

The separation of TransactionalEmail, SmsMessage, and WhatsAppMessage into distinct tables rather than a unified Messages table is a deliberate choice. Each channel has unique metadata (bounce type for email, carrier for SMS, conversation ID for WhatsApp) and different query patterns. Unified tables would create sparse rows with many NULL columns and make channel-specific optimizations difficult.

5. API Design

The API layer follows RESTful conventions with JSON payloads. Authentication uses API keys for programmatic access and OAuth 2.0 for the dashboard UI. All endpoints are versioned under /v3/ and rate-limited per account based on plan tier.

Core API Endpoints

MethodEndpointDescriptionRate Limit
POST/v3/email/sendSend a single transactional email100/sec
POST/v3/email/batchSend batch transactional emails (up to 1000)10/sec
POST/v3/sms/sendSend a single SMS message50/sec
POST/v3/whatsapp/sendSend a WhatsApp template message50/sec
POST/v3/contactsCreate or update a contact (upsert by email)100/sec
POST/v3/contacts/batchBulk upsert contacts (up to 10K per call)10/sec
GET/v3/contacts/:idRetrieve a contact by ID500/sec
DELETE/v3/contacts/:idDelete a contact500/sec
GET/v3/contactsList contacts with filtering and pagination100/sec
POST/v3/campaignsCreate a new campaign20/sec
POST/v3/campaigns/:id/sendSchedule or immediately send a campaign10/sec
GET/v3/campaigns/:id/statsGet real-time campaign statistics100/sec
POST/v3/templatesCreate an email template20/sec
GET/v3/analytics/eventsQuery contact events with filters50/sec
POST/v3/webhooksRegister a webhook endpoint10/sec

Send Email Request and Response

{
  "sender": {
    "email": "hello@example.com",
    "name": "My Store"
  },
  "to": [
    { "email": "customer@example.com", "name": "John Doe" }
  ],
  "cc": [],
  "bcc": [],
  "replyTo": { "email": "support@example.com" },
  "subject": "Your order #12345 has shipped!",
  "htmlContent": "<h1>Hello {{contact.first_name}}</h1><p>Your order has shipped.</p>",
  "textContent": "Hello {{contact.first_name}}, Your order has shipped.",
  "templateId": 42,
  "params": {
    "first_name": "John",
    "order_id": "12345",
    "tracking_url": "https://track.example.com/abc123"
  },
  "tags": ["transactional", "order-update"],
  "priority": "high",
  "scheduledAt": null
}

// Response 201 Created
{
  "messageId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "queued",
  "estimatedDeliveryTime": "2026-07-01T10:30:15Z"
}

Create Campaign Request

{
  "name": "July Summer Sale",
  "type": "email",
  "senderName": "My Store",
  "senderEmail": "promos@example.com",
  "subject": "Summer Sale - 50% Off Everything!",
  "previewText": "Don't miss our biggest sale of the year",
  "templateId": 78,
  "content": "<html>...</html>",
  "targetLists": ["list-uuid-1", "list-uuid-2"],
  "excludeLists": ["list-uuid-3"],
  "segmentCriteria": {
    "conditions": [
      { "field": "attributes.city", "operator": "eq", "value": "New York" },
      { "field": "attributes.purchases", "operator": "gt", "value": 5 }
    ],
    "logic": "AND"
  },
  "abTestConfig": {
    "enabled": true,
    "splitPercentage": 20,
    "winnerMetric": "open_rate",
    "variants": [
      { "subject": "Summer Sale - 50% Off Everything!" },
      { "subject": "Your exclusive 50% discount inside" }
    ]
  },
  "scheduleAt": "2026-07-15T09:00:00Z"
}

Error Response Format

{
  "error": {
    "code": "INVALID_SENDER",
    "message": "Sender email domain is not verified",
    "details": {
      "domain": "example.com",
      "suggestion": "Add MX record pointing to mail.brevo.example.com"
    }
  },
  "requestId": "req-abc-123"
}
API Design Principle: Every response includes a requestId for distributed tracing. Error responses include actionable details — not just what went wrong but how to fix it. This is critical for developer experience since transactional email is often integrated by third-party developers who need to debug issues without access to server logs.

The API uses cursor-based pagination for list endpoints rather than offset-based pagination. This ensures consistent results even when contacts are being added or removed during pagination, which is essential for batch import operations that may take minutes to complete across millions of records.

Idempotency is enforced via the Idempotency-Key header on POST endpoints. Since transactional emails are often retried by clients on network timeouts, idempotency prevents duplicate sends. The server caches idempotency keys for 24 hours and returns the original response for duplicate requests.

6. High-Level Architecture

The high-level architecture of a Brevo-like platform follows a microservices pattern with clear separation between the management plane (campaign creation, contact management, analytics) and the data plane (message sending, delivery tracking, event processing).

graph TB
    subgraph "Client Layer"
        WebApp[React Web App]
        MobileApp[Mobile App]
        PublicAPI[Public REST API]
    end
    subgraph "API Gateway"
        Gateway[API Gateway / Load Balancer]
        Auth[Authentication Service]
        RateLimit[Rate Limiter]
    end
    subgraph "Core Services"
        AccountSvc[Account Service]
        ContactSvc[Contact Service]
        CampaignSvc[Campaign Service]
        TemplateSvc[Template Service]
        AutomationSvc[Automation Service]
        AnalyticsSvc[Analytics Service]
        WebhookSvc[Webhook Service]
    end
    subgraph "Channel Services"
        EmailChannel[Email Channel Service]
        SmsChannel[SMS Channel Service]
        WhatsappChannel[WhatsApp Channel Service]
    end
    subgraph "Message Queues"
        EmailQueue[Email Queue - RabbitMQ]
        SmsQueue[SMS Queue]
        WhatsappQueue[WhatsApp Queue]
        EventQueue[Event Processing Queue]
    end
    subgraph "Delivery Infrastructure"
        SharedIPPool[Shared IP Pool]
        DedicatedIPs[Dedicated IPs]
        SmtpRelay[SMTP Relay Layer]
        SmsProviders[SMS Providers]
        WhatsappAPI[WhatsApp Business API]
    end
    subgraph "Data Layer"
        PrimaryDB[(PostgreSQL Primary)]
        ReadReplicas[(Read Replicas)]
        Redis[(Redis Cluster)]
        ClickHouse[(ClickHouse)]
        S3[(S3 Object Storage)]
    end
    WebApp --> Gateway
    MobileApp --> Gateway
    PublicAPI --> Gateway
    Gateway --> Auth
    Gateway --> RateLimit
    Gateway --> AccountSvc
    Gateway --> ContactSvc
    Gateway --> CampaignSvc
    Gateway --> TemplateSvc
    CampaignSvc --> EmailQueue
    CampaignSvc --> SmsQueue
    CampaignSvc --> WhatsappQueue
    AutomationSvc --> EmailQueue
    AutomationSvc --> SmsQueue
    AutomationSvc --> WhatsappQueue
    EmailQueue --> EmailChannel
    SmsQueue --> SmsChannel
    WhatsappQueue --> WhatsappChannel
    EmailChannel --> SmtpRelay
    SmtpRelay --> SharedIPPool
    SmtpRelay --> DedicatedIPs
    SmsChannel --> SmsProviders
    WhatsappChannel --> WhatsappAPI
    EmailChannel --> EventQueue
    SmsChannel --> EventQueue
    WhatsappChannel --> EventQueue
    EventQueue --> AnalyticsSvc
    ContactSvc --> PrimaryDB
    CampaignSvc --> PrimaryDB
    AnalyticsSvc --> ClickHouse
    PrimaryDB --> ReadReplicas
    WebhookSvc --> EventQueue
    EmailChannel --> Redis
    ContactSvc --> Redis
    TemplateSvc --> Redis

Key Architectural Decisions

Separation of Management and Data Plane: The management plane handles campaign creation, contact uploads, template editing, and analytics queries. The data plane handles the actual sending of millions of messages, tracking opens and clicks, and processing bounce notifications. These two planes have completely different scaling characteristics. The management plane is bursty (peaks during business hours) while the data plane runs continuously with peaks aligned to campaign schedules.

Dedicated Channel Services: Each communication channel (email, SMS, WhatsApp) is a separate microservice. This allows independent scaling, provider failover, and channel-specific optimizations. The email channel service handles SMTP connection pooling, DKIM signing, and IP rotation. The SMS service handles carrier routing and delivery receipt parsing. The WhatsApp service manages template approval workflows and conversation windows.

Queue-Driven Asynchronous Processing: All message sending is asynchronous. When a campaign is triggered or a transactional email API is called, the message is placed on a queue and acknowledged immediately. Worker processes consume from queues at controlled rates to respect provider limits and IP reputation constraints. This decoupling provides natural backpressure and fault isolation.

CQRS for Analytics: Campaign metrics (opens, clicks, bounces) are written to ClickHouse rather than the primary PostgreSQL database. ClickHouse columnar storage is optimized for the analytical query patterns that power dashboards — aggregations over time ranges, funnel analysis, and cohort comparisons. PostgreSQL handles the transactional workload while ClickHouse handles the analytical workload.

Scalability Note: The queue layer is the most critical component for reliability. If RabbitMQ goes down, no messages can be sent. This is why production deployments use a replicated RabbitMQ cluster with quorum queues, and implement a dead-letter queue for messages that cannot be processed after multiple retries.

7. Multi-Channel Campaign Builder

One of Brevo's distinguishing features is the ability to orchestrate multi-channel campaigns that coordinate email, SMS, and WhatsApp messages in a single workflow. This requires a sophisticated campaign execution engine that tracks state across channels and enforces timing constraints.

Multi-Channel Workflow Diagram

graph LR
    A[Trigger Event] --> B{Evaluate Segment}
    B -->|Matches| C[Send Email: Welcome]
    B -->|No Match| D[End]
    C --> E{Opened Email?}
    E -->|Yes| F[Send SMS: Discount Code]
    E -->|No after 24h| G[Send WhatsApp Reminder]
    F --> H{Clicked Link?}
    G --> H
    H -->|Yes| I[Update CRM: Hot Lead]
    H -->|No after 48h| J[Send Final Email]
    I --> K[Tag Contact as Converted]
    J --> L{Engaged?}
    L -->|Yes| F
    L -->|No| M[Move to Re-engagement List]

Campaign Execution State Machine

Each multi-channel campaign follows a state machine that tracks execution progress across all channels and contacts. The engine must handle partial failures — for example, if email delivery succeeds but SMS fails due to carrier issues, the workflow continues with the remaining channels.

StateDescriptionTransitions
CreatedCampaign drafted, not yet scheduledto Scheduled, to Cancelled
ScheduledCampaign queued for future executionto Executing, to Cancelled
ExecutingCampaign is actively sending messagesto Paused, to Completed, to Failed
PausedAdmin manually paused executionto Executing, to Cancelled
CompletedAll messages sent and workflows finishedterminal state
FailedUnrecoverable error during executionterminal state
CancelledAdmin cancelled the campaignterminal state

Multi-Channel Campaign Engine

public class MultiChannelCampaignEngine
{
    private readonly ICampaignRepository _campaignRepo;
    private readonly IEmailChannelService _emailService;
    private readonly ISmsChannelService _smsService;
    private readonly IWhatsAppChannelService _whatsappService;
    private readonly IContactSegmentationService _segmentService;
    private readonly IEventBus _eventBus;
    private readonly ILogger<MultiChannelCampaignEngine> _logger;

    public async Task ExecuteCampaignAsync(Guid campaignId, CancellationToken ct)
    {
        var campaign = await _campaignRepo.GetByIdAsync(campaignId, ct);
        if (campaign == null || campaign.Status != CampaignStatus.Scheduled)
            throw new InvalidOperationException("Campaign not in valid state");

        campaign.Status = CampaignStatus.Executing;
        campaign.StartedAt = DateTime.UtcNow;
        await _campaignRepo.UpdateAsync(campaign, ct);

        try
        {
            var recipients = await _segmentService
                .ResolveRecipientsAsync(
                    campaign.TargetListIds,
                    campaign.ExcludeListIds,
                    campaign.SegmentCriteria, ct);

            campaign.TotalRecipients = recipients.Count;
            await _campaignRepo.UpdateAsync(campaign, ct);

            foreach (var channel in campaign.Channels)
            {
                switch (channel.Type)
                {
                    case ChannelType.Email:
                        await ExecuteEmailChannelAsync(
                            campaign, channel, recipients, ct);
                        break;
                    case ChannelType.Sms:
                        await ExecuteSmsChannelAsync(
                            campaign, channel, recipients, ct);
                        break;
                    case ChannelType.WhatsApp:
                        await ExecuteWhatsAppChannelAsync(
                            campaign, channel, recipients, ct);
                        break;
                }
            }

            campaign.Status = CampaignStatus.Completed;
            campaign.CompletedAt = DateTime.UtcNow;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Campaign {Id} failed", campaignId);
            campaign.Status = CampaignStatus.Failed;
        }

        await _campaignRepo.UpdateAsync(campaign, ct);
    }

    private async Task<int> ExecuteEmailChannelAsync(
        Campaign campaign, ChannelConfig channel,
        List<Recipient> recipients, CancellationToken ct)
    {
        var batchSize = 1000;
        var totalSent = 0;

        foreach (var batch in recipients.Chunk(batchSize))
        {
            var messages = batch.Select(r => new OutboundEmail
            {
                CampaignId = campaign.Id,
                AccountId = campaign.AccountId,
                RecipientEmail = r.Email,
                RecipientName = r.Name,
                Subject = ReplaceVariables(campaign.Subject, r.Attributes),
                HtmlContent = ReplaceVariables(campaign.Content, r.Attributes),
                SenderEmail = campaign.SenderEmail,
                SenderName = campaign.SenderName,
                DynamicData = r.Attributes,
                Tags = new List<string> { "campaign", campaign.Id.ToString() }
            }).ToList();

            await _emailService.EnqueueBatchAsync(messages, ct);
            totalSent += messages.Count;

            if (channel.ThrottleDelayMs > 0)
                await Task.Delay(channel.ThrottleDelayMs, ct);
        }

        return totalSent;
    }

    private async Task<int> ExecuteSmsChannelAsync(
        Campaign campaign, ChannelConfig channel,
        List<Recipient> recipients, CancellationToken ct)
    {
        var smsRecipients = recipients
            .Where(r => r.SmsOptIn && r.PhoneNumber != null)
            .ToList();

        foreach (var recipient in smsRecipients)
        {
            var message = new OutboundSms
            {
                CampaignId = campaign.Id,
                AccountId = campaign.AccountId,
                RecipientPhone = recipient.PhoneNumber,
                Content = ReplaceVariables(
                    channel.SmsTemplate, recipient.Attributes)
            };

            await _smsService.EnqueueAsync(message, ct);

            if (channel.ThrottleDelayMs > 0)
                await Task.Delay(channel.ThrottleDelayMs, ct);
        }

        return smsRecipients.Count;
    }

    private async Task<int> ExecuteWhatsAppChannelAsync(
        Campaign campaign, ChannelConfig channel,
        List<Recipient> recipients, CancellationToken ct)
    {
        var whatsappRecipients = recipients
            .Where(r => r.WhatsAppOptIn && r.PhoneNumber != null)
            .ToList();

        foreach (var recipient in whatsappRecipients)
        {
            var message = new OutboundWhatsApp
            {
                CampaignId = campaign.Id,
                AccountId = campaign.AccountId,
                RecipientPhone = recipient.PhoneNumber,
                TemplateName = channel.WhatsAppTemplateName,
                TemplateLanguage = channel.WhatsAppTemplateLanguage,
                Parameters = channel.WhatsAppParameters
            };

            await _whatsappService.EnqueueAsync(message, ct);

            if (channel.ThrottleDelayMs > 0)
                await Task.Delay(channel.ThrottleDelayMs, ct);
        }

        return whatsappRecipients.Count;
    }

    private string ReplaceVariables(
        string template, Dictionary<string, object> data)
    {
        var result = template;
        foreach (var kvp in data)
        {
            result = result.Replace(
                "{{" + kvp.Key + "}}",
                kvp.Value?.ToString() ?? string.Empty);
        }
        return result;
    }
}
Key Design Point: The multi-channel engine processes channels sequentially within a campaign but processes individual messages within a channel asynchronously via the queue. This ensures that email sending does not block SMS sending at the campaign level, while still providing controlled throughput within each channel via the queue worker configuration.

The delay-based branching logic shown in the Mermaid diagram above (wait 24 hours for email open, then send SMS) is handled by the Marketing Automation engine rather than the campaign engine itself. Campaigns are one-shot executions while automations are stateful workflows that persist execution state across hours or days. We will cover the automation engine in detail in Section 11.

8. Transactional Email Pipeline

Transactional email is the highest priority use case on the platform. When a user triggers a password reset, order confirmation, or shipping notification, the email must be delivered within seconds. This requires a fundamentally different architecture from batch marketing campaigns.

Transactional Email Flow

sequenceDiagram
    participant Client as Client Application
    participant API as Transactional API
    participant Queue as Priority Queue
    participant Renderer as Template Renderer
    participant Sender as Email Sender
    participant Provider as SMTP Provider
    participant Tracker as Event Tracker

    Client->>API: POST /v3/email/send
    API->>API: Validate API key and rate limit
    API->>API: Check sending quota
    API->>Queue: Enqueue with HIGH priority
    API-->>Client: 201 messageId status queued

    Queue->>Renderer: Dequeue message
    Renderer->>Renderer: Render template with dynamic data
    Renderer->>Renderer: Inject tracking pixel and links
    Renderer->>Sender: Rendered email ready

    Sender->>Sender: Select IP pool
    Sender->>Sender: DKIM sign
    Sender->>Provider: SMTP transmit
    Provider-->>Sender: 250 OK

    Sender->>Tracker: Log delivery event
    Tracker->>Tracker: Update contact activity
    Tracker->>Tracker: Fire webhooks
    Tracker->>Tracker: Update campaign stats

Priority Queue Architecture

Transaction emails must always be processed ahead of marketing campaigns. We implement this using a priority queue system with three tiers:

PriorityUse CaseTarget LatencyQueue Name
High (0)Password reset, 2FA codes, security alertsP99 less than 1 secondemail.priority.high
Normal (1)Order confirmations, receipts, shippingP99 less than 5 secondsemail.priority.normal
Low (2)Welcome series, onboarding dripsP99 less than 60 secondsemail.priority.low
Campaign (3)Marketing campaigns, newslettersWithin scheduled windowemail.priority.campaign

The queue system uses RabbitMQ with priority queue support. Worker processes consume from the high-priority queue first, then normal, then low, then campaign. Each worker has configurable concurrency — transactional workers run with high concurrency (20-50 parallel sends) while campaign workers are throttled based on IP reputation metrics.

Template Rendering Pipeline

Transactional emails use Mustache-style templates with dynamic variable injection. The rendering pipeline must handle millions of unique renders per hour since each email contains personalized data like order numbers, tracking URLs, and customer names.

public class TransactionalEmailService
{
    private readonly ITemplateRenderer _renderer;
    private readonly IPriorityQueue _queue;
    private readonly IAccountQuotaService _quotaService;
    private readonly IDkimSigner _dkimSigner;
    private readonly ILogger<TransactionalEmailService> _logger;

    public async Task<SendResult> SendTransactionalEmailAsync(
        SendTransactionalEmailRequest request, CancellationToken ct)
    {
        var account = await _quotaService
            .ValidateAndGetAccountAsync(request.ApiKey, ct);

        if (!await _quotaService.HasRemainingQuotaAsync(
            account.Id, ct))
        {
            return SendResult.Failure(
                "QUOTA_EXCEEDED",
                "Monthly email quota reached.");
        }

        var template = await _renderer.GetTemplateAsync(
            request.TemplateId, account.Id, ct);

        var renderedContent = await _renderer.RenderAsync(
            template, request.DynamicData, new RenderContext
            {
                TrackOpens = account.PlanTier != PlanTier.Free,
                TrackClicks = account.PlanTier != PlanTier.Free,
                BaseUrl = $"https://track.{account.SendingDomain}",
                UnsubscribeUrl =
                    $"https://unsub.brevo.example.com/{account.Id}/"
                    + "{{contact.email}}"
            }, ct);

        var message = new OutboundEmail
        {
            MessageId = Guid.NewGuid(),
            AccountId = account.Id,
            RecipientEmail = request.To.Email,
            RecipientName = request.To.Name,
            SenderEmail = request.Sender?.Email
                ?? account.DefaultSenderEmail,
            SenderName = request.Sender?.Name
                ?? account.DefaultSenderName,
            ReplyTo = request.ReplyTo?.Email,
            Subject = renderedContent.Subject,
            HtmlContent = renderedContent.Html,
            TextContent = renderedContent.Text,
            Priority = MapPriority(request.Priority),
            Tags = request.Tags ?? new List<string>(),
            CreatedAt = DateTime.UtcNow
        };

        message.DkimSignature = await _dkimSigner.SignAsync(
            message, account.SendingDomain, ct);

        await _queue.EnqueueAsync(
            "email.priority.normal", message, ct);

        await _quotaService.IncrementUsageAsync(
            account.Id, 1, ct);

        _logger.LogInformation(
            "Transactional email {Id} queued for {Recipient}",
            message.MessageId, message.RecipientEmail);

        return SendResult.Success(message.MessageId);
    }
}
Performance Optimization: Template rendering is the most CPU-intensive part of the transactional pipeline. We pre-cache rendered templates in Redis keyed by (templateId, hashOfDynamicData) to avoid redundant rendering when the same template with the same variables is sent multiple times. Cache TTL is 5 minutes for transactional templates.

Bounce and complaint handling for transactional email follows a different path than marketing email. A hard bounce on a transactional email immediately updates the contact status to Bounced and triggers a webhook to the client application. The client is responsible for handling the bounced address — perhaps by prompting the user to verify their email. Marketing email bounces are handled more gracefully since they do not carry the same urgency.

9. Email Sending Infrastructure

Email deliverability is the single most important technical capability of a marketing platform. If emails land in spam folders, the entire platform is worthless regardless of how good the other features are. The sending infrastructure must manage IP reputation, authentication protocols, bounce processing, and complaint handling.

IP Pool Architecture

graph TB
    subgraph "Email Sending Service"
        Router[Smart IP Router]
        Monitor[Reputation Monitor]
    end
    subgraph "IP Pools"
        DedicatedA[Dedicated Pool A - Enterprise]
        DedicatedB[Dedicated Pool B - Enterprise]
        SharedPremium[Shared Premium Pool]
        SharedStandard[Shared Standard Pool]
        Warmup[Warmup Pool]
        Transactional[Transactional Pool]
    end
    subgraph "Reputation Signals"
        BounceRate[Bounce Rate Tracker]
        SpamRate[Spam Complaint Tracker]
        OpenRate[Open Rate Tracker]
        Health[IP Health Score]
    end
    Router --> DedicatedA
    Router --> DedicatedB
    Router --> SharedPremium
    Router --> SharedStandard
    Router --> Warmup
    Router --> Transactional
    Monitor --> BounceRate
    Monitor --> SpamRate
    Monitor --> OpenRate
    Monitor --> Health
    Health --> Router

IP Reputation Management

PoolUse CaseIP CountDaily Volume CapRequirements
Dedicated AEnterprise customers with own IPsPer customerUnlimitedIP warmup completed, domain verified
Dedicated BHigh-volume Business tier2-4 per customer500K per dayBounce rate below 2%, complaints below 0.05%
Shared PremiumBusiness tier shared sending20-50 IPs5M per day totalAccount age above 3 months, good reputation
Shared StandardStarter tier shared sending30-100 IPs10M per day totalDefault pool for new accounts
WarmupNew IPs being warmed up5-10 IPsGradual increase over 30 daysAutomated warmup schedule
TransactionalAPI-driven transactional email10-20 IPs2M per dayIsolated from marketing reputation

Email Authentication Setup

Every customer sending domain must configure SPF, DKIM, and optionally DMARC records. The platform provides a setup wizard and automated verification.

// DNS Records required for sending domain example.com
// SPF Record - authorizes Brevo servers to send
// Type: TXT, Host: @
// Value: "v=spf1 include:spf.brevo.example.com ~all"

// DKIM Record - cryptographically signs emails
// Type: TXT, Host: s1._domainkey
// Value: "v=DKIM1; k=rsa; p=MIIBIjANBgkqh..."

// DMARC Record - tells receivers what to do
// Type: TXT, Host: _dmarc
// Value: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"

// CNAME for tracking domain
// Type: CNAME, Host: track
// Value: track.brevo.example.com

IP Warmup Schedule

DayVolume per IPNotes
1-350 emailsSend to most engaged contacts only
4-7100 emailsMonitor bounce rate closely
8-14500 emailsGradual increase, monitor spam complaints
15-212,000 emailsInclude all engaged segments
22-2810,000 emailsBroader audience, monitor deliverability
29-3025,000 emailsFull volume, all segments
31+Full volumeIP considered warmed up if metrics hold
Critical Warning: Sending too much volume from a new IP too quickly is the most common cause of deliverability failures. Internet Service Providers treat new IPs with suspicion. A proper warmup schedule builds trust gradually by starting with the contacts most likely to open and engage with emails.

The Smart IP Router makes real-time decisions about which IP pool to use for each email. It considers the recipient's mailbox provider (Gmail, Outlook, Yahoo), the sender's account reputation, the email type (transactional vs marketing), and the current health score of each IP pool. If a particular IP shows elevated bounce rates, the router automatically excludes it from the pool and triggers an alert for the deliverability team.

We also implement feedback loops with major ISPs. When a recipient marks an email as spam, the ISP sends a complaint back to us. Our system processes these complaints in real-time, immediately unsubscribing the complainer and updating the sender's reputation score. Ignoring spam complaints is the fastest way to get IPs blacklisted.

10. SMS and WhatsApp Integration

SMS and WhatsApp are increasingly important channels alongside email. Brevo supports both as first-class channels with dedicated provider integrations, opt-in management, and template approval workflows.

SMS Provider Architecture

graph LR
    A[SMS Request] --> B[Provider Router]
    B --> C[Twilio]
    B --> D[Vonage]
    B --> E[AWS SNS]
    B --> F[Regional Provider]
    C --> G[Delivery Receipt]
    D --> G
    E --> G
    F --> G
    G --> H[Status Update Service]
    H --> I[Analytics]
    H --> J[Webhook Dispatcher]

SMS Provider Selection Logic

CountryPrimary ProviderFallbackCost per SMS (USD)
United StatesTwilioAWS SNS0.0079
CanadaTwilioVonage0.0099
United KingdomTwilioVonage0.0329
GermanyVonageTwilio0.0649
FranceVonageTwilio0.0156
IndiaRegional ProviderTwilio0.0150
BrazilTwilioRegional Provider0.0699
AustraliaTwilioVonage0.0406
JapanRegional ProviderTwilio0.0349

WhatsApp Business API Integration

WhatsApp Business API integration is more complex than SMS because Meta requires pre-approved message templates for outbound messages. The platform must manage the template approval lifecycle and enforce the 24-hour conversation window rule.

public class WhatsAppIntegrationService
{
    private readonly IWhatsAppApiClient _whatsappClient;
    private readonly ITemplateApprovalService _templateService;
    private readonly IConversationTracker _conversationTracker;

    public async Task<SendResult> SendTemplateMessageAsync(
        OutboundWhatsApp message, CancellationToken ct)
    {
        var template = await _templateService
            .GetApprovedTemplateAsync(
                message.TemplateName,
                message.TemplateLanguage, ct);

        if (template == null)
        {
            return SendResult.Failure(
                "TEMPLATE_NOT_APPROVED",
                $"Template '{message.TemplateName}' " +
                $"not approved for '{message.TemplateLanguage}'");
        }

        var conversation = await _conversationTracker
            .GetActiveConversationAsync(
                message.RecipientPhone, ct);

        bool isNewConversation = conversation == null ||
            conversation.LastMessageAt.AddHours(24) < DateTime.UtcNow;

        if (isNewConversation)
        {
            var cost = await CalculateConversationCostAsync(
                message.AccountId, message.RecipientPhone, ct);
            message.Cost = cost;
        }
        else
        {
            message.Cost = 0;
        }

        var whatsappRequest = new WhatsAppApiRequest
        {
            MessagingProduct = "whatsapp",
            To = message.RecipientPhone,
            Type = "template",
            Template = new WhatsAppTemplate
            {
                Name = template.Name,
                Language = new WhatsAppLanguage
                {
                    Code = message.TemplateLanguage
                },
                Components = BuildComponents(
                    template, message.Parameters)
            }
        };

        var response = await _whatsappClient
            .SendMessageAsync(whatsappRequest, ct);

        if (response.IsSuccess)
        {
            await _conversationTracker
                .RecordOutboundMessageAsync(
                    message.RecipientPhone,
                    message.AccountId, ct);
        }

        return response.IsSuccess
            ? SendResult.Success(response.MessageId)
            : SendResult.Failure(
                response.ErrorCode,
                response.ErrorMessage);
    }
}

Opt-in Management

SMS and WhatsApp require explicit opt-in before sending marketing messages. The platform provides multiple opt-in mechanisms:

  • Double Opt-in via Web Form: User enters phone number on a signup form, receives a verification code via SMS, and confirms their number. The platform stores the opt-in timestamp and IP address as proof of consent.
  • Keyword Opt-in: User texts a keyword (e.g., JOIN, START) to a dedicated number. The platform detects the keyword via webhook and automatically opts in the contact.
  • API Opt-in: Developers call the contacts API with smsOptIn set to true, which must be backed by documented consent from the user.
  • WhatsApp Opt-in: User sends a message to the business WhatsApp number first. The platform can only respond after the user initiates a conversation.
Compliance Note: SMS marketing is regulated by TCPA in the US, PECR in the UK, and similar laws in other countries. Sending marketing SMS without prior explicit consent can result in fines of up to $1,500 per unsolicited message. The platform must maintain auditable proof of opt-in for every contact.

11. Marketing Automation Engine

Marketing automation is what transforms a simple email sending tool into a powerful marketing platform. The automation engine allows users to build visual workflows with triggers, conditions, delays, and actions that execute over hours, days, or weeks.

Automation Workflow Architecture

graph TB
    subgraph "Trigger Layer"
        T1[Contact Added to List]
        T2[Form Submitted]
        T3[Tag Applied]
        T4[Custom Event]
        T5[Date-based Trigger]
        T6[Abandoned Cart]
    end
    subgraph "Processing Engine"
        Scheduler[Workflow Scheduler]
        Executor[Step Executor]
        StateStore[(Execution State)]
        DelayQueue[Delay Queue]
    end
    subgraph "Action Layer"
        A1[Send Email]
        A2[Send SMS]
        A3[Send WhatsApp]
        A4[Update Contact]
        A5[Add to List]
        A6[Remove from List]
        A7[Webhook Call]
        A8[Wait/Delay]
        A9[If/Else Condition]
        A10[A/B Split]
    end
    T1 --> Scheduler
    T2 --> Scheduler
    T3 --> Scheduler
    T4 --> Scheduler
    T5 --> Scheduler
    T6 --> Scheduler
    Scheduler --> Executor
    Executor --> StateStore
    Executor --> DelayQueue
    DelayQueue --> Executor
    Executor --> A1
    Executor --> A2
    Executor --> A3
    Executor --> A4
    Executor --> A5
    Executor --> A6
    Executor --> A7
    Executor --> A8
    Executor --> A9
    Executor --> A10

Workflow DSL

The automation engine uses a JSON-based workflow definition language that maps directly to the visual workflow builder UI. Each workflow consists of nodes (triggers, conditions, actions) connected by edges (paths based on conditions).

{
  "workflowId": "wf-welcome-series",
  "name": "Welcome Series for New Signups",
  "status": "active",
  "trigger": {
    "type": "contact_added_to_list",
    "listId": "list-new-subscribers"
  },
  "nodes": [
    {
      "nodeId": "step-1",
      "type": "action",
      "action": "send_email",
      "config": {
        "templateId": "tmpl-welcome-email",
        "senderName": "My Store",
        "senderEmail": "hello@mystore.com"
      }
    },
    {
      "nodeId": "step-2",
      "type": "delay",
      "config": { "duration": 2, "unit": "days" }
    },
    {
      "nodeId": "step-3",
      "type": "condition",
      "config": {
        "field": "email.opened",
        "operator": "equals",
        "value": true,
        "timeWindow": "2d"
      },
      "truePath": "step-4",
      "falsePath": "step-5"
    },
    {
      "nodeId": "step-4",
      "type": "action",
      "action": "send_email",
      "config": {
        "templateId": "tmpl-product-recommendations"
      }
    },
    {
      "nodeId": "step-5",
      "type": "action",
      "action": "send_sms",
      "config": {
        "content": "Hey {{first_name}}! Check out our latest: {{link}}"
      }
    },
    {
      "nodeId": "step-6",
      "type": "delay",
      "config": { "duration": 3, "unit": "days" }
    },
    {
      "nodeId": "step-7",
      "type": "action",
      "action": "send_email",
      "config": {
        "templateId": "tmpl-discount-offer"
      }
    }
  ],
  "edges": [
    { "from": "trigger", "to": "step-1" },
    { "from": "step-1", "to": "step-2" },
    { "from": "step-2", "to": "step-3" },
    { "from": "step-3", "to": "step-4", "condition": "true" },
    { "from": "step-3", "to": "step-5", "condition": "false" },
    { "from": "step-4", "to": "step-6" },
    { "from": "step-5", "to": "step-6" },
    { "from": "step-6", "to": "step-7" }
  ]
}

Automation Execution Engine

public class AutomationWorkflowEngine
{
    private readonly IWorkflowRepository _workflowRepo;
    private readonly IExecutionStateStore _stateStore;
    private readonly IDelayQueue _delayQueue;
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<AutomationWorkflowEngine> _logger;

    public async Task ProcessTriggerAsync(
        string triggerType, Guid accountId,
        Dictionary<string, object> triggerData,
        CancellationToken ct)
    {
        var workflows = await _workflowRepo
            .GetActiveWorkflowsByTriggerAsync(triggerType, ct);

        foreach (var workflow in workflows)
        {
            if (!await _workflowRepo.MatchesFilterAsync(
                workflow.Id, accountId, triggerData, ct))
                continue;

            var execution = new WorkflowExecution
            {
                ExecutionId = Guid.NewGuid(),
                WorkflowId = workflow.Id,
                AccountId = accountId,
                ContactId = GetContactId(triggerData),
                CurrentNodeId = workflow.Nodes
                    .First(n => n.Type == NodeType.Trigger).Id,
                StartedAt = DateTime.UtcNow,
                VariableContext = triggerData
            };

            await _stateStore.SaveExecutionAsync(execution, ct);
            await ExecuteNodeAsync(workflow, execution, ct);
        }
    }

    public async Task ResumeFromDelayAsync(
        WorkflowExecution execution, CancellationToken ct)
    {
        var workflow = await _workflowRepo
            .GetByIdAsync(execution.WorkflowId, ct);
        await ExecuteNodeAsync(workflow, execution, ct);
    }

    private async Task ExecuteNodeAsync(
        Workflow workflow, WorkflowExecution execution,
        CancellationToken ct)
    {
        var node = workflow.GetNode(execution.CurrentNodeId);

        switch (node.Type)
        {
            case NodeType.Action:
                await ExecuteActionAsync(node, execution, ct);
                var nextNode = GetNextNode(
                    workflow, node.Id, null);
                if (nextNode != null)
                {
                    execution.CurrentNodeId = nextNode.Id;
                    await _stateStore.SaveExecutionAsync(
                        execution, ct);
                    await ExecuteNodeAsync(
                        workflow, execution, ct);
                }
                break;

            case NodeType.Delay:
                var delayStep = GetNextNode(
                    workflow, node.Id, null);
                if (delayStep != null)
                {
                    execution.CurrentNodeId = delayStep.Id;
                    var delayUntil = CalculateDelay(node.Config);
                    await _delayQueue.ScheduleAsync(
                        execution, delayUntil, ct);
                    await _stateStore.SaveExecutionAsync(
                        execution, ct);
                }
                break;

            case NodeType.Condition:
                var result = await EvaluateConditionAsync(
                    node.Config, execution, ct);
                var pathNode = GetNextNode(
                    workflow, node.Id,
                    result ? "true" : "false");
                if (pathNode != null)
                {
                    execution.CurrentNodeId = pathNode.Id;
                    await _stateStore.SaveExecutionAsync(
                        execution, ct);
                    await ExecuteNodeAsync(
                        workflow, execution, ct);
                }
                break;
        }
    }

    private async Task ExecuteActionAsync(
        WorkflowNode node, WorkflowExecution execution,
        CancellationToken ct)
    {
        var actionType = node.Config["action"]?.ToString();

        switch (actionType)
        {
            case "send_email":
                var emailSvc = _serviceProvider
                    .GetRequiredService<IEmailChannelService>();
                await emailSvc.SendFromAutomationAsync(
                    execution.AccountId,
                    execution.ContactId,
                    node.Config, ct);
                break;

            case "send_sms":
                var smsSvc = _serviceProvider
                    .GetRequiredService<ISmsChannelService>();
                await smsSvc.SendFromAutomationAsync(
                    execution.AccountId,
                    execution.ContactId,
                    node.Config, ct);
                break;

            case "send_whatsapp":
                var waSvc = _serviceProvider
                    .GetRequiredService<IWhatsAppChannelService>();
                await waSvc.SendFromAutomationAsync(
                    execution.AccountId,
                    execution.ContactId,
                    node.Config, ct);
                break;

            case "add_to_list":
                var contactSvc = _serviceProvider
                    .GetRequiredService<IContactService>();
                var listId = Guid.Parse(
                    node.Config["listId"].ToString());
                await contactSvc.AddToListAsync(
                    execution.ContactId, listId, ct);
                break;

            case "update_attributes":
                var attrSvc = _serviceProvider
                    .GetRequiredService<IContactService>();
                var attrs = JsonSerializer
                    .Deserialize<Dictionary<string, object>>(
                        node.Config["attributes"].ToString());
                await attrSvc.UpdateAttributesAsync(
                    execution.ContactId, attrs, ct);
                break;

            case "webhook":
                var whSvc = _serviceProvider
                    .GetRequiredService<IWebhookService>();
                await whSvc.CallAsync(
                    execution.AccountId,
                    node.Config["url"].ToString(),
                    execution.VariableContext, ct);
                break;
        }
    }

    private TimeSpan CalculateDelay(
        Dictionary<string, object> config)
    {
        var duration = int.Parse(config["duration"].ToString());
        var unit = config["unit"].ToString().ToLower();
        return unit switch
        {
            "minutes" => TimeSpan.FromMinutes(duration),
            "hours" => TimeSpan.FromHours(duration),
            "days" => TimeSpan.FromDays(duration),
            "weeks" => TimeSpan.FromDays(duration * 7),
            _ => TimeSpan.FromDays(duration)
        };
    }
}
Scale Consideration: The delay queue for automations can hold millions of scheduled executions. When a workflow includes a 7-day delay, the execution state is persisted to Redis and the scheduled resume time is stored in a sorted set. A scheduler process scans the sorted set every minute and enqueues any executions whose delay has expired.

The automation engine must also handle contact-level deduplication. If a contact is already in a running workflow, the system must decide whether to allow them to enter the same workflow again, skip the entry, or terminate the existing execution. This is configurable per workflow and defaults to skip to prevent duplicate messages.

12. Contact Management and Segmentation

Brevo's core differentiator is unlimited contacts at no extra cost. This creates a unique engineering challenge: the contact management system must handle accounts with millions of contacts while keeping queries fast and segmentation performant.

Contact Storage Architecture

graph TB
    subgraph "Write Path"
        API[Contact API]
        Validator[Validation]
        Dedup[Deduplication]
        Writer[PostgreSQL Writer]
    end
    subgraph "Read Path"
        Reader[Read Replicas]
        RedisCache[Redis Cache]
        SearchIndex[Elasticsearch]
    end
    subgraph "Segmentation Engine"
        SegQuery[Segment Query Engine]
        MaterializedView[Materialized Views]
        BatchProcessor[Batch Segment Evaluator]
    end
    API --> Validator
    Validator --> Dedup
    Dedup --> Writer
    Writer --> Reader
    Reader --> RedisCache
    Reader --> SearchIndex
    SegQuery --> MaterializedView
    SegQuery --> Reader
    BatchProcessor --> MaterializedView

Segmentation Query Examples

SegmentCriteriaComplexity
High-value customersattributes.total_purchases > 500 AND status = ActiveLow
Engaged subscriberslast_activity_at > 30 days ago AND email_opt_in = trueLow
Abandoned cart usersevent type = cart_abandoned in last 7 daysMedium
VIP customers in NYCattributes.city = New York AND lead_score > 80Low
Never purchasedattributes.purchases = 0 AND created_at < 90 days agoMedium
Opened last 3 campaigns3 consecutive campaign opens in last 30 daysHigh

Dynamic segments are the most performance-sensitive queries in the system. Unlike static segments where contacts are pre-evaluated and stored in a list, dynamic segments are computed at query time against the full contact database. For an account with 5 million contacts, a complex segment query must complete in under 2 seconds to maintain a responsive UI.

We achieve this through a combination of strategies: partial indexes on commonly queried attributes, materialized views for complex aggregations, and Elasticsearch for full-text and fuzzy matching on contact attributes. For the most demanding segments, we pre-compute segment membership via batch jobs that run every 15 minutes and store results in a lookup table.

Lead scoring is another critical feature. The platform assigns each contact a score from 0 to 100 based on their engagement behavior. The scoring model considers email opens, clicks, website visits, form submissions, and purchase history. Scores are recalculated daily via a batch process that scans event logs and updates the lead_score field on each contact record.

13. Landing Page and Form Builder

Brevo provides drag-and-drop builders for landing pages and embedded forms. While these features are secondary to email sending, they are critical for the marketing funnel since they are the entry point for capturing new contacts.

Landing Page Architecture

public class LandingPageService
{
    private readonly IPageRepository _pageRepo;
    private readonly IFormSubmissionRepository _submissionRepo;
    private readonly IContactService _contactService;
    private readonly IConversionTracker _conversionTracker;

    public async Task<RenderedPage> RenderPageAsync(
        Guid pageId, HttpContext context, CancellationToken ct)
    {
        var page = await _pageRepo.GetByIdAsync(pageId, ct);

        var renderedBlocks = new List<RenderedBlock>();
        foreach (var block in page.Blocks.OrderBy(b => b.Order))
        {
            renderedBlocks.Add(new RenderedBlock
            {
                Type = block.Type,
                Html = await RenderBlockAsync(block, context, ct),
                Css = block.CustomCss
            });
        }

        return new RenderedPage
        {
            Title = page.Title,
            Blocks = renderedBlocks,
            FormConfig = page.FormConfig,
            TrackingScript = GenerateTrackingScript(
                page.AccountId, page.Id),
            MetaTags = page.MetaTags
        };
    }

    public async Task<FormSubmissionResult> ProcessFormSubmissionAsync(
        Guid pageId, FormSubmission submission,
        HttpContext context, CancellationToken ct)
    {
        var page = await _pageRepo.GetByIdAsync(pageId, ct);
        var formConfig = page.FormConfig;

        var contact = await _contactService.UpsertContactAsync(
            new UpsertContactRequest
            {
                AccountId = page.AccountId,
                Email = submission.Data["email"]?.ToString(),
                PhoneNumber = submission.Data.ContainsKey("phone")
                    ? submission.Data["phone"]?.ToString()
                    : null,
                Attributes = submission.Data,
                Source = $"landing_page:{pageId}",
                OptedInAt = DateTime.UtcNow,
                IpAddress = context.Connection
                    .RemoteIpAddress?.ToString(),
                DoubleOptIn = formConfig.DoubleOptInEnabled
            }, ct);

        if (formConfig.DoubleOptInEnabled
            && contact.RequiresVerification)
        {
            await SendDoubleOptInEmailAsync(
                contact, page.AccountId, ct);
            return FormSubmissionResult.Success(
                "Please check your email to confirm.");
        }

        await _conversionTracker.TrackConversionAsync(
            new ConversionEvent
            {
                AccountId = page.AccountId,
                PageId = pageId,
                ContactId = contact.ContactId,
                ConversionType = formConfig.ConversionType,
                Value = formConfig.ConversionValue
            }, ct);

        return FormSubmissionResult.Redirect(
            formConfig.ThankYouPageUrl);
    }
}

The landing page renderer outputs static HTML that is fast to load and SEO-friendly. Dynamic elements like forms and countdown timers are hydrated via client-side JavaScript. The entire page is served from a CDN with edge caching to minimize latency for visitors.

Form submissions trigger real-time processing through an event stream. The contact is created or updated immediately, and if the form is connected to a marketing automation workflow, the trigger fires within seconds. This tight integration between forms, contacts, and automation is what makes the platform valuable as an all-in-one marketing solution.

14. CRM Integration

Brevo includes a built-in CRM that allows users to manage deals, track pipelines, and score leads without leaving the platform. Additionally, it supports bidirectional sync with external CRMs like HubSpot and Salesforce.

Built-in CRM Data Model

Deal
├── DealId (GUID, PK)
├── AccountId (FK to Account)
├── ContactId (FK to Contact)
├── Title (VARCHAR 255)
├── Value (DECIMAL 12,2)
├── Currency (VARCHAR 3)
├── StageId (FK to PipelineStage)
├── Probability (INT, 0-100)
├── ExpectedCloseDate (DATE)
├── ActualCloseDate (DATE, NULLABLE)
├── Status (ENUM: Open, Won, Lost)
├── OwnerId (FK to User)
├── Tags (TEXT[])
├── CustomFields (JSONB)
├── CreatedAt (DATETIME)
└── UpdatedAt (DATETIME)

Pipeline
├── PipelineId (GUID, PK)
├── AccountId (FK to Account)
├── Name (VARCHAR 255)
├── IsDefault (BOOLEAN)
└── CreatedAt (DATETIME)

PipelineStage
├── StageId (GUID, PK)
├── PipelineId (FK to Pipeline)
├── Name (VARCHAR 100)
├── Position (INT)
├── Probability (INT)
├── Color (VARCHAR 7)
└── WonStage (BOOLEAN)

External CRM Sync

FeatureHubSpot SyncSalesforce Sync
ContactsBidirectional real-timeBidirectional scheduled (5 min)
DealsBidirectionalPush only (Brevo to SF)
CompaniesPull from HubSpotPull from Salesforce
ActivitiesPush (email opens, clicks)Push (email events as Tasks)
ListsSync HubSpot lists to BrevoSync SF reports to Brevo lists
AuthOAuth 2.0OAuth 2.0 + Connected App
Rate Limits100 requests per 10 secondsAPI calls per 24-hour org limit

The CRM sync engine uses a webhook-based approach for real-time updates. When a contact is updated in HubSpot, HubSpot fires a webhook to our endpoint, which queues a sync job. For Salesforce, we use the Streaming API (PushTopic) to receive real-time change notifications. The sync engine handles conflict resolution using last-write-wins semantics with configurable field-level mapping.

The bidirectional sync must handle schema mapping between platforms. HubSpot properties do not map 1:1 to Salesforce fields, so the integration provides a configurable field mapping UI where users can define which HubSpot properties correspond to which Brevo contact attributes. The mapping is stored as a JSON configuration and applied during each sync operation.

15. Chat and Conversations

Brevo's live chat widget allows businesses to engage website visitors in real-time. The chat system includes a web widget, a unified inbox for agents, and integration with the contact database for enriched conversations.

Chat Architecture

graph LR
    subgraph "Widget Layer"
        Widget[Chat Widget JS]
        WidgetSDK[Mobile SDK]
    end
    subgraph "WebSocket Gateway"
        WS[WebSocket Server]
        Presence[Presence Service]
    end
    subgraph "Chat Services"
        ConversationSvc[Conversation Service]
        InboxSvc[Inbox Service]
        BotSvc[Chatbot Service]
    end
    subgraph "Data Layer"
        MessagesDB[(Messages Store)]
        ContactLink[Contact Enrichment]
    end
    Widget --> WS
    WidgetSDK --> WS
    WS --> Presence
    WS --> ConversationSvc
    ConversationSvc --> InboxSvc
    ConversationSvc --> BotSvc
    ConversationSvc --> MessagesDB
    ConversationSvc --> ContactLink

When a visitor opens a page with the chat widget, a WebSocket connection is established to the nearest gateway server. The presence service tracks which visitors are currently online and which agent is assigned to each conversation. When a visitor sends a message, it flows through the WebSocket gateway to the conversation service, which checks for an existing conversation or creates a new one.

The bot service handles automated responses for common questions using a rules-based engine or optional AI integration. If the visitor request is beyond the bot's capabilities, the conversation is escalated to a human agent via the unified inbox. The inbox groups conversations by contact, showing the full history of emails, SMS, and chat interactions in a single timeline.

Chat transcripts are linked to the contact record in the CRM, giving sales and support teams a complete view of every interaction. This integration between chat and contact management is what differentiates Brevo's chat from standalone tools like Intercom or Drift.

The WebSocket gateway uses a sticky session approach where each visitor is pinned to a specific gateway server for the duration of their session. This avoids the complexity of shared session state across gateway nodes while still allowing horizontal scaling by distributing new connections across available nodes. For production deployments, we run a minimum of 3 gateway nodes behind a load balancer with WebSocket support.

16. Deliverability and Compliance

Deliverability is both a technical and operational challenge. The platform must implement technical controls (authentication, IP reputation, bounce processing) and operational processes (monitoring, remediation, compliance) to ensure emails reach the inbox.

Deliverability Monitoring Dashboard

MetricTargetAlert ThresholdAction on Breach
Bounce RateBelow 2%Exceeds 3%Pause account sending, require list cleanup
Spam Complaint RateBelow 0.05%Exceeds 0.1%Suspend account, manual review
Open RateAbove 20%Below 10%Review content quality, check spam placement
Unsubscribe RateBelow 0.5%Exceeds 1%Review targeting and content relevance
Delivery RateAbove 98%Below 95%Investigate IP reputation, provider issues
Inbox Placement RateAbove 85%Below 75%Postmaster Tools review, ISP engagement

GDPR Compliance Architecture

public class GdprComplianceService
{
    private readonly IContactRepository _contactRepo;
    private readonly IEventStore _eventStore;
    private readonly IDataExportService _exportService;
    private readonly INotificationService _notificationService;

    // Right to Data Portability - Article 20
    public async Task<DataExportResult> ExportContactDataAsync(
        Guid contactId, CancellationToken ct)
    {
        var contact = await _contactRepo.GetByIdAsync(
            contactId, ct);
        var events = await _eventStore
            .GetEventsByContactAsync(contactId, ct);
        var campaigns = await GetCampaignHistoryAsync(
            contactId, ct);

        var exportPackage = new DataExportPackage
        {
            ContactData = contact,
            EventHistory = events,
            CampaignHistory = campaigns,
            ConsentRecords = await GetConsentRecordsAsync(
                contactId, ct),
            ExportedAt = DateTime.UtcNow,
            Format = ExportFormat.Json
        };

        var exportFile = await _exportService
            .PackageAsync(exportPackage, ct);

        await _notificationService.SendAsync(new Notification
        {
            Type = NotificationType.DataExportReady,
            RecipientEmail = contact.Email,
            DownloadUrl = exportFile.SignedUrl,
            ExpiresAt = DateTime.UtcNow.AddDays(7)
        }, ct);

        return DataExportResult.Success(exportFile.FileId);
    }

    // Right to Erasure - Article 17
    public async Task<ErasureResult> EraseContactDataAsync(
        Guid contactId, string reason, CancellationToken ct)
    {
        var contact = await _contactRepo.GetByIdAsync(
            contactId, ct);

        if (await HasRetentionObligationAsync(contactId, ct))
        {
            return ErasureResult.Declined(
                "Data retention required for active " +
                "financial transactions");
        }

        await _contactRepo.AnonymizeAsync(contactId,
            new AnonymizeRequest
            {
                Email = $"erased-{contactId}@redacted.invalid",
                PhoneNumber = null,
                FullName = "Redacted",
                Attributes = new Dictionary<string, object>(),
                Status = ContactStatus.Anonymized
            }, ct);

        await _contactRepo.RemoveAllFromListsAsync(
            contactId, ct);

        await _eventStore.AnonymizeEventsByContactAsync(
            contactId, ct);

        await LogComplianceActionAsync(new ComplianceAuditEntry
        {
            Action = "GDPR_ERASURE",
            ContactId = contactId,
            RequestedReason = reason,
            PerformedAt = DateTime.UtcNow
        }, ct);

        return ErasureResult.Success();
    }

    public async Task RecordConsentAsync(
        Guid contactId, ConsentRecord record,
        CancellationToken ct)
    {
        await _contactRepo.AddConsentRecordAsync(
            contactId, new ConsentRecord
            {
                Channel = record.Channel,
                Purpose = record.Purpose,
                Granted = record.Granted,
                Timestamp = DateTime.UtcNow,
                IpAddress = record.IpAddress,
                UserAgent = record.UserAgent,
                FormId = record.FormId,
                Method = record.Method
            }, ct);
    }
}

CAN-SPAM compliance requires that every marketing email includes a visible unsubscribe link, a physical mailing address, and a clear identification that the message is an advertisement. The platform automatically appends these elements to every marketing email and validates compliance before allowing campaigns to send.

Bounce handling follows RFC 3463 conventions. Hard bounces (permanent failures like non-existent mailbox) immediately mark the contact as bounced and exclude them from future sends. Soft bounces (temporary failures like mailbox full) are retried up to 3 times with exponential backoff before marking the contact as bounced. The bounce classification engine parses SMTP response codes and DSN (Delivery Status Notification) messages to correctly categorize each bounce type.

17. Analytics Dashboard

The analytics dashboard provides real-time visibility into campaign performance, contact growth, revenue attribution, and channel effectiveness. The data pipeline behind the dashboard must handle billions of events while providing sub-second query responses.

Analytics Data Pipeline

graph LR
    subgraph "Event Sources"
        EmailEvents[Email Opens/Clicks]
        SmsEvents[SMS Deliveries]
        WhatsappEvents[WhatsApp Reads]
        FormEvents[Form Submissions]
        WebEvents[Website Visits]
    end
    subgraph "Ingestion"
        Kafka[Kafka Event Stream]
        Enricher[Event Enrichment]
    end
    subgraph "Processing"
        RealTime[Real-time Aggregator]
        BatchETL[Batch ETL - hourly]
        Materialized[Metric Materialization]
    end
    subgraph "Storage"
        ClickHouse[(ClickHouse)]
        TimescaleDB[(TimescaleDB)]
        S3[(S3 raw events)]
    end
    subgraph "Query Layer"
        API[Analytics API]
        Dashboard[React Dashboard]
    end
    EmailEvents --> Kafka
    SmsEvents --> Kafka
    WhatsappEvents --> Kafka
    FormEvents --> Kafka
    WebEvents --> Kafka
    Kafka --> Enricher
    Enricher --> RealTime
    Enricher --> BatchETL
    RealTime --> ClickHouse
    BatchETL --> TimescaleDB
    BatchETL --> S3
    Materialized --> ClickHouse
    ClickHouse --> API
    TimescaleDB --> API
    API --> Dashboard

Key Metrics Calculated

MetricCalculationUpdate Frequency
Open RateUnique opens divided by delivered countNear real-time (30 sec)
Click-Through RateUnique clicks divided by delivered countNear real-time (30 sec)
Click-to-Open RateUnique clicks divided by unique opensNear real-time
Bounce RateBounces divided by total sentNear real-time
Unsubscribe RateUnsubscribes divided by delivered countNear real-time
Revenue per EmailAttributed revenue divided by deliveredHourly
Contact Growth RateNew minus churned contacts per periodDaily
Lifetime ValueTotal attributed revenue per contactDaily
Automation ConversionGoal events divided by entriesHourly

ClickHouse is the backbone of the analytics engine. Its columnar storage format is optimized for the analytical query patterns that power dashboards — aggregations over time ranges, grouping by dimensions, and filtering by multiple criteria. A query that aggregates 100 million events by day and campaign can complete in under 500 milliseconds on ClickHouse, compared to minutes on a traditional row-oriented database.

Real-time aggregation uses a two-tier approach. For the last 24 hours of data, a streaming aggregation pipeline maintains pre-computed counters in Redis. For historical data, ClickHouse materialized views provide efficient query performance. The dashboard queries Redis for recent data and falls back to ClickHouse for historical ranges, giving users instant feedback on recent campaigns while still supporting deep historical analysis.

18. Database Design

The database layer must support both high-throughput writes (event logging, contact updates) and complex analytical queries (segmentation, reporting). We use a polyglot persistence approach with different databases optimized for different workloads.

Database Allocation Strategy

DatabaseWorkloadTables/TopicsReplication
PostgreSQL PrimaryTransactional writesaccounts, contacts, campaigns, templates, automationsPrimary + 3 read replicas
PostgreSQL ReplicasRead-heavy queriesAll tables, eventual consistencyAsync, lag below 100ms
Redis ClusterCaching, sessions, queuesContact cache, template cache, rate limits3 masters + 3 replicas
ClickHouseAnalytics queriesemail_events, sms_events, campaign_metrics3-way replication per shard
ElasticsearchFull-text searchContact search index, campaign search3 nodes minimum
S3 / MinIOObject storageEmail snapshots, export files, mediaMulti-AZ, versioned

PostgreSQL Partitioning Strategy

The event tables are partitioned by month using PostgreSQL native table partitioning. This keeps individual partition sizes manageable and allows efficient partition pruning for time-range queries.

-- Partitioned event tables for efficient time-range queries
CREATE TABLE email_events (
    event_id UUID NOT NULL DEFAULT gen_random_uuid(),
    account_id UUID NOT NULL,
    campaign_id UUID,
    message_id UUID NOT NULL,
    contact_id UUID NOT NULL,
    event_type VARCHAR(20) NOT NULL,
    metadata JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);

-- Monthly partitions
CREATE TABLE email_events_2026_01 PARTITION OF email_events
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE email_events_2026_02 PARTITION OF email_events
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

-- Indexes on partitioned tables
CREATE INDEX idx_email_events_account_created
    ON email_events (account_id, created_at DESC);
CREATE INDEX idx_email_events_campaign
    ON email_events (campaign_id, event_type);
CREATE INDEX idx_email_events_contact
    ON email_events (contact_id, created_at DESC);

-- JSONB index for attribute queries on contacts
CREATE INDEX idx_contacts_attributes_gin
    ON contacts USING GIN (attributes jsonb_path_ops);

-- Composite index for segmentation queries
CREATE INDEX idx_contacts_account_status_score
    ON contacts (account_id, status, lead_score DESC);
Partition Management: We create partitions 3 months in advance and drop partitions older than 24 months (after archiving to S3). A background job runs daily to create upcoming partitions and archive old ones. This keeps the PostgreSQL catalog size manageable even with billions of event rows.

19. Caching Strategy

Caching is essential for reducing database load and improving response times. The platform uses a multi-layered caching strategy with Redis as the primary distributed cache.

Cache Layers and TTLs

Cache LayerWhat is CachedTTLInvalidation
Redis - Contact CacheContact record by ID5 minutesWrite-through on update
Redis - Template CacheRendered email HTML15 minutesWrite-through on edit
Redis - Segment ResultsResolved segment IDs10 minutesTTL-based lazy refresh
Redis - Account CacheAccount config, quota1 hourWrite-through on change
Redis - Analytics CacheDashboard metrics30 secondsStreaming update from pipeline
CDN Edge CacheLanding pages, assets1 hourPurge on publish
Application In-MemoryConnection pools, DNSVariesLRU eviction

Cache Implementation

public class CachedContactService : IContactService
{
    private readonly IContactRepository _repository;
    private readonly IDistributedCache _cache;
    private readonly ILogger<CachedContactService> _logger;

    private static readonly TimeSpan CacheTtl =
        TimeSpan.FromMinutes(5);
    private const string CachePrefix = "contact:";

    public async Task<Contact> GetByIdAsync(
        Guid contactId, CancellationToken ct)
    {
        var cacheKey = $"{CachePrefix}{contactId}";
        var cached = await _cache.GetStringAsync(
            cacheKey, ct);

        if (cached != null)
        {
            return JsonSerializer.Deserialize<Contact>(
                cached);
        }

        var contact = await _repository.GetByIdAsync(
            contactId, ct);

        if (contact != null)
        {
            await _cache.SetStringAsync(cacheKey,
                JsonSerializer.Serialize(contact),
                new DistributedCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = CacheTtl
                }, ct);
        }

        return contact;
    }

    public async Task<Contact> UpsertAsync(
        UpsertContactRequest request,
        CancellationToken ct)
    {
        var contact = await _repository.UpsertAsync(
            request, ct);

        var cacheKey = $"{CachePrefix}{contact.ContactId}";
        await _cache.RemoveAsync(cacheKey, ct);
        await InvalidateSegmentCachesAsync(
            contact.AccountId, ct);

        return contact;
    }

    private async Task InvalidateSegmentCachesAsync(
        Guid accountId, CancellationToken ct)
    {
        _logger.LogInformation(
            "Invalidated segment caches for {AccountId}",
            accountId);
    }
}

The analytics cache is unique because it uses a streaming update pattern rather than cache-aside. The event processing pipeline writes pre-aggregated metrics to Redis keys as events arrive. The dashboard reads directly from these pre-computed keys, avoiding any cache miss scenario. The trade-off is that metrics can be up to 30 seconds stale, which is acceptable for dashboard display purposes.

20. Multi-Region Design

Brevo operates primarily in the EU (headquartered in Paris) and the US, with growing presence in other regions. GDPR requires that personal data of EU citizens be stored and processed within the EU, which drives the multi-region architecture.

Multi-Region Architecture

graph TB
    subgraph "Global Layer"
        GlobalDNS[Global DNS - Route53]
        GlobalCDN[CloudFront CDN]
        GlobalLB[Global Load Balancer]
    end
    subgraph "EU Region - Paris"
        EUAPI[EU API Cluster]
        EUDB[(EU PostgreSQL)]
        EURedis[(EU Redis)]
        EUSending[EU Email Sending]
    end
    subgraph "US Region - Virginia"
        USAPI[US API Cluster]
        USDB[(US PostgreSQL)]
        USRedis[(US Redis)]
        USSending[US Email Sending]
    end
    subgraph "Asia-Pacific - Singapore"
        APACAPI[APAC API Cluster]
        APACDB[(APAC PostgreSQL)]
        APACSending[APAC Email Sending]
    end
    GlobalDNS --> EUAPI
    GlobalDNS --> USAPI
    GlobalDNS --> APACAPI
    GlobalCDN --> GlobalLB
    GlobalLB --> EUAPI
    GlobalLB --> USAPI
    GlobalLB --> APACAPI
    EUAPI --> EUDB
    EUAPI --> EURedis
    USAPI --> USDB
    USAPI --> USRedis
    APACAPI --> APACDB
    EUDB -.->|Async analytics replication| USDB

Data Residency Rules

Customer LocationData StorageSending RegionAnalytics
EU (default)EU PostgreSQL clusterEU IP poolsEU ClickHouse
US (default)US PostgreSQL clusterUS IP poolsUS ClickHouse
EU with US analyticsEU primary, async to USEU IP poolsUS ClickHouse
APACSingapore clusterAPAC or nearestSingapore ClickHouse

Cross-region data replication is strictly limited. Contact personal data never leaves the region where it was created unless the customer explicitly opts into cross-region analytics. Campaign metrics and event data are anonymized before replication for aggregate reporting purposes.

The global load balancer routes users to their nearest region based on DNS geolocation. Each region operates independently with its own database, cache, and sending infrastructure. In the event of a regional outage, the system gracefully degrades — transactional email continues via the affected region's queue (which persists through the outage), while the dashboard shows stale data until the region recovers.

21. Cost Estimation

Understanding infrastructure costs is critical for a platform that charges $9-$18 per month per customer. The unit economics must work at every plan tier.

Per-Email Cost Breakdown

ComponentCost per 1,000 EmailsNotes
SMTP delivery (shared IP)$0.10 - $0.30Volume discounts from providers
SMTP delivery (dedicated IP)$0.20 - $0.50Includes IP rental amortized
Infrastructure (compute + DB)$0.05 - $0.15Amortized across all customers
Event processing and storage$0.03 - $0.08ClickHouse + S3 storage
Template rendering$0.01 - $0.02Minimal compute cost
Total per 1K emails$0.19 - $0.55Varies by plan and volume

SMS Cost Breakdown

ComponentCost per SMSNotes
Twilio (US)$0.0079Standard rate
Twilio (UK)$0.0329Higher international rate
Vonage (EU)$0.01 - $0.06Country-dependent
Platform markup10-20%Covers infrastructure and margin
Total customer price$0.01 - $0.08Sold in credit bundles

Monthly Infrastructure Cost Estimate (500K Customers)

ComponentMonthly Cost (USD)Details
Compute (Kubernetes, 3 regions)$85,000150+ pods, auto-scaling
PostgreSQL (RDS, 3 regions)$45,000r6g.2xlarge primary + replicas
Redis (ElastiCache, 3 regions)$15,000128GB cluster per region
ClickHouse (analytics, 2 regions)$25,0006-node cluster per region
Elasticsearch (2 regions)$12,0003-node cluster per region
RabbitMQ (queues, 3 regions)$8,0003-node quorum cluster per region
Kafka (event streaming)$18,000MSK cluster, 3 AZs
S3 storage (50TB/month)$5,000Templates, exports, archives
CDN (CloudFront)$3,000Landing pages, assets
Email delivery (SMTP)$120,000Bulk SMTP with volume discounts
SMS delivery$60,000500M messages at blended rate
WhatsApp Business API$25,000200M messages
Monitoring and observability$8,000Prometheus, Grafana, Sentry
Total monthly infrastructure$429,000Excluding personnel costs
Unit Economics: At $429K monthly infrastructure cost for 500K customers, the cost per customer is approximately $0.86 per month. Even at the $9 Starter plan, the gross margin is over 90% before accounting for personnel, support, and marketing costs. The per-email cost of roughly $0.034 allows Brevo to undercut competitors who charge $0.01-$0.03 per email but also charge per contact.

The key cost optimization strategies include using spot instances for compute workloads that can tolerate interruption, leveraging reserved instances for databases, implementing aggressive caching to reduce database load, and using columnar storage (ClickHouse) for analytics which is 10-20x more cost-efficient than row-oriented databases for analytical queries.

22. Interview Q&A

Here are common system design interview questions related to building an email marketing platform, along with detailed answers.

Q1: How would you design the email sending pipeline to handle 10 million emails per hour?

The pipeline uses a producer-consumer pattern with RabbitMQ as the message broker. Campaigns are broken into batches of 1,000 messages each, enqueued to a priority queue. Worker processes consume from the queue with configurable concurrency per IP pool. The Smart IP Router assigns each batch to the appropriate IP pool based on recipient mailbox provider, sender reputation, and current load. Rate limiters on each worker ensure we do not exceed provider limits. Backpressure is naturally handled by queue depth — if workers fall behind, the queue grows and producers slow down via queue length monitoring.

Q2: How do you handle the unlimited contacts requirement without degrading performance?

We use a multi-pronged approach. First, contacts are stored in PostgreSQL with partitioning by account_id for large accounts and standard indexing for smaller ones. Second, a GIN index on the JSONB attributes column supports flexible attribute queries without requiring schema changes. Third, segment results are cached in Redis with a 10-minute TTL to avoid recomputing expensive queries. Fourth, for accounts exceeding 1 million contacts, we implement background segment pre-computation that evaluates all active segments every 15 minutes. Fifth, Elasticsearch provides full-text search across contact attributes for the UI search bar.

Q3: How would you ensure email deliverability at scale?

Deliverability requires both technical and operational measures. Technically, we implement SPF, DKIM, and DMARC authentication, use dedicated IPs for high-volume senders, warm up new IPs gradually over 30 days, and maintain separate IP pools for transactional and marketing email. We implement feedback loops with major ISPs to process spam complaints in real-time. Operationally, we monitor bounce rates, complaint rates, and engagement metrics per account and per IP pool. Accounts exceeding thresholds are automatically throttled or suspended. We also implement seed list testing to verify inbox placement across major mailbox providers before sending campaigns.

Q4: How do you design the marketing automation engine for workflows that span days or weeks?

The automation engine uses a state machine pattern. Each workflow execution is persisted as a state object in Redis (for active) and PostgreSQL (for durable storage). When a workflow hits a delay node (e.g., wait 3 days), the execution state is serialized and enqueued to a delay queue backed by a Redis sorted set. A scheduler process scans the sorted set every minute and re-enqueues any executions whose delay has expired. This allows millions of pending delay nodes to be efficiently stored and retrieved without polling the database. The engine handles up to 10 million concurrent workflow executions across all accounts.

Q5: How would you handle a sudden spike where 100 customers schedule campaigns for the same time?

The campaign scheduler uses a token bucket algorithm to control campaign launch rate. Each IP pool has a maximum concurrent campaign capacity. When 100 campaigns are scheduled for the same time, they enter a launch queue and are started sequentially with configurable concurrency per IP pool. Campaigns are also internally batched — each campaign processes recipients in chunks of 1,000, with configurable delays between chunks. This ensures that even with 100 simultaneous campaigns, the aggregate sending rate stays within IP reputation limits. Customers see their campaign in Sending status and watch the progress in real-time.

Q6: How do you design the real-time analytics pipeline for campaign metrics?

Open and click events are captured via tracking pixels and redirect URLs. Each event is published to a Kafka topic with the campaign ID, contact ID, and event type. A Flink streaming job consumes from Kafka and maintains pre-aggregated counters in Redis, keyed by campaign ID and time window. The dashboard reads from Redis for sub-second response times. For historical analysis, a separate ETL job writes the raw events to ClickHouse every hour. ClickHouse materialized views maintain pre-aggregated rollups by day, week, and month. The dual approach provides both real-time dashboard updates and efficient historical queries.

Q7: How do you prevent a single customer from sending spam through your platform?

We implement defense in depth. At the API level, rate limiting caps requests per account per second. At the campaign level, each account has a monthly email quota based on their plan. At the content level, we scan email content for known spam patterns and suspicious URLs. At the list level, we flag accounts with high bounce rates and require list verification before sending. At the behavioral level, we monitor engagement metrics per account and automatically throttle accounts whose open rates drop below 5% or whose complaint rates exceed 0.05%. New accounts are placed in a monitoring period for 30 days with lower sending limits.

Q8: How would you design the A/B testing feature for email campaigns?

A/B testing splits a campaign into variants sent to random subsets of recipients. The campaign config specifies the variants (e.g., two subject lines), the split percentage (e.g., 20% test, 80% control), the winner metric (open rate or click rate), and the evaluation window (e.g., 4 hours). During the test phase, the campaign engine sends each variant to the specified percentage of recipients, tracked via variant tags. After the evaluation window, the analytics service calculates the winner based on the specified metric. The winning variant is then automatically sent to the remaining recipients. All variant assignment is deterministic based on a hash of contact_id to ensure consistency across retries.

Q9: How do you handle webhook delivery at scale with guaranteed delivery?

Webhooks are delivered asynchronously from the event processing pipeline. Each webhook delivery is retried up to 5 times with exponential backoff (1 min, 5 min, 30 min, 2 hours, 12 hours). Failed deliveries are logged with the full request and response for debugging. We implement webhook signatures using HMAC-SHA256 so customers can verify the authenticity of incoming webhooks. For high-volume accounts, we batch webhook events and deliver them in a single HTTP call every 5 seconds. Dead letter webhooks that fail all retries are stored for 7 days and can be manually replayed from the dashboard.

Q10: How would you migrate a customer's data between EU and US regions?

Cross-region migration is a multi-step process. First, we create a snapshot of all customer data in the source region (PostgreSQL dump + S3 export). The snapshot is encrypted with customer-specific keys and transferred via a secure cross-region replication channel. In the destination region, we restore the data, verify integrity by comparing record counts and checksums, and update DNS routing to point the customer to the new region. During the migration (typically 1-4 hours depending on data size), the customer's sending continues from the source region while the dashboard shows a migration-in-progress banner. After verification, we atomically switch traffic and archive the source data after a 7-day grace period.

23. Full C# Implementation

Below is a comprehensive C# implementation of the core services that make up a Brevo-like email marketing platform. This code demonstrates the key patterns discussed throughout the article, including the multi-channel campaign service, transactional email pipeline, SMS provider routing, and automation workflow engine.

Core Types and Interfaces

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;

namespace BrevoPlatform.Core
{
    public enum ChannelType { Email, Sms, WhatsApp }
    public enum CampaignStatus
    {
        Draft, Scheduled, Sending, Sent, Paused, Cancelled, Failed
    }
    public enum PlanTier { Free, Starter, Business, Enterprise }
    public enum MessagePriority { High, Normal, Low }

    public class Campaign
    {
        public Guid Id { get; set; }
        public Guid AccountId { get; set; }
        public string Name { get; set; }
        public CampaignStatus Status { get; set; }
        public List<ChannelConfig> Channels { get; set; } = new();
        public List<Guid> TargetListIds { get; set; } = new();
        public List<Guid> ExcludeListIds { get; set; } = new();
        public SegmentCriteria SegmentCriteria { get; set; }
        public string Subject { get; set; }
        public string SenderEmail { get; set; }
        public string SenderName { get; set; }
        public string Content { get; set; }
        public int TotalRecipients { get; set; }
        public int TotalSent { get; set; }
        public int TotalDelivered { get; set; }
        public int TotalOpened { get; set; }
        public int TotalClicked { get; set; }
        public int TotalBounced { get; set; }
        public int TotalUnsubscribed { get; set; }
        public DateTime? ScheduledAt { get; set; }
        public DateTime? StartedAt { get; set; }
        public DateTime? CompletedAt { get; set; }
        public DateTime CreatedAt { get; set; }
    }

    public class ChannelConfig
    {
        public ChannelType Type { get; set; }
        public string SmsTemplate { get; set; }
        public string WhatsAppTemplateName { get; set; }
        public string WhatsAppTemplateLanguage { get; set; }
        public Dictionary<string, string> WhatsAppParameters { get; set; }
            = new();
        public int ThrottleDelayMs { get; set; } = 100;
    }

    public class SegmentCriteria
    {
        public List<SegmentCondition> Conditions { get; set; } = new();
        public string Logic { get; set; } = "AND";
    }

    public class SegmentCondition
    {
        public string Field { get; set; }
        public string Operator { get; set; }
        public object Value { get; set; }
    }

    public class Recipient
    {
        public Guid ContactId { get; set; }
        public string Email { get; set; }
        public string Name { get; set; }
        public string PhoneNumber { get; set; }
        public bool SmsOptIn { get; set; }
        public bool WhatsAppOptIn { get; set; }
        public Dictionary<string, object> Attributes { get; set; }
            = new();
    }

    public class OutboundEmail
    {
        public Guid MessageId { get; set; } = Guid.NewGuid();
        public Guid CampaignId { get; set; }
        public Guid AccountId { get; set; }
        public string RecipientEmail { get; set; }
        public string RecipientName { get; set; }
        public string SenderEmail { get; set; }
        public string SenderName { get; set; }
        public string Subject { get; set; }
        public string HtmlContent { get; set; }
        public string TextContent { get; set; }
        public Dictionary<string, object> DynamicData { get; set; }
            = new();
        public List<string> Tags { get; set; } = new();
        public MessagePriority Priority { get; set; }
            = MessagePriority.Normal;
        public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    }

    public class OutboundSms
    {
        public Guid SmsId { get; set; } = Guid.NewGuid();
        public Guid CampaignId { get; set; }
        public Guid AccountId { get; set; }
        public string RecipientPhone { get; set; }
        public string Content { get; set; }
        public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    }

    public class OutboundWhatsApp
    {
        public Guid WhatsAppId { get; set; } = Guid.NewGuid();
        public Guid CampaignId { get; set; }
        public Guid AccountId { get; set; }
        public string RecipientPhone { get; set; }
        public string TemplateName { get; set; }
        public string TemplateLanguage { get; set; }
        public Dictionary<string, string> Parameters { get; set; }
            = new();
        public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    }

    public interface ICampaignRepository
    {
        Task<Campaign> GetByIdAsync(Guid id, CancellationToken ct);
        Task UpdateAsync(Campaign campaign, CancellationToken ct);
    }

    public interface IContactSegmentationService
    {
        Task<List<Recipient>> ResolveRecipientsAsync(
            List<Guid> targetLists,
            List<Guid> excludeLists,
            SegmentCriteria criteria,
            CancellationToken ct);
    }

    public interface IEmailChannelService
    {
        Task EnqueueBatchAsync(
            List<OutboundEmail> messages, CancellationToken ct);
    }

    public interface ISmsChannelService
    {
        Task EnqueueAsync(OutboundSms message, CancellationToken ct);
    }

    public interface IWhatsAppChannelService
    {
        Task EnqueueAsync(OutboundWhatsApp message, CancellationToken ct);
    }

    public interface IEventBus
    {
        Task PublishAsync<T>(string topic, T message, CancellationToken ct);
    }
}

Multi-Channel Campaign Service

namespace BrevoPlatform.Services
{
    public class MultiChannelCampaignService
    {
        private readonly ICampaignRepository _campaignRepo;
        private readonly IContactSegmentationService _segmentService;
        private readonly IEmailChannelService _emailService;
        private readonly ISmsChannelService _smsService;
        private readonly IWhatsAppChannelService _whatsappService;
        private readonly IEventBus _eventBus;
        private readonly ILogger<MultiChannelCampaignService> _logger;

        public MultiChannelCampaignService(
            ICampaignRepository campaignRepo,
            IContactSegmentationService segmentService,
            IEmailChannelService emailService,
            ISmsChannelService smsService,
            IWhatsAppChannelService whatsappService,
            IEventBus eventBus,
            ILogger<MultiChannelCampaignService> logger)
        {
            _campaignRepo = campaignRepo;
            _segmentService = segmentService;
            _emailService = emailService;
            _smsService = smsService;
            _whatsappService = whatsappService;
            _eventBus = eventBus;
            _logger = logger;
        }

        public async Task<CampaignLaunchResult> LaunchCampaignAsync(
            Guid campaignId, CancellationToken ct)
        {
            var campaign = await _campaignRepo
                .GetByIdAsync(campaignId, ct);
            if (campaign == null)
                return CampaignLaunchResult.Failed("Campaign not found");

            if (campaign.Status != CampaignStatus.Draft
                && campaign.Status != CampaignStatus.Scheduled)
                return CampaignLaunchResult.Failed(
                    $"Cannot launch from {campaign.Status}");

            campaign.Status = CampaignStatus.Sending;
            campaign.StartedAt = DateTime.UtcNow;
            await _campaignRepo.UpdateAsync(campaign, ct);

            try
            {
                var recipients = await _segmentService
                    .ResolveRecipientsAsync(
                        campaign.TargetListIds,
                        campaign.ExcludeListIds,
                        campaign.SegmentCriteria, ct);

                if (recipients.Count == 0)
                {
                    campaign.Status = CampaignStatus.Completed;
                    campaign.CompletedAt = DateTime.UtcNow;
                    await _campaignRepo.UpdateAsync(campaign, ct);
                    return CampaignLaunchResult.Success(0, "No recipients");
                }

                campaign.TotalRecipients = recipients.Count;
                await _campaignRepo.UpdateAsync(campaign, ct);

                var results = new Dictionary<ChannelType, int>();
                foreach (var channel in campaign.Channels)
                {
                    var count = channel.Type switch
                    {
                        ChannelType.Email => await SendEmails(
                            campaign, channel, recipients, ct),
                        ChannelType.Sms => await SendSms(
                            campaign, channel, recipients, ct),
                        ChannelType.WhatsApp => await SendWhatsApp(
                            campaign, channel, recipients, ct),
                        _ => 0
                    };
                    results[channel.Type] = count;
                }

                campaign.TotalSent = results.Values.Sum();
                campaign.Status = CampaignStatus.Sent;
                campaign.CompletedAt = DateTime.UtcNow;
                await _campaignRepo.UpdateAsync(campaign, ct);

                await _eventBus.PublishAsync("campaign.completed",
                    new { campaign.Id, campaign.AccountId,
                        campaign.TotalSent, results }, ct);

                return CampaignLaunchResult.Success(
                    campaign.TotalSent, results);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Campaign {Id} failed", campaignId);
                campaign.Status = CampaignStatus.Failed;
                await _campaignRepo.UpdateAsync(campaign, ct);
                return CampaignLaunchResult.Failed(ex.Message);
            }
        }

        private async Task<int> SendEmails(
            Campaign campaign, ChannelConfig channel,
            List<Recipient> recipients, CancellationToken ct)
        {
            var total = 0;
            foreach (var batch in recipients.Chunk(1000))
            {
                var messages = batch.Select(r => new OutboundEmail
                {
                    CampaignId = campaign.Id,
                    AccountId = campaign.AccountId,
                    RecipientEmail = r.Email,
                    RecipientName = r.Name,
                    SenderEmail = campaign.SenderEmail,
                    SenderName = campaign.SenderName,
                    Subject = Vars(campaign.Subject, r.Attributes),
                    HtmlContent = Vars(campaign.Content, r.Attributes),
                    DynamicData = r.Attributes,
                    Tags = new List<string> { "campaign", campaign.Id.ToString() }
                }).ToList();
                await _emailService.EnqueueBatchAsync(messages, ct);
                total += messages.Count;
                if (channel.ThrottleDelayMs > 0)
                    await Task.Delay(channel.ThrottleDelayMs, ct);
            }
            return total;
        }

        private async Task<int> SendSms(
            Campaign campaign, ChannelConfig channel,
            List<Recipient> recipients, CancellationToken ct)
        {
            var valid = recipients
                .Where(r => r.SmsOptIn && !string.IsNullOrEmpty(r.PhoneNumber))
                .ToList();
            foreach (var r in valid)
            {
                await _smsService.EnqueueAsync(new OutboundSms
                {
                    CampaignId = campaign.Id,
                    AccountId = campaign.AccountId,
                    RecipientPhone = r.PhoneNumber,
                    Content = Vars(channel.SmsTemplate, r.Attributes)
                }, ct);
                if (channel.ThrottleDelayMs > 0)
                    await Task.Delay(channel.ThrottleDelayMs, ct);
            }
            return valid.Count;
        }

        private async Task<int> SendWhatsApp(
            Campaign campaign, ChannelConfig channel,
            List<Recipient> recipients, CancellationToken ct)
        {
            var valid = recipients
                .Where(r => r.WhatsAppOptIn && !string.IsNullOrEmpty(r.PhoneNumber))
                .ToList();
            foreach (var r in valid)
            {
                await _whatsappService.EnqueueAsync(new OutboundWhatsApp
                {
                    CampaignId = campaign.Id,
                    AccountId = campaign.AccountId,
                    RecipientPhone = r.PhoneNumber,
                    TemplateName = channel.WhatsAppTemplateName,
                    TemplateLanguage = channel.WhatsAppTemplateLanguage,
                    Parameters = channel.WhatsAppParameters
                }, ct);
                if (channel.ThrottleDelayMs > 0)
                    await Task.Delay(channel.ThrottleDelayMs, ct);
            }
            return valid.Count;
        }

        private string Vars(string template, Dictionary<string, object> data)
        {
            if (string.IsNullOrEmpty(template) || data == null)
                return template ?? string.Empty;
            var result = template;
            foreach (var kvp in data)
                result = result.Replace("{{" + kvp.Key + "}}",
                    kvp.Value?.ToString() ?? string.Empty);
            return result;
        }
    }

    public class CampaignLaunchResult
    {
        public bool IsSuccess { get; set; }
        public string ErrorMessage { get; set; }
        public int TotalSent { get; set; }
        public Dictionary<ChannelType, int> ChannelBreakdown { get; set; }
        public static CampaignLaunchResult Success(int sent, string msg = null)
            => new() { IsSuccess = true, TotalSent = sent,
                ChannelBreakdown = new Dictionary<ChannelType, int>() };
        public static CampaignLaunchResult Success(int sent, Dictionary<ChannelType, int> bd)
            => new() { IsSuccess = true, TotalSent = sent, ChannelBreakdown = bd };
        public static CampaignLaunchResult Failed(string error)
            => new() { IsSuccess = false, ErrorMessage = error };
    }
}

SMS Provider Router with Failover

namespace BrevoPlatform.Channels
{
    public interface ISmsProvider
    {
        Task<SmsSendResult> SendAsync(OutboundSms msg, CancellationToken ct);
    }

    public class SmsSendResult
    {
        public bool IsSuccess { get; set; }
        public string ProviderMessageId { get; set; }
        public string ErrorMessage { get; set; }
        public decimal Cost { get; set; }
        public static SmsSendResult Success(string id, decimal cost)
            => new() { IsSuccess = true, ProviderMessageId = id, Cost = cost };
        public static SmsSendResult Failed(string error)
            => new() { IsSuccess = false, ErrorMessage = error };
    }

    public class SmsProviderConfig
    {
        public string Name { get; set; }
        public ISmsProvider Provider { get; set; }
        public int MaxRetries { get; set; } = 3;
        public SmsProviderConfig Fallback { get; set; }
    }

    public class SmsProviderRouter
    {
        private readonly Dictionary<string, SmsProviderConfig> _providers;
        private readonly ILogger<SmsProviderRouter> _logger;

        public SmsProviderRouter(
            Dictionary<string, SmsProviderConfig> providers,
            ILogger<SmsProviderRouter> logger)
        {
            _providers = providers;
            _logger = logger;
        }

        public async Task<SmsSendResult> SendAsync(
            OutboundSms message, CancellationToken ct)
        {
            var country = ExtractCountry(message.RecipientPhone);
            var config = SelectProvider(country);

            for (int i = 0; i < config.MaxRetries; i++)
            {
                try
                {
                    var result = await config.Provider.SendAsync(message, ct);
                    if (result.IsSuccess)
                    {
                        _logger.LogInformation(
                            "SMS {Id} sent via {Provider}", message.SmsId, config.Name);
                        return result;
                    }
                    _logger.LogWarning("SMS {Id} attempt {A} failed: {Err}",
                        message.SmsId, i + 1, result.ErrorMessage);
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "SMS {Id} attempt {A} exception",
                        message.SmsId, i + 1);
                }

                if (i < config.MaxRetries - 1)
                    await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i)), ct);
            }

            if (config.Fallback != null)
            {
                _logger.LogWarning("SMS {Id} using fallback", message.SmsId);
                return await config.Fallback.Provider.SendAsync(message, ct);
            }

            return SmsSendResult.Failed("All providers exhausted");
        }

        private SmsProviderConfig SelectProvider(string countryCode)
        {
            if (_providers.TryGetValue(countryCode, out var cfg)) return cfg;
            return _providers.ContainsKey("*") ? _providers["*"] : null;
        }

        private string ExtractCountry(string phone)
        {
            if (string.IsNullOrEmpty(phone)) return "*";
            var c = phone.TrimStart('+', '0');
            if (c.StartsWith("1")) return "US";
            if (c.StartsWith("44")) return "UK";
            if (c.StartsWith("33")) return "FR";
            if (c.StartsWith("49")) return "DE";
            if (c.StartsWith("91")) return "IN";
            return c.Substring(0, Math.Min(2, c.Length));
        }
    }
}

Automation Workflow Engine

namespace BrevoPlatform.Automation
{
    public enum NodeType { Trigger, Action, Delay, Condition, ABTest }

    public class Workflow
    {
        public Guid Id { get; set; }
        public string Name { get; set; }
        public List<WorkflowNode> Nodes { get; set; } = new();
        public List<WorkflowEdge> Edges { get; set; } = new();
        public WorkflowNode GetNode(string nodeId)
            => Nodes.FirstOrDefault(n => n.Id == nodeId);
    }

    public class WorkflowNode
    {
        public string Id { get; set; }
        public NodeType Type { get; set; }
        public string Action { get; set; }
        public Dictionary<string, object> Config { get; set; } = new();
        public string TruePath { get; set; }
        public string FalsePath { get; set; }
    }

    public class WorkflowEdge
    {
        public string From { get; set; }
        public string To { get; set; }
        public string Condition { get; set; }
    }

    public class WorkflowExecution
    {
        public Guid ExecutionId { get; set; }
        public Guid WorkflowId { get; set; }
        public Guid AccountId { get; set; }
        public Guid ContactId { get; set; }
        public string CurrentNodeId { get; set; }
        public DateTime StartedAt { get; set; }
        public Dictionary<string, object> VariableContext { get; set; } = new();
    }

    public interface IWorkflowRepository
    {
        Task<List<Workflow>> GetActiveByTriggerAsync(string trigger, CancellationToken ct);
        Task<Workflow> GetByIdAsync(Guid id, CancellationToken ct);
        Task<bool> MatchesFilterAsync(Guid wfId, Guid accountId,
            Dictionary<string, object> data, CancellationToken ct);
    }

    public interface IExecutionStateStore
    {
        Task SaveAsync(WorkflowExecution exec, CancellationToken ct);
        Task<WorkflowExecution> LoadAsync(Guid execId, CancellationToken ct);
    }

    public interface IDelayQueue
    {
        Task ScheduleAsync(WorkflowExecution exec, TimeSpan delay, CancellationToken ct);
    }

    public class AutomationWorkflowEngine
    {
        private readonly IWorkflowRepository _wfRepo;
        private readonly IExecutionStateStore _stateStore;
        private readonly IDelayQueue _delayQueue;
        private readonly IServiceProvider _sp;
        private readonly ILogger<AutomationWorkflowEngine> _log;

        public AutomationWorkflowEngine(
            IWorkflowRepository wfRepo, IExecutionStateStore stateStore,
            IDelayQueue delayQueue, IServiceProvider sp,
            ILogger<AutomationWorkflowEngine> log)
        {
            _wfRepo = wfRepo;
            _stateStore = stateStore;
            _delayQueue = delayQueue;
            _sp = sp;
            _log = log;
        }

        public async Task ProcessTriggerAsync(
            string triggerType, Guid accountId,
            Dictionary<string, object> data, CancellationToken ct)
        {
            var workflows = await _wfRepo
                .GetActiveByTriggerAsync(triggerType, ct);

            foreach (var wf in workflows)
            {
                if (!await _wfRepo.MatchesFilterAsync(
                    wf.Id, accountId, data, ct)) continue;

                var exec = new WorkflowExecution
                {
                    ExecutionId = Guid.NewGuid(),
                    WorkflowId = wf.Id,
                    AccountId = accountId,
                    ContactId = GetContactId(data),
                    CurrentNodeId = wf.Nodes
                        .First(n => n.Type == NodeType.Trigger).Id,
                    StartedAt = DateTime.UtcNow,
                    VariableContext = new Dictionary<string, object>(data)
                };

                await _stateStore.SaveAsync(exec, ct);
                await ExecuteNodeAsync(wf, exec, ct);
            }
        }

        public async Task ResumeFromDelayAsync(
            WorkflowExecution exec, CancellationToken ct)
        {
            var wf = await _wfRepo.GetByIdAsync(exec.WorkflowId, ct);
            if (wf != null) await ExecuteNodeAsync(wf, exec, ct);
        }

        private async Task ExecuteNodeAsync(
            Workflow wf, WorkflowExecution exec, CancellationToken ct)
        {
            var node = wf.GetNode(exec.CurrentNodeId);
            if (node == null) return;

            switch (node.Type)
            {
                case NodeType.Action:
                    await RunActionAsync(node, exec, ct);
                    var next = NextNode(wf, node.Id, null);
                    if (next != null)
                    {
                        exec.CurrentNodeId = next.Id;
                        await _stateStore.SaveAsync(exec, ct);
                        await ExecuteNodeAsync(wf, exec, ct);
                    }
                    break;

                case NodeType.Delay:
                    var after = NextNode(wf, node.Id, null);
                    if (after != null)
                    {
                        exec.CurrentNodeId = after.Id;
                        await _delayQueue.ScheduleAsync(
                            exec, CalcDelay(node.Config), ct);
                        await _stateStore.SaveAsync(exec, ct);
                    }
                    break;

                case NodeType.Condition:
                    var cond = await EvalConditionAsync(node.Config, exec, ct);
                    var path = NextNode(wf, node.Id, cond ? "true" : "false");
                    if (path != null)
                    {
                        exec.CurrentNodeId = path.Id;
                        await _stateStore.SaveAsync(exec, ct);
                        await ExecuteNodeAsync(wf, exec, ct);
                    }
                    break;

                case NodeType.ABTest:
                    var variant = EvalAB(node.Config, exec.ContactId);
                    var abPath = NextNode(wf, node.Id, variant);
                    if (abPath != null)
                    {
                        exec.CurrentNodeId = abPath.Id;
                        await _stateStore.SaveAsync(exec, ct);
                        await ExecuteNodeAsync(wf, exec, ct);
                    }
                    break;
            }
        }

        private async Task RunActionAsync(
            WorkflowNode node, WorkflowExecution exec, CancellationToken ct)
        {
            var action = node.Action ?? node.Config["action"]?.ToString();

            switch (action)
            {
                case "send_email":
                    var email = new OutboundEmail
                    {
                        CampaignId = exec.WorkflowId,
                        AccountId = exec.AccountId,
                        RecipientEmail = exec.VariableContext
                            .GetValueOrDefault("email")?.ToString(),
                        Subject = Vars(node.Config["subject"]?.ToString(),
                            exec.VariableContext),
                        HtmlContent = Vars(node.Config["content"]?.ToString(),
                            exec.VariableContext),
                        Tags = new List<string> { "automation", exec.WorkflowId.ToString() }
                    };
                    await _sp.GetRequiredService<IEmailChannelService>()
                        .EnqueueBatchAsync(new List<OutboundEmail> { email }, ct);
                    break;

                case "send_sms":
                    var sms = new OutboundSms
                    {
                        CampaignId = exec.WorkflowId,
                        AccountId = exec.AccountId,
                        RecipientPhone = exec.VariableContext
                            .GetValueOrDefault("phone")?.ToString(),
                        Content = Vars(node.Config["content"]?.ToString(),
                            exec.VariableContext)
                    };
                    await _sp.GetRequiredService<ISmsChannelService>()
                        .EnqueueAsync(sms, ct);
                    break;

                case "update_attributes":
                    var attrs = JsonSerializer.Deserialize<Dictionary<string, object>>(
                        node.Config["attributes"].ToString());
                    await _sp.GetRequiredService<IContactService>()
                        .UpdateAttributesAsync(exec.ContactId, attrs, ct);
                    break;

                case "add_to_list":
                    var listId = Guid.Parse(node.Config["listId"].ToString());
                    await _sp.GetRequiredService<IContactService>()
                        .AddToListAsync(exec.ContactId, listId, ct);
                    break;

                case "webhook":
                    await _sp.GetRequiredService<IWebhookService>()
                        .CallAsync(exec.AccountId, node.Config["url"].ToString(),
                            exec.VariableContext, ct);
                    break;
            }
        }

        private async Task<bool> EvalConditionAsync(
            Dictionary<string, object> config,
            WorkflowExecution exec, CancellationToken ct)
        {
            var svc = _sp.GetRequiredService<IContactService>();
            var actual = await svc.EvaluateFieldAsync(
                exec.ContactId, config["field"]?.ToString(), ct);
            var expected = config["value"];
            var op = config["operator"]?.ToString();

            return op switch
            {
                "equals" => actual?.ToString() == expected?.ToString(),
                "not_equals" => actual?.ToString() != expected?.ToString(),
                "gt" => double.TryParse(actual?.ToString(), out var a)
                    && double.TryParse(expected?.ToString(), out var b) && a > b,
                "lt" => double.TryParse(actual?.ToString(), out var a2)
                    && double.TryParse(expected?.ToString(), out var b2) && a2 < b2,
                "contains" => actual?.ToString()?.Contains(expected?.ToString() ?? "") == true,
                _ => false
            };
        }

        private string EvalAB(Dictionary<string, object> config, Guid contactId)
        {
            var variants = JsonSerializer.Deserialize<List<string>>(
                config["variants"].ToString());
            var split = int.Parse(config["splitPercentage"].ToString());
            return Math.Abs(contactId.GetHashCode()) % 100 < split
                ? variants[0] : variants[1];
        }

        private WorkflowNode NextNode(Workflow wf, string from, string cond)
        {
            var edge = wf.Edges.FirstOrDefault(e =>
                e.From == from && (cond == null || e.Condition == cond));
            return edge != null ? wf.GetNode(edge.To) : null;
        }

        private TimeSpan CalcDelay(Dictionary<string, object> config)
        {
            var d = int.Parse(config["duration"].ToString());
            return config["unit"]?.ToString()?.ToLower() switch
            {
                "minutes" => TimeSpan.FromMinutes(d),
                "hours" => TimeSpan.FromHours(d),
                "days" => TimeSpan.FromDays(d),
                "weeks" => TimeSpan.FromDays(d * 7),
                _ => TimeSpan.FromDays(d)
            };
        }

        private string Vars(string template, Dictionary<string, object> data)
        {
            if (string.IsNullOrEmpty(template)) return "";
            var r = template;
            foreach (var kv in data)
                r = r.Replace("{{" + kv.Key + "}}", kv.Value?.ToString() ?? "");
            return r;
        }

        private Guid GetContactId(Dictionary<string, object> data)
        {
            if (data.TryGetValue("contact_id", out var v))
            {
                if (v is Guid g) return g;
                if (Guid.TryParse(v?.ToString(), out var pg)) return pg;
            }
            return Guid.Empty;
        }
    }
}

Transaction Email Service

namespace BrevoPlatform.Services
{
    public class TransactionalEmailService
    {
        private readonly ITemplateRenderer _renderer;
        private readonly IPriorityQueue _queue;
        private readonly IAccountQuotaService _quota;
        private readonly IDkimSigner _dkim;
        private readonly ILogger<TransactionalEmailService> _log;

        public TransactionalEmailService(
            ITemplateRenderer renderer, IPriorityQueue queue,
            IAccountQuotaService quota, IDkimSigner dkim,
            ILogger<TransactionalEmailService> log)
        {
            _renderer = renderer;
            _queue = queue;
            _quota = quota;
            _dkim = dkim;
            _log = log;
        }

        public async Task<SendResult> SendAsync(
            SendTransactionalRequest req, CancellationToken ct)
        {
            var account = await _quota
                .ValidateAndGetAccountAsync(req.ApiKey, ct);

            if (!await _quota.HasRemainingQuotaAsync(account.Id, ct))
                return SendResult.Failure("QUOTA_EXCEEDED",
                    "Monthly email quota reached.");

            var template = await _renderer
                .GetTemplateAsync(req.TemplateId, account.Id, ct);

            var rendered = await _renderer.RenderAsync(
                template, req.DynamicData, new RenderContext
                {
                    TrackOpens = account.PlanTier != PlanTier.Free,
                    TrackClicks = account.PlanTier != PlanTier.Free,
                    BaseUrl = $"https://track.{account.SendingDomain}",
                    UnsubscribeUrl =
                        $"https://unsub.brevo.example.com/{account.Id}/{{{{contact.email}}}}"
                }, ct);

            var message = new OutboundEmail
            {
                MessageId = Guid.NewGuid(),
                AccountId = account.Id,
                RecipientEmail = req.To.Email,
                RecipientName = req.To.Name,
                SenderEmail = req.Sender?.Email ?? account.DefaultSenderEmail,
                SenderName = req.Sender?.Name ?? account.DefaultSenderName,
                Subject = rendered.Subject,
                HtmlContent = rendered.Html,
                TextContent = rendered.Text,
                Priority = req.Priority,
                Tags = req.Tags ?? new List<string>(),
                CreatedAt = DateTime.UtcNow
            };

            message.DkimSignature = await _dkim
                .SignAsync(message, account.SendingDomain, ct);

            await _queue.EnqueueAsync("email.priority.normal", message, ct);
            await _quota.IncrementUsageAsync(account.Id, 1, ct);

            _log.LogInformation("Transactional {Id} queued for {Email}",
                message.MessageId, message.RecipientEmail);

            return SendResult.Success(message.MessageId);
        }
    }

    public class SendTransactionalRequest
    {
        public string ApiKey { get; set; }
        public EmailAddress To { get; set; }
        public EmailAddress Sender { get; set; }
        public EmailAddress ReplyTo { get; set; }
        public Guid TemplateId { get; set; }
        public Dictionary<string, object> DynamicData { get; set; } = new();
        public MessagePriority Priority { get; set; } = MessagePriority.Normal;
        public List<string> Tags { get; set; }
    }

    public class EmailAddress
    {
        public string Email { get; set; }
        public string Name { get; set; }
    }

    public class SendResult
    {
        public bool IsSuccess { get; set; }
        public Guid? MessageId { get; set; }
        public string ErrorCode { get; set; }
        public string ErrorMessage { get; set; }
        public static SendResult Success(Guid id)
            => new() { IsSuccess = true, MessageId = id };
        public static SendResult Failure(string code, string msg)
            => new() { IsSuccess = false, ErrorCode = code, ErrorMessage = msg };
    }

    public interface IPriorityQueue
    {
        Task EnqueueAsync<T>(string queue, T message, CancellationToken ct);
    }

    public interface IDkimSigner
    {
        Task<string> SignAsync(OutboundEmail msg, string domain, CancellationToken ct);
    }

    public interface IAccountQuotaService
    {
        Task<AccountConfig> ValidateAndGetAccountAsync(string apiKey, CancellationToken ct);
        Task<bool> HasRemainingQuotaAsync(Guid accountId, CancellationToken ct);
        Task IncrementUsageAsync(Guid accountId, int count, CancellationToken ct);
    }

    public class AccountConfig
    {
        public Guid Id { get; set; }
        public PlanTier PlanTier { get; set; }
        public string SendingDomain { get; set; }
        public string DefaultSenderEmail { get; set; }
        public string DefaultSenderName { get; set; }
    }

    public interface ITemplateRenderer
    {
        Task<EmailTemplate> GetTemplateAsync(Guid templateId, Guid accountId, CancellationToken ct);
        Task<RenderedContent> RenderAsync(EmailTemplate template,
            Dictionary<string, object> data, RenderContext ctx, CancellationToken ct);
    }

    public class EmailTemplate
    {
        public Guid TemplateId { get; set; }
        public string Subject { get; set; }
        public string HtmlContent { get; set; }
        public string TextContent { get; set; }
    }

    public class RenderedContent
    {
        public string Subject { get; set; }
        public string Html { get; set; }
        public string Text { get; set; }
    }

    public class RenderContext
    {
        public bool TrackOpens { get; set; }
        public bool TrackClicks { get; set; }
        public string BaseUrl { get; set; }
        public string UnsubscribeUrl { get; set; }
    }
}
Implementation Notes: The code above demonstrates production-quality patterns including proper async/await usage, dependency injection, structured logging, circuit breaker patterns (via retry with exponential backoff in the SMS router), and separation of concerns across service boundaries. In a real deployment, these services would be split across microservices communicating via message queues and gRPC.

24. Conclusion

Designing a budget email marketing platform like Brevo is a masterclass in balancing engineering excellence with cost efficiency. The platform must deliver enterprise-grade capabilities — multi-channel campaigns across email, SMS, and WhatsApp; marketing automation with visual workflow builders; real-time analytics dashboards; and built-in CRM — all while keeping infrastructure costs low enough to sustain a $9 per month plan with unlimited contacts.

The key architectural insights that make this possible are: first, the separation of management and data planes allows each to scale independently based on its own characteristics. Second, queue-driven asynchronous processing provides natural backpressure and fault isolation for the high-volume sending pipeline. Third, a polyglot persistence strategy — PostgreSQL for transactions, Redis for caching, ClickHouse for analytics, Elasticsearch for search — ensures each workload runs on the most efficient storage engine. Fourth, the multi-channel campaign engine and automation workflow engine use state machine patterns that can persist execution state across hours or days while supporting millions of concurrent workflows.

Deliverability remains the most challenging and highest-stakes aspect of the entire system. All the engineering in the world cannot save a platform that lands in the spam folder. The IP pool architecture, warmup schedules, authentication protocols, bounce processing, and complaint handling must work in concert to maintain the sender reputation that determines whether emails reach the inbox.

For engineers preparing for system design interviews, this architecture demonstrates several important patterns: event-driven architecture with message queues for decoupling, CQRS for separating read and write workloads, strategic caching at multiple layers, polyglot persistence for specialized workloads, and microservices decomposition aligned with business capabilities rather than technical layers.

The Brevo model proves that building affordable developer tools does not require compromising on technical quality. With the right architecture — built on open-source foundations like PostgreSQL, Redis, RabbitMQ, and ClickHouse — it is possible to serve millions of customers, process billions of messages, and still maintain the deliverability reputation and compliance posture required to operate in the heavily regulated email and messaging ecosystem.

The complete C# implementation provided in this article can serve as a starting point for building your own marketing automation platform. The interfaces are designed for dependency injection and testability, the service boundaries are cleanly separated for eventual microservice decomposition, and the patterns (priority queuing, retry with backoff, write-through caching, state machine execution) are directly applicable to production systems.

Final Thought: Whether you are building a marketing platform, preparing for a system design interview, or scaling an existing email infrastructure, the patterns and principles covered in this article — from IP reputation management to workflow state machines to cost estimation — provide a comprehensive foundation for understanding how modern multi-channel marketing platforms work at scale.

The email marketing industry continues to evolve with the rise of AI-powered content generation, predictive send-time optimization, and advanced personalization engines. The architecture described here is designed to accommodate these future capabilities through its modular service boundaries and extensible event processing pipeline. As new channels emerge and customer expectations evolve, the fundamental principles of queue-driven asynchronous processing, multi-tier caching, and polyglot persistence will remain the bedrock of scalable marketing infrastructure.

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

Published: July 1, 2026 | Last updated: July 1, 2026