diff --git a/app/(dashboard)/settings/suppressions/page.tsx b/app/(dashboard)/settings/suppressions/page.tsx new file mode 100644 index 0000000..add417d --- /dev/null +++ b/app/(dashboard)/settings/suppressions/page.tsx @@ -0,0 +1,324 @@ +"use client" + +import { useEffect, useState, useCallback } from "react" +import Link from "next/link" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Badge } from "@/components/ui/badge" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { useToast } from "@/components/ui/use-toast" +import { Skeleton } from "@/components/ui/skeleton" +import { format } from "date-fns" + +type Reason = "bounce" | "complaint" | "unsubscribe" | "manual" | "imported" + +interface Suppression { + id: string + email: string + reason: Reason + source: string | null + createdAt: string +} + +interface ListResponse { + data: Suppression[] + meta: { page: number; limit: number; total: number } +} + +const REASON_LABEL: Record = { + bounce: "Bounced", + complaint: "Complained", + unsubscribe: "Unsubscribed", + manual: "Manual", + imported: "Imported", +} + +function reasonBadgeClass(reason: Reason): string { + switch (reason) { + case "bounce": + return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300" + case "complaint": + return "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300" + case "unsubscribe": + return "bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-300" + case "manual": + return "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300" + case "imported": + return "bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300" + } +} + +const PAGE_SIZE = 50 + +export default function SuppressionsPage() { + const { toast } = useToast() + + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const [total, setTotal] = useState(0) + const [search, setSearch] = useState("") + + const [addOpen, setAddOpen] = useState(false) + const [addEmail, setAddEmail] = useState("") + const [addReason, setAddReason] = useState("manual") + const [saving, setSaving] = useState(false) + + const [deleteId, setDeleteId] = useState(null) + + const fetchData = useCallback(async () => { + setLoading(true) + try { + const params = new URLSearchParams({ + page: String(page), + limit: String(PAGE_SIZE), + }) + if (search) params.set("search", search) + const res = await fetch(`/api/internal/suppressions?${params.toString()}`) + if (!res.ok) throw new Error() + const json = (await res.json()) as ListResponse + setData(json.data) + setTotal(json.meta.total) + } catch { + toast({ title: "Failed to load suppressions", variant: "destructive" }) + } finally { + setLoading(false) + } + }, [page, search, toast]) + + useEffect(() => { fetchData() }, [fetchData]) + + const handleAdd = async () => { + if (!addEmail.trim()) return + setSaving(true) + try { + const res = await fetch("/api/internal/suppressions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: addEmail.trim(), reason: addReason, source: "manual" }), + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + toast({ title: body.error || "Failed to add suppression", variant: "destructive" }) + return + } + toast({ title: "Email suppressed" }) + setAddEmail("") + setAddReason("manual") + setAddOpen(false) + setPage(1) + fetchData() + } catch { + toast({ title: "Failed to add suppression", variant: "destructive" }) + } finally { + setSaving(false) + } + } + + const handleDelete = async () => { + if (!deleteId) return + try { + const res = await fetch(`/api/internal/suppressions/${deleteId}`, { method: "DELETE" }) + if (!res.ok) { + toast({ title: "Failed to remove suppression", variant: "destructive" }) + return + } + toast({ title: "Email un-suppressed" }) + fetchData() + } catch { + toast({ title: "Failed to remove suppression", variant: "destructive" }) + } finally { + setDeleteId(null) + } + } + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + + return ( +
+
+
+

Suppressions

+

+ Centralized record of email addresses that should never be sent to. Auto-populated from + bounces, complaints, and unsubscribes. CSV import supported for historical data. +

+
+
+ + + + + + + + + + Add a suppression + +
+
+ + setAddEmail(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + /> +
+
+ + +
+
+ + + + +
+
+
+
+ +
+ { + setSearch(e.target.value) + setPage(1) + }} + className="max-w-sm" + /> +

+ {total.toLocaleString()} total +

+
+ + {loading ? ( + + ) : data.length === 0 ? ( +
+ {search ? "No suppressions match your search." : "No suppressions yet."} +
+ ) : ( + <> + + + + Email + Reason + Source + Added + + + + + {data.map((row) => ( + + {row.email} + + + {REASON_LABEL[row.reason] ?? row.reason} + + + {row.source ?? ""} + + {format(new Date(row.createdAt), "MMM d, yyyy")} + + + + + + ))} + +
+ +
+ + Page {page} of {totalPages} + +
+ + +
+
+ + )} + + { if (!open) setDeleteId(null) }}> + + + Remove suppression + +

+ This email will become eligible to receive campaigns again. Continue? +

+ + + + +
+
+
+ ) +} diff --git a/app/(dashboard)/settings/suppressions/upload/page.tsx b/app/(dashboard)/settings/suppressions/upload/page.tsx new file mode 100644 index 0000000..7a90955 --- /dev/null +++ b/app/(dashboard)/settings/suppressions/upload/page.tsx @@ -0,0 +1,396 @@ +"use client" + +import { useState, useRef } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Progress } from "@/components/ui/progress" +import { Card, CardContent } from "@/components/ui/card" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +type Target = "skip" | "email" | "reason" | "source" + +interface ColumnMapping { + target: Target +} + +interface ImportResult { + inserted: number + skipped: number +} + +const STEP_LABELS = ["Upload File", "Map Columns", "Import"] + +export default function SuppressionsUploadPage() { + const router = useRouter() + + const [step, setStep] = useState<1 | 2 | 3>(1) + const [isDragging, setIsDragging] = useState(false) + const [isUploading, setIsUploading] = useState(false) + const [uploadError, setUploadError] = useState(null) + + const [headers, setHeaders] = useState([]) + const [preview, setPreview] = useState([]) + const [s3Key, setS3Key] = useState("") + const [filename, setFilename] = useState("") + + const [columnMappings, setColumnMappings] = useState([]) + + const [isImporting, setIsImporting] = useState(false) + const [importProgress, setImportProgress] = useState(0) + const [importResult, setImportResult] = useState(null) + const [importError, setImportError] = useState(null) + + const fileInputRef = useRef(null) + + const emailMappedIndex = columnMappings.findIndex((m) => m.target === "email") + const isEmailMapped = emailMappedIndex !== -1 + + async function handleFile(file: File) { + if (!file) return + setUploadError(null) + setIsUploading(true) + setFilename(file.name) + + const formData = new FormData() + formData.append("file", file) + formData.append("filename", file.name) + + try { + const res = await fetch("/api/internal/suppressions/upload", { + method: "POST", + body: formData, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.error || "Upload failed") + } + const data = await res.json() + setHeaders(data.headers) + setPreview(data.preview) + setS3Key(data.s3Key) + + const initial: ColumnMapping[] = (data.headers as string[]).map((h) => { + const lc = h.toLowerCase().trim() + if (lc === "email") return { target: "email" } + if (lc === "reason") return { target: "reason" } + if (lc === "source") return { target: "source" } + return { target: "skip" } + }) + setColumnMappings(initial) + setStep(2) + } catch (err: unknown) { + setUploadError(err instanceof Error ? err.message : "An error occurred during upload") + } finally { + setIsUploading(false) + } + } + + function updateMapping(index: number, target: Target) { + setColumnMappings((prev) => { + const next = [...prev] + next[index] = { target } + return next + }) + } + + async function handleImport() { + setIsImporting(true) + setImportError(null) + setImportProgress(20) + + let emailColumn: number | undefined + let reasonColumn: number | undefined + let sourceColumn: number | undefined + columnMappings.forEach((m, i) => { + if (m.target === "email") emailColumn = i + else if (m.target === "reason") reasonColumn = i + else if (m.target === "source") sourceColumn = i + }) + + if (emailColumn === undefined) { + setImportError("Email column is required") + setIsImporting(false) + return + } + + const mapping: Record = { email: emailColumn } + if (reasonColumn !== undefined) mapping.reason = reasonColumn + if (sourceColumn !== undefined) mapping.source = sourceColumn + + setImportProgress(50) + + try { + const res = await fetch("/api/internal/suppressions/upload/confirm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ s3Key, filename, mapping }), + }) + setImportProgress(85) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.error || "Import failed") + } + const result = (await res.json()) as ImportResult + setImportProgress(100) + setImportResult(result) + } catch (err: unknown) { + setImportError(err instanceof Error ? err.message : "An error occurred during import") + } finally { + setIsImporting(false) + } + } + + return ( +
+
+ {STEP_LABELS.map((label, i) => { + const stepNum = (i + 1) as 1 | 2 | 3 + const isActive = step === stepNum + const isDone = step > stepNum + return ( +
+
+ {isDone ? "\u2713" : stepNum} +
+ + {label} + + {i < STEP_LABELS.length - 1 &&
} +
+ ) + })} +
+ + {step === 1 && ( + + +

Upload a suppression file

+

+ Accepted formats: CSV and XLSX. The first row must be a header row. Required column: email. + Optional: reason, source. +

+ +
fileInputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }} + onDragLeave={() => setIsDragging(false)} + onDrop={(e) => { + e.preventDefault() + setIsDragging(false) + const file = e.dataTransfer.files?.[0] + if (file) handleFile(file) + }} + className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-12 cursor-pointer transition-colors ${ + isDragging + ? "border-primary bg-primary/5" + : "border-border hover:border-primary/40 hover:bg-muted/50" + }`} + > + {isUploading ? ( +

Uploading file...

+ ) : ( + <> +

+ Drag and drop your file here, or click to browse +

+

.csv and .xlsx files only

+ + )} +
+ + { + const file = e.target.files?.[0] + if (file) handleFile(file) + }} + /> + + {uploadError && ( +

+ {uploadError} +

+ )} +
+
+ )} + + {step === 2 && ( +
+ + +

Preview

+

+ Showing the first {preview.length} rows from your file. +

+
+ + + + {headers.map((h, i) => ( + + {h} + + ))} + + + + {preview.map((row, ri) => ( + + {row.map((cell, ci) => ( + + {cell ?? ""} + + ))} + + ))} + +
+
+
+
+ + + +

Map columns

+

+ Email is required. Reason values must be one of: bounce, complaint, unsubscribe, + manual, imported. Other values default to imported. +

+ +
+ {headers.map((header, i) => ( +
+
+ + {header} + +
+
+ +
+
+ ))} +
+ + {!isEmailMapped && ( +

+ Please map one column to Email before importing. +

+ )} + +
+ + +
+
+
+
+ )} + + {step === 3 && ( + + +

Importing suppressions

+ + {isImporting && ( +
+ +

+ Processing your file, please wait... +

+
+ )} + + {importError && ( +
+

+ {importError} +

+
+ + +
+
+ )} + + {importResult && !isImporting && ( +
+ +
+
+

+ {importResult.inserted} +

+

Inserted

+
+
+

+ {importResult.skipped} +

+

Skipped

+
+
+

+ Import complete. Skipped entries were either invalid emails or already on the list. +

+ +
+ )} +
+
+ )} +
+ ) +} diff --git a/app/api/internal/campaigns/[id]/send/route.ts b/app/api/internal/campaigns/[id]/send/route.ts index 1da8d24..e246b7e 100644 --- a/app/api/internal/campaigns/[id]/send/route.ts +++ b/app/api/internal/campaigns/[id]/send/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' -import { campaigns, campaignSends, contacts } from '@/lib/db/schema' -import { eq, and } from 'drizzle-orm' +import { campaigns, campaignSends, contacts, suppressions } from '@/lib/db/schema' +import { eq, and, sql } from 'drizzle-orm' import { getQueue, JOBS } from '@/lib/queue' import { logger, trackEvent, trackError } from '@/lib/logger' @@ -68,11 +68,17 @@ export async function POST( 'Campaign validation passed, fetching contacts' ) - // Fetch active contacts for the list + // Fetch active contacts for the list, excluding any whose email is on the global suppression list const contactList = await db .select() .from(contacts) - .where(and(eq(contacts.listId, campaign.listId), eq(contacts.status, 'active'))) + .where( + and( + eq(contacts.listId, campaign.listId), + eq(contacts.status, 'active'), + sql`NOT EXISTS (SELECT 1 FROM ${suppressions} WHERE ${suppressions.email} = ${contacts.email})` + ) + ) if (contactList.length === 0) { logger.warn({ campaignId: params.id, listId: campaign.listId }, 'No active contacts in list') diff --git a/app/api/internal/suppressions/[id]/route.ts b/app/api/internal/suppressions/[id]/route.ts new file mode 100644 index 0000000..6dbce8b --- /dev/null +++ b/app/api/internal/suppressions/[id]/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import { unsuppressEmailById } from '@/lib/suppressions' + +export async function DELETE( + _req: Request, + { params }: { params: { id: string } } +) { + const removed = await unsuppressEmailById(params.id) + if (!removed) { + return NextResponse.json({ error: 'Suppression not found' }, { status: 404 }) + } + return NextResponse.json({ success: true }) +} diff --git a/app/api/internal/suppressions/route.ts b/app/api/internal/suppressions/route.ts new file mode 100644 index 0000000..e8e9dc0 --- /dev/null +++ b/app/api/internal/suppressions/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { suppressions } from '@/lib/db/schema' +import { ilike, sql, desc } from 'drizzle-orm' +import { createSuppressionSchema } from '@/lib/validations/suppressions' +import { suppressEmail, normalizeEmail } from '@/lib/suppressions' + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url) + const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1) + const limitRaw = parseInt(searchParams.get('limit') ?? '50', 10) || 50 + const limit = Math.min(200, Math.max(1, limitRaw)) + const search = (searchParams.get('search') ?? '').trim() + + const where = search ? ilike(suppressions.email, `%${search.toLowerCase()}%`) : undefined + + const dataQuery = db + .select() + .from(suppressions) + .orderBy(desc(suppressions.createdAt)) + .limit(limit) + .offset((page - 1) * limit) + + const data = where ? await dataQuery.where(where) : await dataQuery + + const totalQuery = db.select({ count: sql`count(*)::int` }).from(suppressions) + const totalRows = where ? await totalQuery.where(where) : await totalQuery + const total = totalRows[0]?.count ?? 0 + + return NextResponse.json({ data, meta: { page, limit, total } }) +} + +export async function POST(req: NextRequest) { + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const parsed = createSuppressionSchema.safeParse(body) + if (!parsed.success) { + const message = parsed.error.errors.map((e) => e.message).join(', ') + return NextResponse.json({ error: message }, { status: 400 }) + } + + const email = normalizeEmail(parsed.data.email) + await suppressEmail({ + email, + reason: parsed.data.reason ?? 'manual', + source: parsed.data.source ?? 'manual', + metadata: parsed.data.metadata, + }) + + const [row] = await db.select().from(suppressions).where(sql`${suppressions.email} = ${email}`).limit(1) + return NextResponse.json(row, { status: 201 }) +} diff --git a/app/api/internal/suppressions/upload/confirm/route.ts b/app/api/internal/suppressions/upload/confirm/route.ts new file mode 100644 index 0000000..70345f6 --- /dev/null +++ b/app/api/internal/suppressions/upload/confirm/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from 'next/server' +import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3' +import * as XLSX from 'xlsx' +import { uploadConfirmSchema, SUPPRESSION_REASONS } from '@/lib/validations/suppressions' +import { suppressEmailsBulk, type SuppressInput, type SuppressionReason } from '@/lib/suppressions' + +const s3 = new S3Client({ + region: process.env.S3_REGION!, + endpoint: process.env.S3_ENDPOINT, + forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true', + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY_ID!, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!, + }, +}) + +const reasonValues = new Set(SUPPRESSION_REASONS) + +export async function POST(request: NextRequest) { + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const parsed = uploadConfirmSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.flatten() }, + { status: 400 } + ) + } + + const { s3Key, mapping, filename } = parsed.data + + let buf: Buffer + try { + const obj = await s3.send( + new GetObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: s3Key }) + ) + buf = Buffer.from(await obj.Body!.transformToByteArray()) + } catch { + return NextResponse.json({ error: 'Failed to retrieve file from storage' }, { status: 500 }) + } + + const wb = XLSX.read(buf, { type: 'buffer' }) + const sheet = wb.Sheets[wb.SheetNames[0]] + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }) + const dataRows = rows.slice(1) as string[][] + + const defaultSource = filename ? `csv:${filename}` : 'csv' + const inputs: SuppressInput[] = [] + + for (const row of dataRows) { + const emailRaw = row[mapping.email] + if (!emailRaw || typeof emailRaw !== 'string' || emailRaw.trim() === '') continue + + let reason: SuppressionReason = 'imported' + if (mapping.reason !== undefined) { + const raw = row[mapping.reason] + const candidate = raw ? String(raw).trim().toLowerCase() : '' + if (reasonValues.has(candidate)) reason = candidate as SuppressionReason + } + + const sourceCol = mapping.source !== undefined ? row[mapping.source] : undefined + const source = sourceCol ? String(sourceCol).trim() || defaultSource : defaultSource + + inputs.push({ email: emailRaw, reason, source }) + } + + const result = await suppressEmailsBulk(inputs) + return NextResponse.json(result) +} diff --git a/app/api/internal/suppressions/upload/route.ts b/app/api/internal/suppressions/upload/route.ts new file mode 100644 index 0000000..a95f96d --- /dev/null +++ b/app/api/internal/suppressions/upload/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server' +import * as XLSX from 'xlsx' +import { uploadFile } from '@/lib/storage' + +export async function POST(request: NextRequest) { + let formData: FormData + try { + formData = await request.formData() + } catch { + return NextResponse.json({ error: 'Failed to parse form data' }, { status: 400 }) + } + + const file = formData.get('file') as Blob | null + const filename = (formData.get('filename') as string | null) ?? '' + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + } + + const buffer = Buffer.from(await file.arrayBuffer()) + + const wb = XLSX.read(buffer, { type: 'buffer' }) + const sheet = wb.Sheets[wb.SheetNames[0]] + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }) + + if (!rows || rows.length === 0) { + return NextResponse.json({ error: 'File is empty or could not be parsed' }, { status: 400 }) + } + + const headers = rows[0] as string[] + const preview = rows.slice(1, 6) as string[][] + + const s3Key = `uploads/suppressions/${Date.now()}.xlsx` + await uploadFile(s3Key, buffer, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + + return NextResponse.json({ headers, preview, s3Key, filename }) +} diff --git a/app/api/v1/suppressions/[id]/route.ts b/app/api/v1/suppressions/[id]/route.ts new file mode 100644 index 0000000..06b51f7 --- /dev/null +++ b/app/api/v1/suppressions/[id]/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from 'next/server' +import { authenticateApiKey } from '@/lib/api-auth' +import { unsuppressEmailById } from '@/lib/suppressions' + +export async function DELETE( + req: NextRequest, + { params }: { params: { id: string } } +) { + if (!(await authenticateApiKey(req))) { + return NextResponse.json({ error: 'Unauthorized', data: null, meta: {} }, { status: 401 }) + } + + const removed = await unsuppressEmailById(params.id) + if (!removed) { + return NextResponse.json({ error: 'Suppression not found', data: null, meta: {} }, { status: 404 }) + } + return NextResponse.json({ data: { id: params.id, removed: true }, meta: {}, error: null }) +} diff --git a/app/api/v1/suppressions/bulk/route.ts b/app/api/v1/suppressions/bulk/route.ts new file mode 100644 index 0000000..ef6e57b --- /dev/null +++ b/app/api/v1/suppressions/bulk/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server' +import { authenticateApiKey } from '@/lib/api-auth' +import { bulkSuppressionsSchema } from '@/lib/validations/suppressions' +import { suppressEmailsBulk, type SuppressInput } from '@/lib/suppressions' + +export async function POST(req: NextRequest) { + if (!(await authenticateApiKey(req))) { + return NextResponse.json({ error: 'Unauthorized', data: null, meta: {} }, { status: 401 }) + } + + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body', data: null, meta: {} }, { status: 400 }) + } + + const parsed = bulkSuppressionsSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', data: null, meta: { details: parsed.error.flatten() } }, + { status: 400 } + ) + } + + const inputs: SuppressInput[] = parsed.data.emails.map((entry) => ({ + email: entry.email, + reason: entry.reason ?? 'manual', + source: entry.source ?? 'api-bulk', + metadata: entry.metadata, + })) + + const result = await suppressEmailsBulk(inputs) + return NextResponse.json({ data: result, meta: { submitted: inputs.length }, error: null }, { status: 201 }) +} diff --git a/app/api/v1/suppressions/route.ts b/app/api/v1/suppressions/route.ts new file mode 100644 index 0000000..0f8b0ee --- /dev/null +++ b/app/api/v1/suppressions/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { suppressions } from '@/lib/db/schema' +import { ilike, sql, desc } from 'drizzle-orm' +import { authenticateApiKey } from '@/lib/api-auth' +import { createSuppressionSchema } from '@/lib/validations/suppressions' +import { suppressEmail, normalizeEmail } from '@/lib/suppressions' + +export async function GET(req: NextRequest) { + if (!(await authenticateApiKey(req))) { + return NextResponse.json({ error: 'Unauthorized', data: null, meta: {} }, { status: 401 }) + } + + const { searchParams } = new URL(req.url) + const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1) + const limitRaw = parseInt(searchParams.get('limit') ?? '50', 10) || 50 + const limit = Math.min(200, Math.max(1, limitRaw)) + const search = (searchParams.get('search') ?? '').trim() + + const where = search ? ilike(suppressions.email, `%${search.toLowerCase()}%`) : undefined + + const dataQuery = db + .select() + .from(suppressions) + .orderBy(desc(suppressions.createdAt)) + .limit(limit) + .offset((page - 1) * limit) + const data = where ? await dataQuery.where(where) : await dataQuery + + const totalQuery = db.select({ count: sql`count(*)::int` }).from(suppressions) + const totalRows = where ? await totalQuery.where(where) : await totalQuery + const total = totalRows[0]?.count ?? 0 + + return NextResponse.json({ data, meta: { page, limit, total }, error: null }) +} + +export async function POST(req: NextRequest) { + if (!(await authenticateApiKey(req))) { + return NextResponse.json({ error: 'Unauthorized', data: null, meta: {} }, { status: 401 }) + } + + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body', data: null, meta: {} }, { status: 400 }) + } + + const parsed = createSuppressionSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', data: null, meta: { details: parsed.error.flatten() } }, + { status: 400 } + ) + } + + const email = normalizeEmail(parsed.data.email) + await suppressEmail({ + email, + reason: parsed.data.reason ?? 'manual', + source: parsed.data.source ?? 'api', + metadata: parsed.data.metadata, + }) + + const [row] = await db.select().from(suppressions).where(sql`${suppressions.email} = ${email}`).limit(1) + return NextResponse.json({ data: row, meta: {}, error: null }, { status: 201 }) +} diff --git a/app/api/webhooks/resend/route.ts b/app/api/webhooks/resend/route.ts index 1bccce8..fe8c5df 100644 --- a/app/api/webhooks/resend/route.ts +++ b/app/api/webhooks/resend/route.ts @@ -3,6 +3,7 @@ import { db } from '@/lib/db' import { campaignSends, campaignEvents, contacts } from '@/lib/db/schema' import { eq } from 'drizzle-orm' import { Webhook } from 'svix' +import { suppressEmail } from '@/lib/suppressions' async function handleBounceOrComplaint( providerMessageId: string, @@ -21,16 +22,26 @@ async function handleBounceOrComplaint( .set({ status: eventType === 'bounce' ? 'bounced' : 'failed' }) .where(eq(campaignSends.id, send.id)) - await db + const [contact] = await db .update(contacts) .set({ status: contactStatus }) .where(eq(contacts.id, send.contactId)) + .returning({ email: contacts.email }) await db.insert(campaignEvents).values({ campaignSendId: send.id, campaignId: send.campaignId, type: eventType, }) + + if (contact?.email) { + await suppressEmail({ + email: contact.email, + reason: eventType === 'complaint' ? 'complaint' : 'bounce', + source: 'resend', + metadata: { providerMessageId, campaignId: send.campaignId, campaignSendId: send.id }, + }) + } } export async function POST(req: NextRequest) { diff --git a/app/api/webhooks/ses/route.ts b/app/api/webhooks/ses/route.ts index 8e20e9d..130c213 100644 --- a/app/api/webhooks/ses/route.ts +++ b/app/api/webhooks/ses/route.ts @@ -2,11 +2,13 @@ import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' import { campaignSends, campaignEvents, contacts } from '@/lib/db/schema' import { eq } from 'drizzle-orm' +import { suppressEmail } from '@/lib/suppressions' async function handleBounceOrComplaint( providerMessageId: string, contactStatus: string, - eventType: string + eventType: string, + shouldSuppress: boolean ) { const [send] = await db .select() @@ -20,16 +22,26 @@ async function handleBounceOrComplaint( .set({ status: eventType === 'bounce' ? 'bounced' : 'failed' }) .where(eq(campaignSends.id, send.id)) - await db + const [contact] = await db .update(contacts) .set({ status: contactStatus }) .where(eq(contacts.id, send.contactId)) + .returning({ email: contacts.email }) await db.insert(campaignEvents).values({ campaignSendId: send.id, campaignId: send.campaignId, type: eventType, }) + + if (shouldSuppress && contact?.email) { + await suppressEmail({ + email: contact.email, + reason: eventType === 'complaint' ? 'complaint' : 'bounce', + source: 'ses', + metadata: { providerMessageId, campaignId: send.campaignId, campaignSendId: send.id }, + }) + } } export async function POST(req: NextRequest) { @@ -47,14 +59,16 @@ export async function POST(req: NextRequest) { if (message.notificationType === 'Bounce') { const messageId: string = message.mail.messageId const recipientCount: number = (message.bounce.bouncedRecipients as unknown[]).length + // Only Permanent bounces add to global suppressions. Transient bounces still flip per-list status. + const isPermanent: boolean = message.bounce.bounceType === 'Permanent' for (let i = 0; i < recipientCount; i++) { - await handleBounceOrComplaint(messageId, 'bounced', 'bounce') + await handleBounceOrComplaint(messageId, 'bounced', 'bounce', isPermanent) } } else if (message.notificationType === 'Complaint') { const messageId: string = message.mail.messageId const recipientCount: number = (message.complaint.complainedRecipients as unknown[]).length for (let i = 0; i < recipientCount; i++) { - await handleBounceOrComplaint(messageId, 'unsubscribed', 'complaint') + await handleBounceOrComplaint(messageId, 'unsubscribed', 'complaint', true) } } } diff --git a/app/unsubscribe/[token]/page.tsx b/app/unsubscribe/[token]/page.tsx index 7d16fbf..3138b19 100644 --- a/app/unsubscribe/[token]/page.tsx +++ b/app/unsubscribe/[token]/page.tsx @@ -2,6 +2,7 @@ import { db } from '@/lib/db' import { contacts, lists, campaignSends, campaignEvents } from '@/lib/db/schema' import { eq, desc } from 'drizzle-orm' import { redirect } from 'next/navigation' +import { suppressEmail } from '@/lib/suppressions' async function getContactByToken(token: string) { const result = await db @@ -54,6 +55,13 @@ async function unsubscribeAction(formData: FormData) { }) } + await suppressEmail({ + email: contact.email, + reason: 'unsubscribe', + source: 'unsubscribe-link', + metadata: { contactId: contact.id, listId: contact.listId }, + }) + redirect(`/unsubscribe/${token}`) } diff --git a/components/dashboard/sidebar.tsx b/components/dashboard/sidebar.tsx index 065d1a7..9746443 100644 --- a/components/dashboard/sidebar.tsx +++ b/components/dashboard/sidebar.tsx @@ -70,6 +70,16 @@ const settingsNavItems: NavItem[] = [ ), }, + { + label: 'Suppressions', + href: '/settings/suppressions', + icon: ( + + + + + ), + }, ] function NavLink({ item, pathname }: { item: NavItem; pathname: string }) { diff --git a/drizzle/migrations/0001_woozy_silvermane.sql b/drizzle/migrations/0001_woozy_silvermane.sql new file mode 100644 index 0000000..5307501 --- /dev/null +++ b/drizzle/migrations/0001_woozy_silvermane.sql @@ -0,0 +1,9 @@ +CREATE TABLE "suppressions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" text NOT NULL, + "reason" text NOT NULL, + "source" text, + "metadata" jsonb DEFAULT '{}'::jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "suppressions_email_unique" UNIQUE("email") +); diff --git a/drizzle/migrations/meta/0001_snapshot.json b/drizzle/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..bb82fa9 --- /dev/null +++ b/drizzle/migrations/meta/0001_snapshot.json @@ -0,0 +1,681 @@ +{ + "id": "378e93f9-e1c7-4ee8-bdaf-4e645c039b90", + "prevId": "c66073d7-a246-4a46-8ee5-225f1fc78ebe", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_events": { + "name": "campaign_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "campaign_send_id": { + "name": "campaign_send_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link_url": { + "name": "link_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "campaign_events_campaign_send_id_campaign_sends_id_fk": { + "name": "campaign_events_campaign_send_id_campaign_sends_id_fk", + "tableFrom": "campaign_events", + "tableTo": "campaign_sends", + "columnsFrom": [ + "campaign_send_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "campaign_events_campaign_id_campaigns_id_fk": { + "name": "campaign_events_campaign_id_campaigns_id_fk", + "tableFrom": "campaign_events", + "tableTo": "campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_sends": { + "name": "campaign_sends", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "campaign_id": { + "name": "campaign_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "campaign_sends_campaign_id_campaigns_id_fk": { + "name": "campaign_sends_campaign_id_campaigns_id_fk", + "tableFrom": "campaign_sends", + "tableTo": "campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "campaign_sends_contact_id_contacts_id_fk": { + "name": "campaign_sends_contact_id_contacts_id_fk", + "tableFrom": "campaign_sends", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "campaign_sends_campaign_id_contact_id_unique": { + "name": "campaign_sends_campaign_id_contact_id_unique", + "nullsNotDistinct": false, + "columns": [ + "campaign_id", + "contact_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaigns": { + "name": "campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "list_id": { + "name": "list_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "template_json": { + "name": "template_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "template_html": { + "name": "template_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_recipients": { + "name": "total_recipients", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cancel_requested": { + "name": "cancel_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "campaigns_list_id_lists_id_fk": { + "name": "campaigns_list_id_lists_id_fk", + "tableFrom": "campaigns", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "campaigns_provider_id_email_providers_id_fk": { + "name": "campaigns_provider_id_email_providers_id_fk", + "tableFrom": "campaigns", + "tableTo": "email_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contacts": { + "name": "contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "list_id": { + "name": "list_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "unsubscribe_token": { + "name": "unsubscribe_token", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "contacts_list_id_lists_id_fk": { + "name": "contacts_list_id_lists_id_fk", + "tableFrom": "contacts", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "contacts_unsubscribe_token_unique": { + "name": "contacts_unsubscribe_token_unique", + "nullsNotDistinct": false, + "columns": [ + "unsubscribe_token" + ] + }, + "contacts_list_id_email_unique": { + "name": "contacts_list_id_email_unique", + "nullsNotDistinct": false, + "columns": [ + "list_id", + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_providers": { + "name": "email_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_encrypted": { + "name": "config_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rate_limit_per_second": { + "name": "rate_limit_per_second", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lists": { + "name": "lists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.suppressions": { + "name": "suppressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "suppressions_email_unique": { + "name": "suppressions_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index d2df13a..7c2334f 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1776275290720, "tag": "0000_lame_texas_twister", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1777752836747, + "tag": "0001_woozy_silvermane", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index e867a05..eeea4b9 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -78,6 +78,15 @@ export const campaignEvents = pgTable('campaign_events', { createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }) +export const suppressions = pgTable('suppressions', { + id: uuid('id').primaryKey().defaultRandom(), + email: text('email').notNull().unique(), + reason: text('reason').notNull(), // bounce | complaint | unsubscribe | manual | imported + source: text('source'), + metadata: jsonb('metadata').default({}).$type>(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + export const apiKeys = pgTable('api_keys', { id: uuid('id').primaryKey().defaultRandom(), name: text('name').notNull(), @@ -103,3 +112,4 @@ export type Campaign = typeof campaigns.$inferSelect export type CampaignSend = typeof campaignSends.$inferSelect export type CampaignEvent = typeof campaignEvents.$inferSelect export type ApiKey = typeof apiKeys.$inferSelect +export type Suppression = typeof suppressions.$inferSelect diff --git a/lib/suppressions/index.ts b/lib/suppressions/index.ts new file mode 100644 index 0000000..19397da --- /dev/null +++ b/lib/suppressions/index.ts @@ -0,0 +1,91 @@ +import { db } from '@/lib/db' +import { suppressions } from '@/lib/db/schema' +import { eq, sql } from 'drizzle-orm' + +export type SuppressionReason = 'bounce' | 'complaint' | 'unsubscribe' | 'manual' | 'imported' + +export interface SuppressInput { + email: string + reason: SuppressionReason + source?: string | null + metadata?: Record +} + +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase() +} + +export async function isSuppressed(email: string): Promise { + const normalized = normalizeEmail(email) + if (!normalized) return false + const [row] = await db + .select({ id: suppressions.id }) + .from(suppressions) + .where(eq(suppressions.email, normalized)) + .limit(1) + return !!row +} + +export async function suppressEmail(input: SuppressInput): Promise { + const email = normalizeEmail(input.email) + if (!email) return + await db + .insert(suppressions) + .values({ + email, + reason: input.reason, + source: input.source ?? null, + metadata: input.metadata ?? {}, + }) + .onConflictDoNothing({ target: suppressions.email }) +} + +export async function suppressEmailsBulk( + rows: SuppressInput[] +): Promise<{ inserted: number; skipped: number }> { + if (rows.length === 0) return { inserted: 0, skipped: 0 } + + const seen = new Set() + const dedup: { email: string; reason: SuppressionReason; source: string | null; metadata: Record }[] = [] + for (const r of rows) { + const email = normalizeEmail(r.email) + if (!email) continue + if (seen.has(email)) continue + seen.add(email) + dedup.push({ email, reason: r.reason, source: r.source ?? null, metadata: r.metadata ?? {} }) + } + + let inserted = 0 + const BATCH = 500 + for (let i = 0; i < dedup.length; i += BATCH) { + const chunk = dedup.slice(i, i + BATCH) + const result = await db + .insert(suppressions) + .values(chunk) + .onConflictDoNothing({ target: suppressions.email }) + .returning({ id: suppressions.id }) + inserted += result.length + } + + return { inserted, skipped: rows.length - inserted } +} + +export async function unsuppressEmailById(id: string): Promise { + const result = await db.delete(suppressions).where(eq(suppressions.id, id)).returning({ id: suppressions.id }) + return result.length > 0 +} + +export async function unsuppressEmail(email: string): Promise { + const normalized = normalizeEmail(email) + if (!normalized) return false + const result = await db + .delete(suppressions) + .where(eq(suppressions.email, normalized)) + .returning({ id: suppressions.id }) + return result.length > 0 +} + +export async function countSuppressions(): Promise { + const [row] = await db.select({ count: sql`count(*)::int` }).from(suppressions) + return row?.count ?? 0 +} diff --git a/lib/validations/suppressions.ts b/lib/validations/suppressions.ts new file mode 100644 index 0000000..bbb4950 --- /dev/null +++ b/lib/validations/suppressions.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' + +export const SUPPRESSION_REASONS = ['bounce', 'complaint', 'unsubscribe', 'manual', 'imported'] as const + +export const suppressionReasonSchema = z.enum(SUPPRESSION_REASONS) + +export const createSuppressionSchema = z.object({ + email: z.string().email('Valid email is required'), + reason: suppressionReasonSchema.optional(), + source: z.string().max(255).optional(), + metadata: z.record(z.unknown()).optional(), +}) + +export const bulkSuppressionsSchema = z.object({ + emails: z.array(createSuppressionSchema).min(1).max(1000), +}) + +export const uploadConfirmSchema = z.object({ + s3Key: z.string().min(1), + filename: z.string().optional(), + mapping: z.object({ + email: z.number().min(0, 'Email column is required'), + reason: z.number().optional(), + source: z.number().optional(), + }), +}) + +export type CreateSuppressionInput = z.infer +export type BulkSuppressionsInput = z.infer +export type SuppressionUploadConfirmInput = z.infer diff --git a/worker.ts b/worker.ts index 85fefbf..ae417d3 100644 --- a/worker.ts +++ b/worker.ts @@ -6,6 +6,7 @@ import { createProviderAdapter } from './lib/providers/factory' import { renderTemplate, renderPlainText } from './lib/renderer' import { JOBS } from './lib/queue' import { logger, trackEvent, trackError, shutdownTracking } from './lib/logger' +import { isSuppressed } from './lib/suppressions' const APP_URL = process.env.APP_URL! const CONCURRENCY = parseInt(process.env.WORKER_CONCURRENCY || '5') @@ -61,6 +62,13 @@ async function processSendJob(sendId: string, campaignId: string) { return } + if (await isSuppressed(contact.email)) { + logger.warn({ sendId, contactId: send.contactId, contactEmail: contact.email }, 'Email is globally suppressed, skipping send') + await db.update(campaignSends).set({ status: 'failed', errorMessage: 'Globally suppressed' }).where(eq(campaignSends.id, sendId)) + trackEvent('email_send_skipped', { sendId, campaignId, reason: 'suppressed' }) + return + } + const [provider] = await db.select().from(emailProviders).where(eq(emailProviders.id, campaign.providerId!)) if (!provider) { logger.error({ sendId, campaignId, providerId: campaign.providerId }, 'Provider not found')