📧 Broadcast Email Deduplication Guide
Kopier som prompt til andre apps
# BROADCAST EMAIL DEDUPLICATION GUIDE
# Sådan sikrer du at der KUN sendes 1 email pr. unik modtager
# Sidst opdateret: 2026-04-02
=====================================================================
PROBLEMET
=====================================================================
Broadcast-kampagner afsendes via et cron-job hvert X minut.
Uden beskyttelse kan det samme kald (eller to overlappende kald)
sende den samme email to gange.
Risici:
- [HØJ] Duplikerede modtagere i BroadcastRecipient tabellen
- [MEDIUM] Race condition: to overlappende cron-kald behandler samme modtager
- [HØJ] Retry uden statustjek sender emails der allerede er sendt
=====================================================================
LØSNINGEN — 3 BESKYTTELSESLAG
=====================================================================
LAG 1: In-memory deduplication
→ Dedupliker alle recipients i memory FØR afsendelse (email_normalized)
LAG 2: Claim-before-send (UDEN re-fetch)
→ Sæt sent: true FØR Brevo-kald. Revert til sent: false ved fejl.
→ VIGTIGT: Lav IKKE et ekstra .filter({ id }) re-fetch per recipient —
det koster 1 ekstra DB API-kald per email og rammer Base44's rate limit (429).
In-memory deduplication (LAG 1) er tilstrækkelig beskyttelse mod dobbelt-send.
LAG 3: Rate limit + cron interval
→ 50 emails per kald, 400ms delay, 2 minutters cron interval
=====================================================================
ENTITETS-FLOW
=====================================================================
BroadcastCampaign (status: draft → sending → sent)
↓
BroadcastRecipient (will_receive: true, sent: false → sent: true)
↓
Brevo API (afsender selve emailen)
BroadcastCampaign felter:
status: 'draft' | 'sending' | 'sent'
sent_count: number
failed_count: number
will_receive_count: number
sending_started_at: datetime
sent_at: datetime
BroadcastRecipient felter:
campaign_id: string
email: string
email_normalized: string ← KRITISK (lowercase/trimmed)
will_receive: boolean
sent: boolean
failed: boolean
sent_at: datetime
error_message: string
=====================================================================
LAG 1: IN-MEMORY DEDUPLICATION (kode)
=====================================================================
// Hent ALLE unsent recipients (pagineret — VIGTIGT!)
const allRecipients = [];
let skip = 0;
while (true) {
const batch = await base44.asServiceRole.entities.BroadcastRecipient.filter(
{ campaign_id: campaign.id, will_receive: true, sent: false },
'-created_date', 500, skip
);
if (!batch || batch.length === 0) break;
allRecipients.push(...batch);
if (batch.length < 500) break;
skip += 500;
}
// Dedupliker baseret på email_normalized
const seenEmails = new Set();
const recipients = [];
const duplicates = [];
for (const r of allRecipients) {
const key = (r.email_normalized || r.email || '').toLowerCase().trim();
if (!key || seenEmails.has(key)) { duplicates.push(r); continue; }
seenEmails.add(key);
recipients.push(r);
}
// Marker duplikater som sent
if (duplicates.length > 0) {
await Promise.all(duplicates.map(r =>
base44.asServiceRole.entities.BroadcastRecipient.update(r.id, {
sent: true,
sent_at: new Date().toISOString(),
error_message: 'Skipped: duplicate email address'
})
));
}
FALDGRUBER:
! Brug email_normalized (lowercase/trimmed) som nøgle — IKKE rå email
! Husk paginering — stop for tidligt og duplikater overlever på tværs af sider
=====================================================================
LAG 2: CLAIM-BEFORE-SEND (kode) — UDEN re-fetch
=====================================================================
⚠️ KRITISK: Lav IKKE et .filter({ id: recipient.id }) re-fetch per recipient!
Det koster 1 ekstra Base44 API-kald per email → 50 ekstra kald per run →
rammer hurtigt Base44's rate limit (429) og stopper hele kampagnen.
for (const recipient of recipientsToSend) {
// CLAIM — sæt sent: true FØR Brevo-kald (ingen re-fetch)
await base44.asServiceRole.entities.BroadcastRecipient.update(recipient.id, {
sent: true,
sent_at: new Date().toISOString(),
error_message: 'Sending...'
});
try {
await sendViaBrevo(recipient, campaign);
// SUCCESS — bekræft og ryd placeholder
await base44.asServiceRole.entities.BroadcastRecipient.update(recipient.id, {
sent: true, failed: false, error_message: null
});
} catch (error) {
// FEJL — REVERT claim så næste kald kan retry
await base44.asServiceRole.entities.BroadcastRecipient.update(recipient.id, {
sent: false, failed: true, error_message: error.message
});
}
}
FALDGRUBER:
! Glemmer du at revertere sent: false ved fejl, hænger recipients i "Sending..." limbo
! Lav ALDRIG .filter({ id }) re-fetch per recipient — forårsager Base44 rate limit (429)
=====================================================================
LAG 3: RATE LIMITING OG CRON INTERVAL
=====================================================================
Konfiguration:
maxEmailsPerRun = 50
batchSize = 10
delayMsBetween = 400ms
cronInterval = 2 minutter
Beregning:
50 emails × ~400ms = ~20 sekunder per kald
Kald tager 20 sek, interval er 120 sek → 100 sek buffer → ingen overlap
~150 emails/minut (Brevo grænse: ~300/min)
~1.500 emails/time, ~36.000 emails/dag
Rate limit håndtering:
let rateLimitBackoff = 1;
if (brevoResponse.status === 429) {
rateLimitBackoff = Math.min(rateLimitBackoff * 2, 10);
await new Promise(r => setTimeout(r, 2000 * rateLimitBackoff));
throw new Error('Rate limit');
}
rateLimitBackoff = 1; // nulstil efter success
await new Promise(r => setTimeout(r, 400 * rateLimitBackoff)); // delay mellem batches
FALDGRUBER:
! Sæt ALDRIG cron til under 1 minuts interval
! Base44 functions timeout efter ~30 sekunder
=====================================================================
FULD KODE TEMPLATE — resumeBroadcastCampaigns
=====================================================================
import { createClientFromRequest } from 'npm:@base44/sdk@0.8.23';
Deno.serve(async (req) => {
try {
const base44 = createClientFromRequest(req);
const BREVO_API_KEY = Deno.env.get('BREVO_API_KEY');
const campaigns = await base44.asServiceRole.entities.BroadcastCampaign.filter({ status: 'sending' });
if (campaigns.length === 0) return Response.json({ message: 'No campaigns to process' });
for (const campaign of campaigns) {
// 1. Hent alle unsent recipients (pagineret)
const allRecipients = [];
let skip = 0;
while (true) {
const batch = await base44.asServiceRole.entities.BroadcastRecipient.filter(
{ campaign_id: campaign.id, will_receive: true, sent: false },
'-created_date', 500, skip
);
if (!batch || batch.length === 0) break;
allRecipients.push(...batch);
if (batch.length < 500) break;
skip += 500;
}
// 2. In-memory deduplication (LAG 1)
const seenEmails = new Set();
const recipients = [], duplicates = [];
for (const r of allRecipients) {
const key = (r.email_normalized || r.email || '').toLowerCase().trim();
if (!key || seenEmails.has(key)) { duplicates.push(r); continue; }
seenEmails.add(key);
recipients.push(r);
}
if (duplicates.length > 0) {
await Promise.all(duplicates.map(r =>
base44.asServiceRole.entities.BroadcastRecipient.update(r.id, {
sent: true, sent_at: new Date().toISOString(), error_message: 'Skipped: duplicate'
})
));
}
// 3. Begræns til max 50 per kald (LAG 3)
const toSend = recipients.slice(0, 50);
let sentCount = 0, failedCount = 0, rateLimitBackoff = 1;
for (let i = 0; i < toSend.length; i += 10) {
const batch = toSend.slice(i, i + 10);
for (const recipient of batch) {
// 4. Claim-before-send (LAG 2) — INGEN re-fetch (undgår Base44 rate limit)
await base44.asServiceRole.entities.BroadcastRecipient.update(recipient.id, {
sent: true, sent_at: new Date().toISOString(), error_message: 'Sending...'
});
try {
const htmlContent = campaign.html_content
.replace(/{{firstName}}/g, recipient.first_name || '')
.replace(/{{email}}/g, recipient.email || '');
const brevoRes = await fetch('https://api.brevo.com/v3/smtp/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'api-key': BREVO_API_KEY },
body: JSON.stringify({
sender: { name: campaign.from_name, email: campaign.from_email },
to: [{ email: recipient.email }],
subject: campaign.subject,
htmlContent
})
});
if (brevoRes.status === 429) {
rateLimitBackoff = Math.min(rateLimitBackoff * 2, 10);
await new Promise(r => setTimeout(r, 2000 * rateLimitBackoff));
throw new Error('Rate limit');
}
if (!brevoRes.ok) throw new Error('Brevo: ' + brevoRes.status);
await base44.asServiceRole.entities.BroadcastRecipient.update(recipient.id, {
sent: true, failed: false, error_message: null
});
sentCount++;
rateLimitBackoff = 1;
} catch (error) {
await base44.asServiceRole.entities.BroadcastRecipient.update(recipient.id, {
sent: false, failed: true, error_message: error.message
});
failedCount++;
}
}
if (i + 10 < toSend.length) {
await new Promise(r => setTimeout(r, 400 * rateLimitBackoff));
}
}
// 5. Tjek om kampagnen er færdig
const remaining = await base44.asServiceRole.entities.BroadcastRecipient.filter(
{ campaign_id: campaign.id, will_receive: true, sent: false }, '-created_date', 1
);
await base44.asServiceRole.entities.BroadcastCampaign.update(campaign.id, {
status: remaining.length === 0 ? 'sent' : 'sending',
sent_count: (campaign.sent_count || 0) + sentCount,
failed_count: (campaign.failed_count || 0) + failedCount,
...(remaining.length === 0 ? { sent_at: new Date().toISOString() } : {})
});
}
return Response.json({ success: true });
} catch (error) {
return Response.json({ error: error.message }, { status: 500 });
}
});
=====================================================================
TJEKLISTE
=====================================================================
DATABASE / ENTITETER:
[ ] BroadcastRecipient har email_normalized felt (lowercase/trimmed)
[ ] BroadcastRecipient har sent (boolean), failed (boolean), error_message (string)
[ ] BroadcastCampaign har status enum: draft, sending, sent
[ ] BroadcastCampaign har sent_count, failed_count, will_receive_count felter
BACKEND FUNKTION:
[ ] Pagineret hentning af recipients (while-loop, 500 per side)
[ ] In-memory deduplication på email_normalized FØR afsendelse
[ ] Duplikater markeres sent: true med error_message: "Skipped: duplicate"
[ ] Max 50 recipients per kald
[ ] Claim-before-send: sæt sent: true med "Sending..." FØR Brevo-kald
[ ] INGEN re-fetch (.filter({ id })) per recipient — forårsager Base44 rate limit!
[ ] SUCCESS path: opdater med failed: false, error_message: null
[ ] FEJL path: revert til sent: false, failed: true, error_message
[ ] 429 rate limit håndtering med eksponentiel backoff
[ ] 400ms delay mellem batches af 10
[ ] Kampagne markeres status: sent når alle recipients er behandlet
CRON JOB:
[ ] Interval: 2 minutter (IKKE under 1 minut)
[ ] Cron kalder KUN resumeBroadcastCampaigns
[ ] Ingen andre aktive cron jobs med legacy email-sending
DEPREKEREDE FUNKTIONER (skal deaktiveres):
[ ] processScheduledEmails — returner 200 no-op
[ ] sendEmailBatch — returner 410 Gone
[ ] sendEmailBatchV3 — returner 410 Gone
=====================================================================
FEJLSCENARIER OG LØSNINGER
=====================================================================
SCENARIO 1: Emails sendt dobbelt
Symptom: Modtagere klager over at have modtaget samme email 2 gange
Årsag: Manglende claim-before-send ELLER cron interval for kort
Løsning: Implementer claim-before-send (uden re-fetch) + sæt cron til 2 minutter
SCENARIO 2: Kampagne hænger på status "sending" / sender ikke
Symptom: Kampagne viser "sending" men ingen emails sendes, sent_count stiger ikke
Årsag A: Recipients er i limbo — sent: true med "Sending..." placeholder
Årsag B: Base44 rate limit (429) — for mange DB-kald per run (fx re-fetch per recipient)
Løsning A: Kør recovery script:
Løsning B: Fjern re-fetch (.filter({ id })) per recipient fra koden
const stuck = await base44.asServiceRole.entities.BroadcastRecipient.filter({
campaign_id: 'DIN_CAMPAIGN_ID',
error_message: 'Sending...'
});
await Promise.all(stuck.map(r =>
base44.asServiceRole.entities.BroadcastRecipient.update(r.id, {
sent: false, failed: false, error_message: null
})
));
SCENARIO 3: Mange fejlede recipients
Symptom: failed_count stiger hurtigt
Årsag: Brevo rate limit (429) eller ugyldige emails
Løsning: Tjek error_message på recipients. Sænk maxEmailsPerRun til 30.
SCENARIO 4: Duplikater i database
Symptom: Samme email har to BroadcastRecipient records
Årsag: generateBroadcastRecipients er kørt to gange
Løsning: In-memory deduplication (LAG 1) håndterer dette automatisk.
De markeres med error_message: "Skipped: duplicate"
