suppressions.ts92 lines · main
1import { eq } from 'drizzle-orm';
2
3import { newId } from '@briven/shared';
4
5import { getDb } from '../db/client.js';
6import { emailSuppressions, type NewEmailSuppression, type EmailSuppression } from '../db/schema.js';
7import { log } from '../lib/logger.js';
8
9export type SuppressionReason = 'permanent_bounce' | 'complaint' | 'mittera_suppressed' | 'manual';
10
11/**
12 * Lower-case + trim so equality lookups match regardless of casing
13 * the recipient typed in. Same normalisation used at insert time.
14 */
15function normaliseEmail(email: string): string {
16 return email.trim().toLowerCase();
17}
18
19/**
20 * Returns true when the recipient is in the suppression list — used
21 * by the outbound path in lib/email.ts to short-circuit before we
22 * call mittera (cheaper than a 4xx + retry storm).
23 */
24export async function isSuppressed(email: string): Promise<boolean> {
25 const db = getDb();
26 const norm = normaliseEmail(email);
27 const rows = await db
28 .select({ id: emailSuppressions.id })
29 .from(emailSuppressions)
30 .where(eq(emailSuppressions.email, norm))
31 .limit(1);
32 return rows.length > 0;
33}
34
35/**
36 * Idempotent insert — if the email is already suppressed we keep the
37 * earliest reason on record (don't downgrade complaint→bounce or
38 * vice-versa). Returns the row that's now live.
39 */
40export async function suppress(args: {
41 email: string;
42 reason: SuppressionReason;
43 detail?: string | null;
44 sourceEventId?: string | null;
45}): Promise<EmailSuppression | null> {
46 const db = getDb();
47 const norm = normaliseEmail(args.email);
48 if (!norm) return null;
49
50 const row: NewEmailSuppression = {
51 id: newId('sup'),
52 email: norm,
53 reason: args.reason,
54 detail: args.detail ?? null,
55 sourceEventId: args.sourceEventId ?? null,
56 };
57
58 try {
59 const [inserted] = await db
60 .insert(emailSuppressions)
61 .values(row)
62 .onConflictDoNothing({ target: emailSuppressions.email })
63 .returning();
64 return inserted ?? null;
65 } catch (err) {
66 log.warn('suppression_insert_failed', {
67 email: norm,
68 reason: args.reason,
69 message: err instanceof Error ? err.message : String(err),
70 });
71 return null;
72 }
73}
74
75export async function unsuppress(email: string): Promise<boolean> {
76 const db = getDb();
77 const norm = normaliseEmail(email);
78 const deleted = await db
79 .delete(emailSuppressions)
80 .where(eq(emailSuppressions.email, norm))
81 .returning({ id: emailSuppressions.id });
82 return deleted.length > 0;
83}
84
85export async function listSuppressions(limit = 200): Promise<EmailSuppression[]> {
86 const db = getDb();
87 return db
88 .select()
89 .from(emailSuppressions)
90 .orderBy(emailSuppressions.createdAt)
91 .limit(limit);
92}