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
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.
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.
| Capability | Description | Priority |
|---|---|---|
| Campaign Builder | Create and schedule email, SMS, and WhatsApp campaigns with a visual editor | P0 |
| Multi-Channel Sending | Send campaigns across email, SMS, and WhatsApp channels simultaneously | P0 |
| Transactional Email | API-driven transactional email delivery for password resets, order confirmations, and receipts | P0 |
| Contact Management | Unlimited contacts per account with custom attributes, tags, and lists | P0 |
| Segmentation | Dynamic and static segments based on contact attributes and behavioral events | P1 |
| Marketing Automation | Visual workflow builder with triggers, conditions, delays, and actions | P1 |
| Email Template Editor | Drag-and-drop email builder with responsive pre-built templates | P0 |
| Landing Page Builder | Drag-and-drop landing page creation with form embedding | P2 |
| Forms and Pop-ups | Embeddable forms for lead capture with double opt-in support | P1 |
| CRM | Basic CRM with deal tracking, pipeline management, and contact scoring | P2 |
| Live Chat | Embeddable chat widget for real-time conversations with website visitors | P2 |
| Analytics Dashboard | Campaign performance dashboards with open rates, click rates, and revenue attribution | P1 |
| API Access | RESTful API for programmatic access to all platform features | P0 |
| Webhooks | Event-driven webhooks for external integrations | P1 |
| A/B Testing | Split testing for subject lines, content, send times, and sender names | P1 |
| GDPR Tools | Consent management, data export, right-to-erasure workflow | P0 |
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Transactional Latency | P99 less than 2 seconds | Users expect near-instant transactional email delivery |
| Campaign Throughput | 10M emails per hour | Large campaigns must complete within hours not days |
| Availability | 99.95% uptime | Transactional email must never go down |
| Contact Scale | 100M contacts per tenant | Brevo's unlimited contacts promise requires extreme scale per account |
| Deliverability | Inbox placement above 90% | Low deliverability destroys platform reputation |
| Data Residency | EU and US regions | GDPR requires EU data to stay in EU for many customers |
| Compliance | GDPR, CAN-SPAM, CCPA, LGPD | Legal requirements for email marketing platforms |
| Event Retention | Event logs for 2 years | Compliance and analytics requirements |
| API Rate Limiting | Per-account configurable | Prevent abuse and ensure fair resource allocation |
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
| Metric | Value | Calculation |
|---|---|---|
| Total customers | 500,000 | Blended free and paid accounts |
| Average emails sent per month per customer | 25,000 | Mix of Starter (10K) and Business (40K) plans |
| Total emails per month | 12.5 billion | 500K multiplied by 25K |
| Average emails per second | ~4,800 | 12.5B divided by (30 x 24 x 3600) |
| Peak emails per second | ~25,000 | 5x average during peak hours (9-11 AM local) |
| Transactional percentage of total | 15% | Most volume is marketing campaigns |
| Marketing email percentage | 85% | Bulk campaigns, newsletters, automations |
Contact and Storage Estimation
| Metric | Value | Notes |
|---|---|---|
| Average contacts per customer | 50,000 | Ranges from 1K (small) to 5M (enterprise) |
| Total contacts stored | 25 billion | 500K multiplied by 50K contacts |
| Average contact record size | 2 KB | Email, name, attributes, custom fields |
| Total contact storage | 50 TB | 25B multiplied by 2KB |
| Event logs per month | 250 billion | Average 20 events per contact per month |
| Event log size per month | 50 TB | Each event approximately 200 bytes |
| Template library size | 500 GB | HTML templates with embedded assets |
API and Infrastructure Estimation
| Metric | Value |
|---|---|
| API requests per second (management plane) | 5,000 |
| API requests per second (sending API) | 25,000 |
| Webhook deliveries per second | 10,000 |
| SMS messages per month | 500 million |
| WhatsApp messages per month | 200 million |
| Bandwidth (inbound) | 500 Mbps average |
| Bandwidth (outbound) | 2 Gbps average |
| Redis memory for caches | 128 GB cluster |
| Application servers | 100 plus instances |
| Database shards | 50 plus shards |
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)
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
| Method | Endpoint | Description | Rate Limit |
|---|---|---|---|
| POST | /v3/email/send | Send a single transactional email | 100/sec |
| POST | /v3/email/batch | Send batch transactional emails (up to 1000) | 10/sec |
| POST | /v3/sms/send | Send a single SMS message | 50/sec |
| POST | /v3/whatsapp/send | Send a WhatsApp template message | 50/sec |
| POST | /v3/contacts | Create or update a contact (upsert by email) | 100/sec |
| POST | /v3/contacts/batch | Bulk upsert contacts (up to 10K per call) | 10/sec |
| GET | /v3/contacts/:id | Retrieve a contact by ID | 500/sec |
| DELETE | /v3/contacts/:id | Delete a contact | 500/sec |
| GET | /v3/contacts | List contacts with filtering and pagination | 100/sec |
| POST | /v3/campaigns | Create a new campaign | 20/sec |
| POST | /v3/campaigns/:id/send | Schedule or immediately send a campaign | 10/sec |
| GET | /v3/campaigns/:id/stats | Get real-time campaign statistics | 100/sec |
| POST | /v3/templates | Create an email template | 20/sec |
| GET | /v3/analytics/events | Query contact events with filters | 50/sec |
| POST | /v3/webhooks | Register a webhook endpoint | 10/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"
}
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.
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.
| State | Description | Transitions |
|---|---|---|
| Created | Campaign drafted, not yet scheduled | to Scheduled, to Cancelled |
| Scheduled | Campaign queued for future execution | to Executing, to Cancelled |
| Executing | Campaign is actively sending messages | to Paused, to Completed, to Failed |
| Paused | Admin manually paused execution | to Executing, to Cancelled |
| Completed | All messages sent and workflows finished | terminal state |
| Failed | Unrecoverable error during execution | terminal state |
| Cancelled | Admin cancelled the campaign | terminal 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;
}
}
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:
| Priority | Use Case | Target Latency | Queue Name |
|---|---|---|---|
| High (0) | Password reset, 2FA codes, security alerts | P99 less than 1 second | email.priority.high |
| Normal (1) | Order confirmations, receipts, shipping | P99 less than 5 seconds | email.priority.normal |
| Low (2) | Welcome series, onboarding drips | P99 less than 60 seconds | email.priority.low |
| Campaign (3) | Marketing campaigns, newsletters | Within scheduled window | email.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);
}
}
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
| Pool | Use Case | IP Count | Daily Volume Cap | Requirements |
|---|---|---|---|---|
| Dedicated A | Enterprise customers with own IPs | Per customer | Unlimited | IP warmup completed, domain verified |
| Dedicated B | High-volume Business tier | 2-4 per customer | 500K per day | Bounce rate below 2%, complaints below 0.05% |
| Shared Premium | Business tier shared sending | 20-50 IPs | 5M per day total | Account age above 3 months, good reputation |
| Shared Standard | Starter tier shared sending | 30-100 IPs | 10M per day total | Default pool for new accounts |
| Warmup | New IPs being warmed up | 5-10 IPs | Gradual increase over 30 days | Automated warmup schedule |
| Transactional | API-driven transactional email | 10-20 IPs | 2M per day | Isolated 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
| Day | Volume per IP | Notes |
|---|---|---|
| 1-3 | 50 emails | Send to most engaged contacts only |
| 4-7 | 100 emails | Monitor bounce rate closely |
| 8-14 | 500 emails | Gradual increase, monitor spam complaints |
| 15-21 | 2,000 emails | Include all engaged segments |
| 22-28 | 10,000 emails | Broader audience, monitor deliverability |
| 29-30 | 25,000 emails | Full volume, all segments |
| 31+ | Full volume | IP considered warmed up if metrics hold |
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
| Country | Primary Provider | Fallback | Cost per SMS (USD) |
|---|---|---|---|
| United States | Twilio | AWS SNS | 0.0079 |
| Canada | Twilio | Vonage | 0.0099 |
| United Kingdom | Twilio | Vonage | 0.0329 |
| Germany | Vonage | Twilio | 0.0649 |
| France | Vonage | Twilio | 0.0156 |
| India | Regional Provider | Twilio | 0.0150 |
| Brazil | Twilio | Regional Provider | 0.0699 |
| Australia | Twilio | Vonage | 0.0406 |
| Japan | Regional Provider | Twilio | 0.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.
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)
};
}
}
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
| Segment | Criteria | Complexity |
|---|---|---|
| High-value customers | attributes.total_purchases > 500 AND status = Active | Low |
| Engaged subscribers | last_activity_at > 30 days ago AND email_opt_in = true | Low |
| Abandoned cart users | event type = cart_abandoned in last 7 days | Medium |
| VIP customers in NYC | attributes.city = New York AND lead_score > 80 | Low |
| Never purchased | attributes.purchases = 0 AND created_at < 90 days ago | Medium |
| Opened last 3 campaigns | 3 consecutive campaign opens in last 30 days | High |
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
| Feature | HubSpot Sync | Salesforce Sync |
|---|---|---|
| Contacts | Bidirectional real-time | Bidirectional scheduled (5 min) |
| Deals | Bidirectional | Push only (Brevo to SF) |
| Companies | Pull from HubSpot | Pull from Salesforce |
| Activities | Push (email opens, clicks) | Push (email events as Tasks) |
| Lists | Sync HubSpot lists to Brevo | Sync SF reports to Brevo lists |
| Auth | OAuth 2.0 | OAuth 2.0 + Connected App |
| Rate Limits | 100 requests per 10 seconds | API 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
| Metric | Target | Alert Threshold | Action on Breach |
|---|---|---|---|
| Bounce Rate | Below 2% | Exceeds 3% | Pause account sending, require list cleanup |
| Spam Complaint Rate | Below 0.05% | Exceeds 0.1% | Suspend account, manual review |
| Open Rate | Above 20% | Below 10% | Review content quality, check spam placement |
| Unsubscribe Rate | Below 0.5% | Exceeds 1% | Review targeting and content relevance |
| Delivery Rate | Above 98% | Below 95% | Investigate IP reputation, provider issues |
| Inbox Placement Rate | Above 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
| Metric | Calculation | Update Frequency |
|---|---|---|
| Open Rate | Unique opens divided by delivered count | Near real-time (30 sec) |
| Click-Through Rate | Unique clicks divided by delivered count | Near real-time (30 sec) |
| Click-to-Open Rate | Unique clicks divided by unique opens | Near real-time |
| Bounce Rate | Bounces divided by total sent | Near real-time |
| Unsubscribe Rate | Unsubscribes divided by delivered count | Near real-time |
| Revenue per Email | Attributed revenue divided by delivered | Hourly |
| Contact Growth Rate | New minus churned contacts per period | Daily |
| Lifetime Value | Total attributed revenue per contact | Daily |
| Automation Conversion | Goal events divided by entries | Hourly |
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
| Database | Workload | Tables/Topics | Replication |
|---|---|---|---|
| PostgreSQL Primary | Transactional writes | accounts, contacts, campaigns, templates, automations | Primary + 3 read replicas |
| PostgreSQL Replicas | Read-heavy queries | All tables, eventual consistency | Async, lag below 100ms |
| Redis Cluster | Caching, sessions, queues | Contact cache, template cache, rate limits | 3 masters + 3 replicas |
| ClickHouse | Analytics queries | email_events, sms_events, campaign_metrics | 3-way replication per shard |
| Elasticsearch | Full-text search | Contact search index, campaign search | 3 nodes minimum |
| S3 / MinIO | Object storage | Email snapshots, export files, media | Multi-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);
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 Layer | What is Cached | TTL | Invalidation |
|---|---|---|---|
| Redis - Contact Cache | Contact record by ID | 5 minutes | Write-through on update |
| Redis - Template Cache | Rendered email HTML | 15 minutes | Write-through on edit |
| Redis - Segment Results | Resolved segment IDs | 10 minutes | TTL-based lazy refresh |
| Redis - Account Cache | Account config, quota | 1 hour | Write-through on change |
| Redis - Analytics Cache | Dashboard metrics | 30 seconds | Streaming update from pipeline |
| CDN Edge Cache | Landing pages, assets | 1 hour | Purge on publish |
| Application In-Memory | Connection pools, DNS | Varies | LRU 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 Location | Data Storage | Sending Region | Analytics |
|---|---|---|---|
| EU (default) | EU PostgreSQL cluster | EU IP pools | EU ClickHouse |
| US (default) | US PostgreSQL cluster | US IP pools | US ClickHouse |
| EU with US analytics | EU primary, async to US | EU IP pools | US ClickHouse |
| APAC | Singapore cluster | APAC or nearest | Singapore 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
| Component | Cost per 1,000 Emails | Notes |
|---|---|---|
| SMTP delivery (shared IP) | $0.10 - $0.30 | Volume discounts from providers |
| SMTP delivery (dedicated IP) | $0.20 - $0.50 | Includes IP rental amortized |
| Infrastructure (compute + DB) | $0.05 - $0.15 | Amortized across all customers |
| Event processing and storage | $0.03 - $0.08 | ClickHouse + S3 storage |
| Template rendering | $0.01 - $0.02 | Minimal compute cost |
| Total per 1K emails | $0.19 - $0.55 | Varies by plan and volume |
SMS Cost Breakdown
| Component | Cost per SMS | Notes |
|---|---|---|
| Twilio (US) | $0.0079 | Standard rate |
| Twilio (UK) | $0.0329 | Higher international rate |
| Vonage (EU) | $0.01 - $0.06 | Country-dependent |
| Platform markup | 10-20% | Covers infrastructure and margin |
| Total customer price | $0.01 - $0.08 | Sold in credit bundles |
Monthly Infrastructure Cost Estimate (500K Customers)
| Component | Monthly Cost (USD) | Details |
|---|---|---|
| Compute (Kubernetes, 3 regions) | $85,000 | 150+ pods, auto-scaling |
| PostgreSQL (RDS, 3 regions) | $45,000 | r6g.2xlarge primary + replicas |
| Redis (ElastiCache, 3 regions) | $15,000 | 128GB cluster per region |
| ClickHouse (analytics, 2 regions) | $25,000 | 6-node cluster per region |
| Elasticsearch (2 regions) | $12,000 | 3-node cluster per region |
| RabbitMQ (queues, 3 regions) | $8,000 | 3-node quorum cluster per region |
| Kafka (event streaming) | $18,000 | MSK cluster, 3 AZs |
| S3 storage (50TB/month) | $5,000 | Templates, exports, archives |
| CDN (CloudFront) | $3,000 | Landing pages, assets |
| Email delivery (SMTP) | $120,000 | Bulk SMTP with volume discounts |
| SMS delivery | $60,000 | 500M messages at blended rate |
| WhatsApp Business API | $25,000 | 200M messages |
| Monitoring and observability | $8,000 | Prometheus, Grafana, Sentry |
| Total monthly infrastructure | $429,000 | Excluding personnel costs |
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; }
}
}
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.
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.