Email suppression list bukan sekadar fitur email marketing. Di sistem transaksional, suppression list adalah garda terakhir sebelum hard bounce merusak reputasi IP seluruh server. Artikel ini menjelaskan perbedaan implementasi suppression untuk transactional email, cara mendeteksi bounce secara otomatis via webhook, pola deduplication untuk concurrent send, dan strategi sync suppression antar SMTP provider.
Email marketing punya kemewahaan yang sayang sekali untuk di skip: sebelum kirim, cek daftar, remove yang sudah unsubscribe, baru blast. Transaksional tidak punya kemewahan itu. Ketika user melakukan password reset atau memicu OTP, email kirim secara real-time , kadang 50 request dalam satu detik dari 50 user berbeda. Dan di tengah traffic tinggi itu, ada satu alamat email yang sudah tidak valid.
Kalau Anda terus kirim ke alamat itu tanpa dicek, efeknya bukan hanya email tidak sampai. Hard bounce akan merusak reputasi IP Anda di mata ESP. Reputasi turun drastis, email lain ikut terimbas masuk spam. Dalam hitungan jam, seluruh sistem email perusahaan bisa terdampak. Cara kerja bounce email dan bounce rate email tinggi saling terkait dengan reputasi ini.
Suppression list adalah jawabannya. Tapi implementasi suppression untuk transactional email berbeda fundamental dari marketing blast.
Daftar Isi
Apa Bedanya Suppression List Transaksional dan Marketing?
Email marketing suppression list bekerja sebelum kirim. Anda punya list 100.000 subscriber, cek terhadap suppression list, kirim hanya ke yang tidak di-suppress. Proses ini offline dan bisa dijadwal.
Transaksional bekerja berbeda. Email kirim bukan dari list tapi dari event. Setiap event bisa trigger satu email. Kalau ada 10.000 transaksi hari ini, berarti 10.000 kali proses kirim , masing-masing harus di-check terhadap suppression list secara real-time sebelum SMTP connection terbuka.
Perbedaan penting lainnya:
Transaksional butuh deduplication yang kuat. Dalam sistem yang menggunakan message queue, satu event bisa diproses oleh beberapa worker secara bersamaan. User memicu password reset, tiga worker job queue menerima task yang sama dalam waktu 100 milidetik. Tanpa deduplication yang benar, Anda akan kirim tiga email password reset ke mailbox yang sama. Untuk memahami perbedaan queue processing di Laravel, baca cara kerja mail queue vs mail send di Laravel.
Marketing suppression biasanya bersifat permanen: unsubscribe berarti tidak pernah dikirimi email lagi. Transaksional lebih nuanced: soft bounce tidak selalu berarti permanent suppression. Alamat yang temporary unavailable harus di-suppress untuk 15-30 hari, lalu di-coba lagi. permanent failure masuk suppression list indefinitely.
Transaksional juga butuh feedback loop dari ESP. Setiap bounce harus di-parse, klasifikasi sebagai hard atau soft, baru masuk suppression list dengan TTL yang sesuai. Untuk memahami perbedaan bounce secara detail, baca artikel bounce email kami.
Langkah 1: Buat Tabel Suppression dengan Schema yang Tepat
Struktur data suppression list menentukan performa seluruh sistem. Berikut schema yang sudah teruji untuk volume tinggi:
CREATE TABLE email_suppression (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
reason ENUM('hard_bounce', 'soft_bounce', 'spam_complaint', 'unsubscribe', 'manual_block', 'validation_failed') NOT NULL,
suppressed_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP NULL,
metadata JSONB,
created_by VARCHAR(50) DEFAULT 'system',
INDEX idx_email (email),
INDEX idx_reason_expires (reason, expires_at)
);
Kolom reason penting karena tiap jenis suppression punya TTL berbeda. Hard bounce bertahan permanen atau minimal 90 hari. Soft bounce bertahan 15-30 hari. Spam complaint biasanya 90 hari. Unsubscribe dari link di transactional email bertahan sampai user manually re-subscribe.
Kolom metadata simpan detail bounce dari ESP , SMTP code, enhanced code, diagnostic text. Ini berguna untuk debugging dan untuk update TTL berdasarkan severity bounce.
Kolom expires_at null berarti permanen. Ketika Anda implementasi retry logic, query yang perlu di-retry adalah yang expires_at IS NOT NULL AND expires_at < NOW() , email yang sudah expired dari soft bounce period-nya.
Langkah 2: Tangkap Bounce via Webhook dan Klasifikasikan
Suppression list tidak berguna kalau hanya diisi manual. Anda perlu menangkap bounce event dari SMTP provider via webhook dan klasifikasikan secara otomatis.
Setiap ESP punya format webhook berbeda. Berikut pola umum untuk parse bounce:
async function handleBounceWebhook(payload, provider) {
const { email, bounceType, diagnosticCode, timestamp } = parseProviderPayload(payload, provider);
const reason = classifyBounce(bounceType, diagnosticCode);
const expiresAt = calculateExpiration(reason);
await db.query(
`INSERT INTO email_suppression
(email, reason, metadata, expires_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (email) DO UPDATE SET
reason = EXCLUDED.reason,
suppressed_at = NOW(),
expires_at = EXCLUDED.expires_at,
metadata = EXCLUDED.metadata`,
[email.toLowerCase(), reason, JSON.stringify({ diagnosticCode, provider, timestamp }), expiresAt]
); await logSuppressionEvent(email, reason, ‘webhook’); } function classifyBounce(bounceType, diagnosticCode) { const hardBouncePatterns = [‘550’, ‘551’, ‘553’, ‘554’, ‘User unknown’, ‘does not exist’, ‘invalid mailbox’]; const softBouncePatterns = [‘450’, ‘421’, ‘try again later’, ‘mailbox full’, ‘temporary failure’]; const code = diagnosticCode?.toUpperCase() || ”; if (hardBouncePatterns.some(p => code.includes(p))) { return ‘hard_bounce’; } if (softBouncePatterns.some(p => code.includes(p))) { return ‘soft_bounce’; } return bounceType === ‘permanent’ ? ‘hard_bounce’ : ‘soft_bounce’; } function calculateExpiration(reason) { const expirations = { hard_bounce: null, // permanent soft_bounce: daysFromNow(30), spam_complaint: daysFromNow(90), unsubscribe: null, // permanent until re-subscribe validation_failed: null, // permanent }; return expirations[reason] || daysFromNow(30); }
Kode di atas handle deduplication via ON CONFLICT , kalau email sudah ada di suppression list, update reason dan reset TTL berdasarkan reason terbaru. Ini penting karena soft bounce yang terjadi berulang bisa upgrade ke hard bounce.
Langkah 3: Deduplication untuk Concurrent Send
Masalah yang sering muncul tapi jarang dibahas: deduplication saat pakai message queue. User klik “kirim ulang OTP” 3 kali dalam 5 detik. Queue job processor menerima 3 task yang sama. Tanpa deduplication, 3 OTP masuk ke mailbox yang sama.
Berikut pola yang aman:
async function sendTransactionalEmail(email, type, payload) {
const normalizedEmail = email.toLowerCase().trim();
// Check if suppressed
const suppressed = await checkSuppression(normalizedEmail);
if (suppressed) {
await logSkippedSend(email, type, suppressed.reason);
return { status: 'suppressed', reason: suppressed.reason };
}
// Deduplication: use Redis SETNX with TTL
const dedupKey = `send:${normalizedEmail}:${type}`;
const isDuplicate = await redis.set(dedupKey, '1', 'EX', 300, 'NX');
if (!isDuplicate) {
await logSkippedSend(email, type, 'duplicate');
return { status: 'duplicate', reason: 'recent duplicate suppressed' };
}
// Actually send
try {
await smtpClient.send({
to: normalizedEmail,
subject: renderSubject(type, payload),
body: renderBody(type, payload)
});
await logSuccessfulSend(email, type);
return { status: 'sent' };
} catch (error) {
// If send fails with SMTP error, don't add to suppression here
// Wait for webhook bounce feedback from ESP
throw error;
}
}
async function checkSuppression(email) {
const result = await db.query(
`SELECT reason, expires_at FROM email_suppression
WHERE email = $1
AND (expires_at IS NULL OR expires_at > NOW())`,
[email]
); return result.rows[0] || null; }
Redis SETNX dengan TTL 300 detik (5 menit) memastikan 3 request dalam 5 detik hanya 1 yang benar-benar dikirim. TTL ini harus lebih panjang dari waktu process satu job di queue Anda.
Ingat: deduplication di aplikasi, BUKAN di database. Kalau pakai SELECT-then-INSERT race condition di database, masih bisa terjadi duplicate send. Redis SETNX atomic guarantee tidak ada race condition.
Langkah 4: Retry Logic yang Menghormati Suppression Period
Retry yang bodoh akan terus coba kirim ke alamat yang sudah di-suppress dan menambah bounce count. Retry yang cerdas tahu kapan harus berhenti.
Berikut pola retry yang benar:
async function processEmailQueue(job) {
const { email, type, payload, attempt = 1 } = job.data;
// Check suppression first
const suppressed = await checkSuppression(email);
if (suppressed && suppressed.reason === 'hard_bounce') {
await job.moveToFailed({ reason: 'hard_bounce_suppression' });
return;
}
// Check if soft bounce and within suppression period
if (suppressed && suppressed.reason === 'soft_bounce') {
const waitUntil = new Date(suppressed.suppressed_at);
waitUntil.setDate(waitUntil.getDate() + 30);
if (new Date() < waitUntil) {
// Re-queue with delay until suppression expires
await job.moveToScheduled(waitUntil, {
email, type, payload, attempt
});
return;
}
// Suppression expired, proceed with send
}
try {
await smtpClient.send({ to: email, ... });
await job.moveToCompleted();
} catch (error) {
if (error.code === 'SOFT_BOUNCE' && attempt < 3) {
await job.moveToScheduled(delaySeconds(attempt * 60), {
email, type, payload, attempt: attempt + 1
});
} else {
await job.moveToFailed({ error: error.message });
}
}
}
Pola ini memastikan soft bounce tidak di-retry berkali-kali dalam interval pendek. Yang terjadi pada banyak sistem: job retry dengan exponential backoff tapi tidak cek suppression. Email di-retry 5 kali dalam 30 menit, semua 5 menghasilkan soft bounce, reputation Anda turun drastis.
Langkah 5: Sync Suppression List Antar SMTP Provider
Kalau Anda pakai SMTP failover , Kirim email via provider A, switch ke provider B kalau A down , suppression list Anda tidak otomatis sync dengan provider B. Email yang sudah di-suppress di KirimEmail bisa jadi belum di-suppress di Amazon SES atau SendGrid.
Berikut arsitektur untuk handle ini:
class SuppressionManager {
constructor() {
this.localDb = db;
this.providers = {
kirimemail: new KirimEmailClient(),
amazonSes: new AwsSesClient(),
};
}
async syncToProvider(providerName) {
const provider = this.providers[providerName];
// Get all permanent suppression entries
const permanent = await this.localDb.query(
`SELECT email, reason, suppressed_at FROM email_suppression
WHERE expires_at IS NULL`
);
// Sync in batches
for (const entry of permanent.rows) {
await provider.addToSuppressionList(entry.email, {
reason: entry.reason,
suppressedAt: entry.suppressed_at
});
}
// Also pull provider's suppression list
const providerSuppressions = await provider.getSuppressionList();
for (const entry of providerSuppressions) {
if (entry.bounceType === 'Permanent') {
await this.localDb.query(
`INSERT INTO email_suppression (email, reason, metadata)
VALUES ($1, 'hard_bounce', $2)
ON CONFLICT (email) DO UPDATE SET
reason = 'hard_bounce'`,
[entry.email, JSON.stringify({ provider: providerName })]
); } } await this.logSyncEvent(providerName, permanent.rows.length); } }
Pola ini bidirectional: push suppression local ke provider, pull suppression provider ke local. Sync ini sebaiknya dijadwal setiap jam, tidak real-time karena latency provider API biasanya accept kalau ada beberapa jam lag.
Langkah 6: Logging dan Alerting
Suppression list tanpa monitoring adalah bom waktu. Anda perlu tahu ketika suppression rate naik secara tiba-tiba, karena itu indikasi ada masalah.
async function logSuppressionEvent(email, reason, source) {
await db.query(
`INSERT INTO suppression_events
(email, reason, source, created_at)
VALUES ($1, $2, $3, NOW())`,
[email, reason, source]
); // Check suppression rate const recentRate = await db.query( `SELECT COUNT(*) as total, SUM(CASE WHEN reason = ‘hard_bounce’ THEN 1 ELSE 0 END) as hard_bounces FROM suppression_events WHERE created_at > NOW() – INTERVAL ‘1 hour’` ); const { total, hard_bounces } = recentRate.rows[0]; const hardBounceRate = total > 0 ? hard_bounces / total : 0; if (hardBounceRate > 0.05) { // 5% hard bounce rate threshold await sendAlert({ type: ‘high_suppression_rate’, hardBounceRate, totalEvents: total, window: ‘1 hour’ }); } } async function sendAlert(data) { // Send to Slack/PagerDuty/etc await fetch(process.env.ALERT_WEBHOOK_URL, { method: ‘POST’, body: JSON.stringify({ text: `Email suppression alert: ${data.hardBounceRate}% hard bounce rate in last ${data.window}. Total events: ${data.totalEvents}` }) }); }
Threshold 5% hard bounce dalam 1 jam adalah warning sign. Lebih dari itu berarti ada masalah fundamental , bisa jadi SMTP credentials compromised dan orang lain pakai infrastructure Anda untuk spam, atau ada bug di validasi email yang allow invalid addresses masuk sistem.
FAQ
Apa bedanya suppression list dan blocklist?
Blocklist biasanya dikelola oleh third party , MXToolbox, Spamhaus, dan sebagainya. Blocklist bekerja di level IP atau domain. Suppression list dikelola oleh Anda atau ESP Anda, bekerja di level individual email address. Jika IP Anda ada di Spamhaus blocklist, seluruh email dari IP tersebut bermasalah. Jika email address ada di suppression list Anda, hanya email ke address tersebut yang tidak dikirim.
Kapan sebaiknya email ditandai sebagai soft bounce versus langsung ditambahkan ke hard bounce suppression?
Gunakan SMTP enhanced code sebagai panduan. Code 4.x.x (misalnya 450, 451) adalah temporary failure , soft bounce. Code 5.x.x (misalnya 550, 553) adalah permanent failure , hard bounce. Ada exception: enhanced code 550 dengan message “User unknown” atau “Mailbox does not exist” adalah hard bounce yang jelas. Code 421 “Service not available” sering temporary dan bisa di-retry.
Bagaimana menangani transactional email yang harus tetap dikirim meski alamat ada di suppression list untuk marketing?
Ini pattern yang disebut “transactional override”. Login confirmation, password reset, dan OTP adalah transactional emails yang regulated berbeda dari marketing. User bisa unsubscribe dari newsletter tapi tetap perlu terima password reset. Implementasikan ini dengan flag di database user: marketing_suppressed_at untuk marketing emails, sementara transactional emails bypass check marketing suppression. Yang tetap di-check: hard bounce dan spam complaint suppression.
Apakah perlu suppress transactional email kalau user sudah unsubscribe dari marketing emails?
Tergantung alasan unsubscribe. Kalau unsubscribe karena tidak suka email promosi, transactional emails tetap boleh kirim. Kalau unsubscribe karena spam complaint, suppress semua termasuk transactional. Spam complaint yang dilaporkan ke ESP (FBL , Feedback Loop) biasanya mengakibatkan provider memblokir seluruh domain, bukan hanya satu address. Dalam kasus ini, semua transactional emails ke domain tersebut juga akan di-blokir ESP.
Bagaimana cara prevent invalid email address masuk sistem sejak awal?
Validasi email ada dua layer: syntax validation dan deliverability validation. Syntax validation pakai RFC 5322 standard, ini catch email yang formatnya salah seperti missing @ atau domain tanpa dot. Deliverability validation pakai API seperti KIRIM.EMAIL Validation API, ini check apakah mailbox benar-benar ada di mail server tujuan. Validasi deliverability harus dilakukan saat registration atau checkout, sebelum email masuk sistem Anda. Biaya API kecil dibandingkan biaya hard bounce yang merusak reputation.
Kalau membangun sistem dari nol, mengimplementasi suppression list yang benar bisa memakan waktu berminggu-minggu. KIRIM.EMAIL Dev menyediakan infrastruktur suppression management built-in, webhook bounce parsing, automatic suppression classification, dan API untuk check status email sebelum kirim. Tidak perlu bangun pipeline sendiri.
Kalau membangun sistem dari nol, setup bounce handling dan suppression classification dari scratch bisa memakan waktu berminggu-minggu. KIRIM.EMAIL Dev menyediakan infrastruktur ini sebagai built-in feature, termasuk webhook parsing untuk klasifikasi bounce otomatis dan API check status sebelum kirim. Anda fokus ke aplikasi, infrastruktur suppression ditangani oleh platform.
Kalau perlu langsung siap production tanpa bangun infrastruktur sendiri, KIRIM.EMAIL Dev menyediakan bounce handling dan suppression management yang sudah terintegrasi dengan SMTP API.
Hasbi Putra adalah Head of Marketing di KIRIM.EMAIL, email delivery infrastructure untuk developer dan tim IT di Indonesia. KIRIM.EMAIL mengirim lebih dari 11 juta email per hari dengan server yang sepenuhnya berlokasi di Indonesia.
- Cara Monitor Email Sent Logs dan Audit Trail di Sistem Transaksional untuk Debugging dan Compliance - April 29, 2026
- Cara Debug Email Sending dengan Email Leak Testing untuk Identifikasi Privacy Leak di Aplikasi Web - April 28, 2026
- Cara Setup Email Queue dengan Background Worker di Node.js untuk Aplikasi High-Volume - April 27, 2026
