Email Afmeld System - Teknisk Dokumentation
📧 Email Afmeld System - Udtømmende Teknisk Dokumentation
Version: 2.0
Dato: Januar 2026
System: Loanly24 Email Marketing Platform
📋 System Oversigt
Email afmeld-systemet er en multi-layer arkitektur der håndterer email opt-out compliance i henhold til GDPR og dansk lovgivning.
Core Komponenter
- Afmeld Links - Embedded i alle email templates
- handleEmailUnsubscribe - Backend POST endpoint til processering
- EmailAfmeldConfirm - Frontend bekræftelsesside
- EmailOptOut - Dedikeret afmeld entity
- EmailOptOutLog - Audit trail for compliance
- UnifiedUser - Master database med consent status
- reactivateEmailConsent - Admin genaktivering
- Brevo Webhook - Email provider integration
- sendEmailFromQueue - Queue processor med opt-out check
🗄️ Database Struktur
1. EmailOptOut Entity
Formål: Dedikeret opt-out database, hurtig lookup performance
{ "name": "EmailOptOut", "properties": { "email": { "type": "string", "format": "email", "description": "Email adresse (normaliseret lowercase+trim)" }, "opted_out": { "type": "boolean", "default": true, "description": "Nuværende opt-out status" }, "opted_out_at": { "type": "string", "format": "date-time", "description": "Timestamp for afmelding" }, "opted_in_at": { "type": "string", "format": "date-time", "description": "Timestamp for evt. gentilmelding" }, "contact_id": { "type": "string", "description": "Reference til EmailContact eller Lead (optional)" }, "source": { "type": "string", "description": "Afmeldings-kilde: 'link', 'manual', 'brevo_webhook'" } }, "required": ["email", "opted_out"] }
Design Rationale:
- Normalisering: Email gemmes altid som
lowercase().trim()for konsistent lookup - Boolean flag:
opted_outtillader både opt-out og opt-in states - Timestamps: Både
opted_out_atogopted_in_atfor fuld historik - Source tracking: Sporer hvor afmeldingen kom fra
2. EmailOptOutLog Entity
Formål: Immutable audit trail for alle opt-out/opt-in handlinger (GDPR compliance)
{ "name": "EmailOptOutLog", "properties": { "email": { "type": "string", "format": "email", "description": "Email adresse" }, "action": { "type": "string", "enum": ["opt_out", "opt_in"], "description": "Type handling" }, "source": { "type": "string", "description": "Kilde: 'link', 'manual', 'forced_reactivation', 'brevo_webhook'" }, "performed_by": { "type": "string", "description": "Email på bruger der udførte handlingen (admin eller 'system')" }, "ip_address": { "type": "string", "description": "IP adresse fra request" }, "user_agent": { "type": "string", "description": "Browser user agent" }, "notes": { "type": "string", "description": "Eventuelle noter/kommentarer" }, "email_opt_out_id": { "type": "string", "description": "Reference til EmailOptOut record" } }, "required": ["email", "action", "performed_by", "ip_address"] }
Design Rationale:
- Immutable: Records slettes aldrig, kun appended
- IP + User Agent: Fuld forensic trail
- performed_by: Sporer hvem der udførte handlingen (admin email eller 'system')
- Compliance: Opfylder GDPR krav om dokumentation af consent changes
3. UnifiedUser Entity (Master Database)
Formål: Central bruger-database med consent status
{ "properties": { "email": "string (unique primary key)", "consent_email": "boolean (default: true)", "consent_sms": "boolean (default: false)", "status": "enum ['active', 'unsubscribed', 'bounced', 'complained']", "consent_timestamp": "date-time", "last_activity_at": "date-time", ... } }
Opt-Out Impact:
consent_email→falsestatus→'unsubscribed'last_activity_at→ opdateres til nu
4. EmailContact Entity
Formål: Email marketing contacts database
{ "properties": { "email": "string (unique)", "status": "enum ['active', 'suppressed', 'bounced', 'complained']", "unsubscribed_at": "date-time", "unsubscribe_reason": "string", ... } }
Opt-Out Impact:
status→'suppressed'unsubscribed_at→ timestamp
🔄 Data Flow - Afmelding
Step-by-Step Process
┌─────────────────────────────────────────────────────────┐
│ 1. User clicks afmeld link i email │
│ URL: /api/functions/handleEmailUnsubscribe? │
│ email=user@example.com │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 2. handleEmailUnsubscribe (POST) │
│ - Modtager email parameter │
│ - Validerer email format │
│ - Henter IP + user agent fra request │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 3. Database Updates (parallel transactions) │
│ │
│ A) EmailOptOut (create/update) │
│ - Normaliserer email: toLowerCase().trim() │
│ - Checker om record eksisterer │
│ - CREATE ny eller UPDATE opted_out=true │
│ - Sætter opted_out_at timestamp │
│ - source = 'link' │
│ │
│ B) EmailOptOutLog (create - immutable) │
│ - action: 'opt_out' │
│ - email, ip_address, user_agent │
│ - performed_by: 'system' │
│ - source: 'link' │
│ - email_opt_out_id: reference til OptOut │
│ │
│ C) UnifiedUser (update) │
│ - Filter by email (normalized) │
│ - consent_email = false │
│ - status = 'unsubscribed' │
│ - last_activity_at = now │
│ │
│ D) UserEvent (create - audit log) │
│ - event_type: 'email_unsubscribed' │
│ - email, event_timestamp │
│ - channel: 'email' │
│ - metadata: { source: 'link', ip, user_agent } │
│ │
│ E) EmailContact (update hvis exists) │
│ - Filter by email │
│ - status = 'suppressed' │
│ - unsubscribed_at = now │
│ - unsubscribe_reason = 'User opt-out via link' │
│ │
│ F) Lead (update hvis exists) │
│ - Filter by email │
│ - status = 'unsubscribed' (custom tracking) │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 4. Response til frontend │
│ JSON: { success: true, email: "user@example.com" } │
└─────────────────────────────────────────────────────────┘
🔧 Backend Funktioner
1. handleEmailUnsubscribe
Path: functions/handleEmailUnsubscribe.js
Method: POST
Public: Ja (ingen auth krævet)
Input:
{ "email": "user@example.com" }
Process:
- Ekstraherer IP + User Agent fra request headers
- Normaliserer email:
email.toLowerCase().trim() - Parallel database opdateringer (se Data Flow)
- Håndterer fejl gracefully (fortsætter selv ved partial failure)
Output:
{ "success": true, "email": "user@example.com" }
Error Handling:
- Try-catch blocks omkring hver entity operation
- Logs fejl til console men fejler ikke helt
- Returnerer success hvis minimum ét update lykkedes
Code Snippet:
const base44 = createClientFromRequest(req); const { email } = await req.json(); const normalizedEmail = email.toLowerCase().trim(); const now = new Date().toISOString(); // Get IP + User Agent const ip = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || 'unknown'; const userAgent = req.headers.get('user-agent') || 'unknown'; // Create/update EmailOptOut const existingOptOuts = await base44.asServiceRole.entities .EmailOptOut.filter({ email: normalizedEmail }); if (existingOptOuts.length > 0) { await base44.asServiceRole.entities.EmailOptOut .update(existingOptOuts[0].id, { opted_out: true, opted_out_at: now, source: 'link' }); } else { await base44.asServiceRole.entities.EmailOptOut.create({ email: normalizedEmail, opted_out: true, opted_out_at: now, source: 'link' }); }
2. reactivateEmailConsent
Path: functions/reactivateEmailConsent.js
Method: POST
Auth: Admin only (user.role === 'admin')
Formål: Tillader admin at genaktivere email consent for en bruger
Input:
{ "userId": "unified-user-id", "email": "user@example.com" }
Process:
- Auth Check: Verificerer admin bruger
- UnifiedUser Update:
consent_email = truestatus = 'active'last_activity_at = now
- EmailOptOut Reset:
- Filter by email
- Hvis exists:
opted_out = false, opted_in_at = now
- UserEvent Log:
event_type: 'consent_updated'metadata.action: 'reactivated_email_consent'metadata.performed_by: admin.email
Output:
{ "success": true, "message": "Email genaktiveret", "email": "user@example.com" }
Security:
- Kræver admin auth (403 Forbidden hvis ikke)
- Logger hvem der genaktiverede (accountability)
- Opdaterer kun specifikke felter (ingen bulk operations)
3. sendEmailFromQueue (Opt-Out Check)
Path: functions/sendEmailFromQueue.js
Formål: Queue processor der sender planlagte emails
KRITISK Opt-Out Check:
Før email sendes, checker funktionen 3 opt-out sources i rækkefølge:
// 1. Check UnifiedUser master database const unifiedUsers = await base44.asServiceRole.entities .UnifiedUser.filter({ email: contactEmail.toLowerCase().trim() }); if (unifiedUsers.length > 0) { const unifiedUser = unifiedUsers[0]; if (unifiedUser.status === 'unsubscribed' || unifiedUser.consent_email === false) { // CANCEL SENDING await base44.asServiceRole.entities.EmailSendQueue .update(item.id, { status: 'canceled' }); console.log(`[BLOCKED] ${contactEmail} - unsubscribed in master DB`); continue; // Skip til næste email } } // 2. Check EmailOptOut records const optOuts = await base44.asServiceRole.entities .EmailOptOut.filter({ email: contactEmail.toLowerCase().trim() }); if (optOuts.length > 0 && optOuts[0].opted_out === true) { await base44.asServiceRole.entities.EmailSendQueue .update(item.id, { status: 'canceled' }); console.log(`[BLOCKED] ${contactEmail} - opted out`); continue; } // 3. Check EmailContact status (hvis lead type) if (emailContact && (emailContact.unsubscribed_at || emailContact.status !== 'active')) { await base44.asServiceRole.entities.EmailSendQueue .update(item.id, { status: 'canceled' }); continue; } // Hvis alle checks passed → send email
Design Rationale:
- Triple redundancy: Maksimal sikkerhed mod at sende til opted-out users
- Master DB priority: UnifiedUser er source of truth
- Cancel ikke fail: Queue item markeres 'canceled', ikke 'failed'
- Logging: Detaljerede console logs for debugging
🌐 Frontend Integration
1. EmailAfmeldConfirm Page
Path: pages/EmailAfmeldConfirm.js
Route: /EmailAfmeldConfirm?email=user@example.com
State Machine:
confirm → loading → success/error
↓ ↓ ↓
UI1 UI2 UI3
States:
A) CONFIRM (Initial)
- Viser email fra URL param
- "Ja, afmeld mig" knap
- "Nej, gå tilbage" link
B) LOADING
- Spinner animation
- "Behandler afmelding..." tekst
C) SUCCESS
- Grøn check ikon
- "Du er nu afmeldt" besked
- Viser email confirmation
D) ERROR
- Rød X ikon
- Error message
- Kontakt info
Code Flow:
const handleOptOut = async () => { setStatus("loading"); try { const response = await base44.functions.invoke( 'handleEmailUnsubscribe', { email: email } ); if (response.data.success) { setStatus("success"); } else { setStatus("error"); setError(response.data.error); } } catch (err) { setStatus("error"); setError(err.message); } };
2. Email Template Links
Embedded i alle email templates:
<!-- Standard afmeld link --> <a href="https://loanly24.dk/api/functions/handleEmailUnsubscribe?email={{email}}"> Afmeld </a> <!-- Med List-Unsubscribe header (RFC 8058) --> <meta name="List-Unsubscribe" content="<https://loanly24.dk/api/functions/handleEmailUnsubscribe?email={{email}}>"> <meta name="List-Unsubscribe-Post" content="List-Unsubscribe=One-Click">
Brevo Template Integration:
I sendEmailFromQueue.js:
const unsubscribeLink = `https://loanly24.dk/api/functions/handleEmailUnsubscribe?email=${encodeURIComponent(contactEmail)}`; const personalizedHtml = htmlContent .replace(/{{UNSUBSCRIBE_LINK}}/g, unsubscribeLink); await sendViaBrevo({ ... htmlContent: personalizedHtml, headers: { 'List-Unsubscribe': `<${unsubscribeLink}>`, 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click' } });
🔗 Brevo Webhook Integration
Path: functions/handleBrevoWebhook.js
Formål: Modtager events fra Brevo email provider
Supported Events:
open- Email åbnetclick- Link klikkethard_bounce- Permanent bouncesoft_bounce- Midlertidig bounceunsubscribe- Bruger afmeldte via Brevo's native linkcomplaint- Spam klage
Unsubscribe Handling:
if (eventType === 'unsubscribe') { // Find enrollment const enrollment = await base44.asServiceRole.entities .EmailEnrollment.filter({ campaign_id: job.campaign_id, contact_id: job.contact_id }).then(e => e[0]); if (enrollment) { // Mark enrollment as unsubscribed await base44.asServiceRole.entities.EmailEnrollment .update(enrollment.id, { status: 'unsubscribed' }); } // TODO: Også opdater EmailOptOut + UnifiedUser // (skal implementeres for fuld sync) }
Design Note:
- Webhook returnerer ALTID 200 OK (ellers retry loop)
- Async processing uden at blokere response
- Idempotent: Kan håndtere duplicate events
🛡️ Admin Tools - UsersDatabaseV2
Path: pages/UsersDatabaseV2.js
Formål: Admin dashboard for bruger-database
Genaktiverings-workflow:
- Admin finder bruger i tabel
- Hvis
status === 'unsubscribed':- Viser "Email" knap med RotateCw ikon
- Klik trigger mutation:
const reactivateMutation = useMutation({ mutationFn: async ({ userId, email }) => { const response = await base44.functions.invoke( 'reactivateEmailConsent', { userId, email } ); return response.data; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["unifiedUsers"] }); toast.success("Email genaktiveret"); } }); const handleReactivate = (userId, email) => { if (confirm(`Genaktiver emails til ${email}?`)) { reactivateMutation.mutate({ userId, email }); } };
UI Display:
{u.status === "unsubscribed" && ( <button onClick={() => handleReactivate(u.id, u.email)} disabled={reactivateMutation.isPending} className="px-2 py-1 bg-green-600 text-white rounded" > <RotateCw className="w-3 h-3" /> Email </button> )}
🔍 Testing & Debugging
Test Scenarie 1: Normal Afmelding
Setup:
- Send test email til test@example.com
- Klik afmeld link i email
Forventet:
- Redirect til EmailAfmeldConfirm
- Confirm screen vises
- Efter confirm → Success screen
- Database checks:
EmailOptOut: opted_out=true, opted_out_at=<timestamp> EmailOptOutLog: action='opt_out', source='link' UnifiedUser: consent_email=false, status='unsubscribed'
Logs:
[handleEmailUnsubscribe] Processing opt-out for test@example.com
[handleEmailUnsubscribe] Created EmailOptOut record
[handleEmailUnsubscribe] Updated UnifiedUser
[handleEmailUnsubscribe] Success
Test Scenarie 2: Genaktivering
Setup:
- Afmeld bruger (Scenarie 1)
- Log ind som admin
- Gå til UsersDatabaseV2
- Find bruger → klik "Email" knap
Forventet:
- Confirm dialog vises
- Success toast efter bekræftelse
- Database checks:
EmailOptOut: opted_out=false, opted_in_at=<timestamp> UnifiedUser: consent_email=true, status='active' UserEvent: event_type='consent_updated', metadata.action='reactivated_email_consent'
Test Scenarie 3: Queue Blokkering
Setup:
- Afmeld bruger
- Planlæg email til brugeren (via campaign)
- Run sendEmailFromQueue
Forventet:
- Email sendes IKKE
- Queue item status → 'canceled'
- Console log:
[UNSUBSCRIBE CHECK] Email test@example.com is unsubscribed - CANCELED
Database check:
EmailSendQueue: status='canceled' EmailEvent: event_type='unsubscribed' (hvis logged)
📊 Monitoring & Analytics
Key Metrics
Dashboard Query:
// Total opted out users const optedOutCount = await base44.entities.EmailOptOut .filter({ opted_out: true }).length; // Opt-outs last 7 days const recentOptOuts = await base44.entities.EmailOptOutLog .filter({ action: 'opt_out', created_date: { $gte: sevenDaysAgo } }).length; // Opt-out rate per campaign const campaign = await base44.entities.CampaignBroadcast.get(campaignId); const optOutRate = (campaign.unsubscribe_count / campaign.sent_count) * 100;
Health Checks
Weekly Admin Review:
- Check
EmailOptOutLogfor anomalies - Verify
EmailOptOut.opted_outmatchesUnifiedUser.consent_email - Monitor opt-out rate (alarm hvis >2% per campaign)
Audit Log Query:
// Find alle opt-out actions sidste måned const logs = await base44.entities.EmailOptOutLog.filter({ created_date: { $gte: lastMonth }, action: 'opt_out' }, '-created_date', 1000);
⚠️ Common Issues & Solutions
Issue 1: Email sendt til opted-out bruger
Symptom: Bruger rapporterer email modtaget efter afmelding
Debug:
-
Check
EmailOptOutrecord:const optOut = await base44.entities.EmailOptOut .filter({ email: 'user@example.com' }); console.log(optOut); -
Check
EmailSendQueuestatus:const queueItem = await base44.entities.EmailSendQueue .filter({ contact_id: contactId, status: 'sent' });
Root Causes:
- Email normalisering fejl (case sensitivity)
- Race condition: Opt-out under queue processing
- Queue item created før opt-out
Solution:
- Tilføj double-check i queue processor LIGE før send
- Normalisér email konsistent alle steder
Issue 2: Opt-out link virker ikke
Symptom: Link fører til 404 eller error
Debug:
-
Check URL format:
Korrekt: /api/functions/handleEmailUnsubscribe?email=test@example.com Forkert: /EmailAfmeldConfirm?email=... (gammel format) -
Test direkte POST:
curl -X POST https://loanly24.dk/api/functions/handleEmailUnsubscribe \ -H "Content-Type: application/json" \ -d '{"email":"test@example.com"}'
Solution:
- Opdater alle email templates til korrekt URL
- Tilføj redirect fra gammel URL til ny
Issue 3: Genaktivering fejler
Symptom: Admin får error ved genaktivering
Debug:
-
Check admin auth:
const user = await base44.auth.me(); console.log(user.role); // Skal være 'admin' -
Check userId eksisterer:
const user = await base44.entities.UnifiedUser.get(userId);
Solution:
- Verify user.role === 'admin' i frontend før knap vises
- Tilføj better error messages i backend
🔒 GDPR Compliance
Data Subject Rights
Ret til sletning (Right to Erasure):
Når bruger anmoder om sletning:
- IKKE slet
EmailOptOutrecord (juridisk grund: "legitimate interest") - BEHOLD opt-out status permanent
- Slet persondata fra andre entities:
- UnifiedUser: Slet navn, telefon, etc.
- EmailContact: Anonymiser eller slet
- Lead: Anonymiser eller slet
Implementation:
// GDPR deletion (anonymize men keep opt-out) await base44.entities.UnifiedUser.update(userId, { first_name: '[DELETED]', last_name: '[DELETED]', phone: null, // BEHOLD: email, consent_email=false, status='unsubscribed' }); // EmailOptOut forbliver uændret (prevent re-subscription)
Rationale:
- Opt-out er juridisk forpligtelse (ikke "persondata" efter GDPR)
- Forhindrer utilsigtet re-subscription af slettet bruger
Audit Trail Requirements
EmailOptOutLog opfylder GDPR Art. 30 (Records of processing):
- Hvem udførte handlingen (performed_by)
- Hvornår (created_date timestamp)
- Hvor fra (ip_address, user_agent)
- Hvilken handling (action: opt_out/opt_in)
Retention:
- EmailOptOut: Permanent (eller indtil bruger slettes + 3 år)
- EmailOptOutLog: Minimum 3 år efter sidste aktivitet
🚀 Future Enhancements
1. Preferences Center
Formål: Granular email preferences (ikke bare on/off)
Design:
// Ny entity: EmailPreferences { email: "user@example.com", newsletters: true, // Nyhedsbreve promotions: false, // Kampagner transactional: true, // Transaktions-emails (altid true) frequency: "weekly" // daily, weekly, monthly }
Frontend:
- Ny side:
/email-preferences?email=... - Checkboxes for hver kategori
- "Gem præferencer" knap
2. Re-engagement Campaigns
Formål: Win-back campaigns for opted-out users (med eksplicit opt-in)
Workflow:
- Filter opted-out users >6 months ago
- Send SMS (IKKE email) med re-opt-in link
- Link → Preferences center
- Bruger vælger præferencer → opted back in
3. Brevo Sync Enhancement
Problem: Brevo webhook håndterer ikke fuldt opt-out flow
Solution:
Tilføj til handleBrevoWebhook.js:
if (eventType === 'unsubscribe') { // Call internal opt-out function await fetch('https://loanly24.dk/api/functions/handleEmailUnsubscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: event.email, source: 'brevo_webhook' }) }); }
📝 Checklist - Deployment
Før deploy af ændringer til opt-out systemet:
- [ ] Test alle 3 test scenarier (se Testing sektion)
- [ ] Verify database writes til alle 6 entities
- [ ] Check EmailOptOut normalisering (lowercase+trim)
- [ ] Verify sendEmailFromQueue blokkerer opted-out emails
- [ ] Test admin genaktivering i UsersDatabaseV2
- [ ] Review EmailOptOutLog for compliance
- [ ] Backup production EmailOptOut tabel
- [ ] Update email templates med korrekte links
- [ ] Test Brevo webhook unsubscribe event
- [ ] Document any schema changes i denne guide
🔗 Quick Reference
Entities:
EmailOptOut- Opt-out statusEmailOptOutLog- Audit trailUnifiedUser- Master DBEmailContact- Marketing contactsUserEvent- Event log
Functions:
handleEmailUnsubscribe- POST endpoint for opt-outreactivateEmailConsent- Admin genaktiveringsendEmailFromQueue- Queue processor med opt-out checkhandleBrevoWebhook- Brevo events
Pages:
EmailAfmeldConfirm- User-facing opt-out pageUsersDatabaseV2- Admin dashboard
URLs:
- Opt-out:
/api/functions/handleEmailUnsubscribe?email=... - Confirm:
/EmailAfmeldConfirm?email=... - Webhook:
/api/functions/handleBrevoWebhook
END OF DOCUMENTATION
