suppressions.ts92 lines · main
| 1 | import { eq } from 'drizzle-orm'; |
| 2 | |
| 3 | import { newId } from '@briven/shared'; |
| 4 | |
| 5 | import { getDb } from '../db/client.js'; |
| 6 | import { emailSuppressions, type NewEmailSuppression, type EmailSuppression } from '../db/schema.js'; |
| 7 | import { log } from '../lib/logger.js'; |
| 8 | |
| 9 | export 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 | */ |
| 15 | function 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 | */ |
| 24 | export 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 | */ |
| 40 | export 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 | |
| 75 | export 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 | |
| 85 | export 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 | } |