Envoyer des millions d’e-mails par jour nécessite une architecture soignée. La gestion des files d’attente, le rate limiting et la conception de l’infrastructure influent sur la délivrabilité et la fiabilité. Voici comment construire pour passer à l’échelle.
Aperçu de l’architecture
Composants principaux
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ API/App │────▶│ Queue │────▶│ Workers │
└─────────────┘ └─────────────┘ └─────────────┘
│
┌─────────────┐ │
│ Provider │◀───────────┘
│ API │
└─────────────┘
Conception des files d’attente
interface EmailJob {
id: string;
priority: 'critical' | 'high' | 'normal' | 'low';
payload: {
to: string;
from: string;
subject: string;
html: string;
};
metadata: {
userId: string;
campaignId?: string;
scheduledFor?: Date;
};
attempts: number;
maxAttempts: number;
createdAt: Date;
}
// Priority queues
const queues = {
critical: 'email:critical', // Password resets, 2FA
high: 'email:high', // Transactional
normal: 'email:normal', // Notifications
low: 'email:low' // Marketing, digests
};
Rate limiting
Limites côté fournisseur
class RateLimiter {
private limits: Map<string, { count: number; resetAt: Date }> = new Map();
async canSend(provider: string): Promise<boolean> {
const limit = this.limits.get(provider);
if (!limit || limit.resetAt < new Date()) {
this.limits.set(provider, {
count: 1,
resetAt: addSeconds(new Date(), 1)
});
return true;
}
if (limit.count >= PROVIDER_RATE_LIMITS[provider]) {
return false;
}
limit.count++;
return true;
}
}
Bridage des FAI
const ispLimits = {
'gmail.com': { perHour: 500, perDay: 5000 },
'yahoo.com': { perHour: 200, perDay: 2000 },
'outlook.com': { perHour: 300, perDay: 3000 }
};
async function getThrottledBatch(emails: Email[]): Promise<Email[]> {
const byDomain = groupBy(emails, e => getDomain(e.to));
const batch: Email[] = [];
for (const [domain, domainEmails] of Object.entries(byDomain)) {
const limit = ispLimits[domain] || { perHour: 1000 };
const sent = await getSentCount(domain, 'hour');
const available = limit.perHour - sent;
batch.push(...domainEmails.slice(0, available));
}
return batch;
}
Mise à l’échelle des workers
Mise à l’échelle horizontale
// Worker configuration
const workerConfig = {
concurrency: 50, // Emails per worker
batchSize: 100, // Fetch from queue
pollInterval: 100, // ms between polls
maxRetries: 3,
retryDelay: [1000, 5000, 30000] // Exponential backoff
};
async function processQueue(queue: string) {
while (true) {
const jobs = await redis.lpop(queue, workerConfig.batchSize);
if (jobs.length === 0) {
await sleep(workerConfig.pollInterval);
continue;
}
await Promise.all(
jobs.map(job => processJob(JSON.parse(job)))
);
}
}
Déclencheurs d’auto-scaling
const scalingRules = {
scaleUp: {
queueDepth: 10000, // Jobs waiting
processingTime: 5000, // ms per job
errorRate: 0.05 // 5% errors
},
scaleDown: {
queueDepth: 100,
idleTime: 300000 // 5 minutes idle
}
};
Traitement par lots
Regroupement efficace
async function sendBatch(emails: Email[]): Promise<BatchResult> {
// Group by template for efficiency
const byTemplate = groupBy(emails, 'templateId');
const results: SendResult[] = [];
for (const [templateId, templateEmails] of Object.entries(byTemplate)) {
// Compile template once
const template = await getCompiledTemplate(templateId);
// Send in parallel with concurrency limit
const batchResults = await pMap(
templateEmails,
email => sendWithTemplate(email, template),
{ concurrency: 50 }
);
results.push(...batchResults);
}
return { sent: results.filter(r => r.success).length, results };
}
Supervision à l’échelle
Métriques clés
const metrics = {
// Throughput
emailsPerSecond: gauge('emails_per_second'),
emailsPerMinute: gauge('emails_per_minute'),
// Latency
queueLatency: histogram('queue_latency_ms'),
sendLatency: histogram('send_latency_ms'),
// Errors
errorRate: gauge('error_rate'),
bounceRate: gauge('bounce_rate'),
// Queue health
queueDepth: gauge('queue_depth'),
oldestJob: gauge('oldest_job_age_seconds')
};
Seuils d’alerte
const alerts = [
{
name: 'high_queue_depth',
condition: 'queue_depth > 100000',
severity: 'warning'
},
{
name: 'high_error_rate',
condition: 'error_rate > 0.05',
severity: 'critical'
},
{
name: 'slow_processing',
condition: 'queue_latency_p99 > 60000',
severity: 'warning'
}
];
Considérations relatives à la base de données
Stockage efficace
// Partition by date for easy cleanup
const emailLogSchema = {
tableName: 'email_logs',
partitionBy: 'RANGE (created_at)',
indexes: [
'user_id',
'campaign_id',
'status',
'created_at'
],
retention: '90 days'
};
// Archive old data
async function archiveOldEmails() {
await db.query(`
INSERT INTO email_logs_archive
SELECT * FROM email_logs
WHERE created_at < NOW() - INTERVAL '90 days'
`);
await db.query(`
DELETE FROM email_logs
WHERE created_at < NOW() - INTERVAL '90 days'
`);
}
Bonnes pratiques
- —Priorisez les files - E-mails critiques en premier
- —Respectez les rate limits - Côté provider et côté FAI
- —Surveillez tout - Profondeur de file, latence, erreurs
- —Planifiez les pannes - Retries, dead letter queues
- —Mettez à l’échelle horizontalement - Ajoutez des workers, pas des serveurs plus gros
- —Regroupez efficacement - Par modèle, par domaine
- —Archivez de manière agressive - Ne laissez pas les logs croître indéfiniment
L’e-mail à haut volume consiste à assurer une délivrabilité régulière et fiable à l’échelle. Construisez pour le volume que vous attendez, mais concevez pour une croissance 10x.