diff --git a/.env.example b/.env.example index 1e7be84..b2724cd 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,11 @@ ENCRYPTION_KEY= # Generate with: openssl rand -hex 32 # Worker WORKER_CONCURRENCY=5 +# Double opt-in confirmation emails +# Required when any list has `requireDoubleOptIn` enabled. +# Sender address used for confirmation emails. Sender name uses APP_NAME. +CONFIRMATION_FROM_EMAIL= # e.g. noreply@yourdomain.com + # Webhooks (optional, for bounce/complaint tracking) # RESEND_WEBHOOK_SECRET= # From Resend dashboard diff --git a/CHANGELOG.md b/CHANGELOG.md index 8623fdf..5dc4edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. - Campaign creation, sending, scheduling, and cancellation - Open tracking (pixel) and click tracking (link wrapping) with per-campaign analytics - Unsubscribe page with one-click opt-out and List-Unsubscribe header support +- Per-list double opt-in: toggle on a list to require new contacts to confirm via emailed link before they can be sent campaigns. Pending contacts are excluded from sends until confirmed. Configurable via `CONFIRMATION_FROM_EMAIL` env var and a switch on the list detail page. - Public REST API with Bearer token authentication - API key management in the dashboard - Webhook receivers for Resend and SES bounce/complaint notifications diff --git a/README.md b/README.md index 8e07a6c..de665b5 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Self-hostable broadcast email tool. Manage contact lists, design emails with a v - **Campaign Sending** - Queue-based sending via pg-boss, with scheduling, cancellation, and per-provider rate limiting - **Open and Click Tracking** - Tracking pixel for opens, link wrapping for clicks, per-campaign analytics with charts - **Unsubscribe Handling** - One-click unsubscribe with List-Unsubscribe header support +- **Double Opt-In** - Per-list toggle that requires new contacts to confirm via emailed link before they receive campaigns - **REST API** - Full API with Bearer token auth for programmatic access to lists, contacts, and campaign data - **Self-Contained** - Postgres for everything (data, queue, migrations). No Redis, no external queue. Optional MinIO/S3 for file uploads - **Docker Ready** - Single `docker compose up` for local development, production-ready Dockerfile included @@ -134,6 +135,7 @@ mc mb local/emailtool | `S3_FORCE_PATH_STYLE` | Use path-style URLs (required for MinIO) | `true` | | `ENCRYPTION_KEY` | 32-byte hex key for encrypting provider credentials | (required) | | `WORKER_CONCURRENCY` | Number of concurrent email send jobs | `5` | +| `CONFIRMATION_FROM_EMAIL` | Sender address for double opt-in confirmation emails. Required when any list has double opt-in enabled. Sender name uses `APP_NAME`. | (required if double opt-in is used) | | `RESEND_WEBHOOK_SECRET` | Resend webhook signing secret (optional) | | ## Email Providers @@ -169,6 +171,27 @@ Webhooks let Mailpost track bounces and complaints reported by the email provide 3. The endpoint will auto-confirm the subscription 4. In SES, configure a Configuration Set to publish bounce and complaint notifications to the SNS topic +## Double Opt-In + +Each list has a per-list double opt-in toggle. When enabled, any new contact added to the list (via the dashboard, CSV upload, or REST API) is created with `status = pending` and immediately receives a confirmation email. The contact is excluded from campaigns until they click the link, after which their status flips to `active`. + +**Enabling per list:** +- *On creation:* check "Require double opt-in" in the New List dialog. +- *On an existing list:* open the list detail page and use the "Double opt-in" switch in the header. You can flip it on or off at any time. + +**Toggle behavior:** +- Turning it **on** affects new contacts only. Existing `active` contacts are not retroactively flipped to `pending`. +- Turning it **off** affects new contacts only. Existing `pending` contacts stay pending until they confirm or are manually edited. + +**Required setup:** +1. Set `CONFIRMATION_FROM_EMAIL` in your env to a verified sender address (e.g. `noreply@yourdomain.com`). +2. Configure at least one email provider and mark it as the default in Settings > Providers. Confirmation emails are sent through the default provider. +3. Make sure `APP_URL` is publicly reachable: the confirmation link in the email points at `APP_URL/confirm/`. + +**What the recipient sees:** a minimal page at `/confirm/` showing the list name and email address, with a single "Confirm Subscription" button. After clicking, the page shows a success message and the token is consumed. + +**Send-time gating:** campaign sends already filter on `status = active`, so pending contacts are automatically excluded with no extra configuration. + ## Using AWS S3 Instead of MinIO For production, switch from MinIO to AWS S3 by changing three variables: diff --git a/ROADMAP.md b/ROADMAP.md index ad9cd7f..6640c95 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,7 +29,7 @@ The features below build out the API surface so it's safe, observable, and compl - [ ] **Typed custom contact fields** [Effort: M]: Per-list field definitions with types (text, number, date, boolean) instead of freeform JSON metadata. Enables validation, filtering, and personalization with proper data types. - [ ] **Tags on contacts** [Effort: S]: Lightweight, multi-valued labels that can be applied to contacts manually or by automations. Used as filters in segments and as triggers in workflows. - [ ] **Segments** [Effort: M]: Saved filter queries against a list (e.g. "opened anything in the last 30 days, country = US"). Used as a campaign target instead of an entire list. Can be combined with tags, fields, and engagement signals. -- [ ] **Double opt-in** [Effort: M]: Per-list toggle that requires new contacts to confirm via emailed link before being marked active. Confirmation page and token-based confirmation flow. +- [x] **Double opt-in** [Effort: M]: Per-list toggle that requires new contacts to confirm via emailed link before being marked active. Confirmation page and token-based confirmation flow. - [ ] **Embeddable signup forms** [Effort: M]: Form builder, hosted form pages, and a JS embed snippet. Submissions flow into a list and respect double opt-in if enabled. - [ ] **Saved templates library** [Effort: S]: Reusable templates with thumbnails. Save any campaign as a template, start new campaigns from any template. - [ ] **Asset library** [Effort: M]: Central image and file manager. Upload once, browse and drop into any campaign editor. Replaces per-campaign image uploads. diff --git a/app/(dashboard)/lists/[id]/page.tsx b/app/(dashboard)/lists/[id]/page.tsx index 842c07c..7cc037b 100644 --- a/app/(dashboard)/lists/[id]/page.tsx +++ b/app/(dashboard)/lists/[id]/page.tsx @@ -37,11 +37,13 @@ interface ListInfo { id: string name: string description?: string + requireDoubleOptIn: boolean createdAt: string counts: { active: number bounced: number unsubscribed: number + pending: number total: number } } @@ -61,7 +63,7 @@ interface ContactsMeta { total: number } -type TabStatus = 'active' | 'bounced' | 'unsubscribed' +type TabStatus = 'active' | 'pending' | 'bounced' | 'unsubscribed' type TabValue = TabStatus | 'duplicates' export default function ListDetailPage() { @@ -95,6 +97,8 @@ export default function ListDetailPage() { const [gdprConfirmEmail, setGdprConfirmEmail] = useState('') const [gdprDeleting, setGdprDeleting] = useState(false) + const [togglingOptIn, setTogglingOptIn] = useState(false) + const { toast } = useToast() async function handleGdprExport(contact: Contact) { @@ -148,6 +152,36 @@ export default function ListDetailPage() { } } + async function handleToggleDoubleOptIn() { + if (!listInfo) return + const next = !listInfo.requireDoubleOptIn + setTogglingOptIn(true) + try { + const res = await fetch(`/api/internal/lists/${listId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ requireDoubleOptIn: next }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + toast({ title: data.error || 'Failed to update list', variant: 'destructive' }) + return + } + const updated = await res.json() + setListInfo({ ...listInfo, requireDoubleOptIn: updated.requireDoubleOptIn }) + toast({ + title: next ? 'Double opt-in enabled' : 'Double opt-in disabled', + description: next + ? 'New contacts will be sent a confirmation email.' + : 'New contacts will be marked active immediately.', + }) + } catch { + toast({ title: 'Failed to update list', variant: 'destructive' }) + } finally { + setTogglingOptIn(false) + } + } + async function handleAddContact(e: React.FormEvent) { e.preventDefault() if (!addEmail.trim()) return @@ -330,9 +364,12 @@ export default function ListDetailPage() { {listInfo?.description && (

{listInfo.description}

)} -
+
{listInfo?.counts.total ?? 0} total {listInfo?.counts.active ?? 0} active + {(listInfo?.counts.pending ?? 0) > 0 && ( + {listInfo?.counts.pending} pending + )} {(listInfo?.counts.bounced ?? 0) > 0 && ( {listInfo?.counts.bounced} bounced )} @@ -340,6 +377,37 @@ export default function ListDetailPage() { {listInfo?.counts.unsubscribed} unsubscribed )}
+
+ +
+
+ Double opt-in {listInfo?.requireDoubleOptIn ? 'enabled' : 'disabled'} + {togglingOptIn && ( + Updating... + )} +
+
+ {listInfo?.requireDoubleOptIn + ? 'New contacts must confirm by email before they can be sent campaigns.' + : 'New contacts are added as active and can receive campaigns immediately.'} +
+
+
+
+ + {list.requireDoubleOptIn && ( + + Double opt-in + + )} +
{list.description && (

{list.description}

)} @@ -214,6 +248,9 @@ export default function ListsPage() { {list.active ?? 0} + + {list.pending ?? 0} + {list.bounced ?? 0} diff --git a/app/api/internal/lists/[id]/contacts/route.ts b/app/api/internal/lists/[id]/contacts/route.ts index 2ca9409..9a5c496 100644 --- a/app/api/internal/lists/[id]/contacts/route.ts +++ b/app/api/internal/lists/[id]/contacts/route.ts @@ -1,9 +1,12 @@ import { NextRequest, NextResponse } from 'next/server' +import { randomUUID } from 'crypto' import { db } from '@/lib/db' -import { contacts } from '@/lib/db/schema' +import { contacts, lists } from '@/lib/db/schema' import { eq, ilike, and, count, SQL } from 'drizzle-orm' import { createContactSchema } from '@/lib/validations/contacts' import { auditFromSession, logAudit } from '@/lib/audit' +import { getQueue, JOBS } from '@/lib/queue' +import { logger } from '@/lib/logger' export async function GET( req: NextRequest, @@ -68,6 +71,12 @@ export async function POST( } try { + const [list] = await db.select().from(lists).where(eq(lists.id, params.id)) + if (!list) { + return NextResponse.json({ error: 'List not found' }, { status: 404 }) + } + const requireDoubleOptIn = list.requireDoubleOptIn + const [created] = await db .insert(contacts) .values({ @@ -76,14 +85,26 @@ export async function POST( firstName: parsed.data.firstName, lastName: parsed.data.lastName, metadata: parsed.data.metadata ?? {}, + ...(requireDoubleOptIn + ? { status: 'pending', confirmationToken: randomUUID() } + : {}), }) .returning() + if (requireDoubleOptIn && created) { + try { + const queue = await getQueue() + await queue.send(JOBS.SEND_CONFIRMATION, { contactId: created.id }) + } catch (err) { + logger.error({ err, contactId: created.id }, 'Failed to enqueue confirmation job') + } + } + await logAudit( await auditFromSession(req), 'contact.create', { type: 'contact', id: created.id }, - { listId: params.id, email: created.email }, + { listId: params.id, email: created.email, requireDoubleOptIn }, ) return NextResponse.json(created, { status: 201 }) diff --git a/app/api/internal/lists/[id]/route.ts b/app/api/internal/lists/[id]/route.ts index 4fae4ec..6baf01a 100644 --- a/app/api/internal/lists/[id]/route.ts +++ b/app/api/internal/lists/[id]/route.ts @@ -3,6 +3,7 @@ import { db } from '@/lib/db' import { lists, contacts } from '@/lib/db/schema' import { eq, sql } from 'drizzle-orm' import { auditFromSession, logAudit } from '@/lib/audit' +import { updateListSchema } from '@/lib/validations/lists' export async function GET( _req: NextRequest, @@ -23,16 +24,59 @@ export async function GET( active: sql`cast(count(case when ${contacts.status} = 'active' then 1 end) as int)`, bounced: sql`cast(count(case when ${contacts.status} = 'bounced' then 1 end) as int)`, unsubscribed: sql`cast(count(case when ${contacts.status} = 'unsubscribed' then 1 end) as int)`, + pending: sql`cast(count(case when ${contacts.status} = 'pending' then 1 end) as int)`, }) .from(contacts) .where(eq(contacts.listId, params.id)) return NextResponse.json({ ...list, - counts: counts ?? { total: 0, active: 0, bounced: 0, unsubscribed: 0 }, + counts: counts ?? { total: 0, active: 0, bounced: 0, unsubscribed: 0, pending: 0 }, }) } +export async function PATCH( + req: NextRequest, + { params }: { params: { id: string } } +) { + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const parsed = updateListSchema.safeParse(body) + if (!parsed.success) { + const message = parsed.error.errors.map((e) => e.message).join(', ') + return NextResponse.json({ error: message }, { status: 400 }) + } + + const updates: Record = { updatedAt: new Date() } + if (parsed.data.name !== undefined) updates.name = parsed.data.name + if (parsed.data.description !== undefined) updates.description = parsed.data.description + if (parsed.data.requireDoubleOptIn !== undefined) updates.requireDoubleOptIn = parsed.data.requireDoubleOptIn + + const [updated] = await db + .update(lists) + .set(updates) + .where(eq(lists.id, params.id)) + .returning() + + if (!updated) { + return NextResponse.json({ error: 'List not found' }, { status: 404 }) + } + + await logAudit( + await auditFromSession(req), + 'list.update', + { type: 'list', id: updated.id }, + { changes: parsed.data }, + ) + + return NextResponse.json(updated) +} + export async function DELETE( req: NextRequest, { params }: { params: { id: string } } diff --git a/app/api/internal/lists/[id]/upload/confirm/route.ts b/app/api/internal/lists/[id]/upload/confirm/route.ts index 4f4c0bb..fcc5dc1 100644 --- a/app/api/internal/lists/[id]/upload/confirm/route.ts +++ b/app/api/internal/lists/[id]/upload/confirm/route.ts @@ -1,11 +1,14 @@ import { NextRequest, NextResponse } from 'next/server' import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3' import * as XLSX from 'xlsx' +import { randomUUID } from 'crypto' import { db } from '@/lib/db' -import { contacts } from '@/lib/db/schema' -import { sql } from 'drizzle-orm' +import { contacts, lists } from '@/lib/db/schema' +import { eq, sql } from 'drizzle-orm' import { uploadConfirmSchema } from '@/lib/validations/lists' import { auditFromSession, logAudit } from '@/lib/audit' +import { getQueue, JOBS } from '@/lib/queue' +import { logger } from '@/lib/logger' const s3 = new S3Client({ region: process.env.S3_REGION!, @@ -41,6 +44,12 @@ export async function POST( const { s3Key, mapping } = parsed.data const listId = params.id + const [list] = await db.select().from(lists).where(eq(lists.id, listId)) + if (!list) { + return NextResponse.json({ error: 'List not found' }, { status: 404 }) + } + const requireDoubleOptIn = list.requireDoubleOptIn + // Fetch file from S3 let buf: Buffer try { @@ -66,6 +75,8 @@ export async function POST( firstName: string | null lastName: string | null metadata: Record + status?: string + confirmationToken?: string | null } const validContacts: ContactInsert[] = [] @@ -106,14 +117,19 @@ export async function POST( firstName: firstName ? String(firstName) : null, lastName: lastName ? String(lastName) : null, metadata, + ...(requireDoubleOptIn + ? { status: 'pending', confirmationToken: randomUUID() } + : {}), }) } // Upsert in batches of 500 - let processed = 0 + let inserted = 0 + let updated = 0 + const newContactIds: string[] = [] for (let i = 0; i < validContacts.length; i += BATCH_SIZE) { const batch = validContacts.slice(i, i + BATCH_SIZE) - await db + const returned = await db .insert(contacts) .values(batch) .onConflictDoUpdate({ @@ -125,21 +141,40 @@ export async function POST( updatedAt: sql`now()`, }, }) - processed += batch.length + .returning({ id: contacts.id, isInsert: sql`xmax = 0` }) + for (const row of returned) { + if (row.isInsert) { + inserted++ + if (requireDoubleOptIn) newContactIds.push(row.id) + } else { + updated++ + } + } + } + + if (requireDoubleOptIn && newContactIds.length > 0) { + try { + const queue = await getQueue() + await Promise.all( + newContactIds.map((contactId) => + queue.send(JOBS.SEND_CONFIRMATION, { contactId }), + ), + ) + } catch (err) { + logger.error({ err, listId, count: newContactIds.length }, 'Failed to enqueue confirmation jobs') + } } await logAudit( await auditFromSession(request), 'contact.upsert_bulk', { type: 'list', id: listId }, - { inserted: processed, skipped, source: 'upload' }, + { inserted, updated, skipped, source: 'upload', requireDoubleOptIn }, ) - // We cannot distinguish inserts from updates without a returning clause diff, - // so we report total processed as the combined count. Skipped rows had no email. return NextResponse.json({ - inserted: processed, - updated: 0, + inserted, + updated, skipped, }) } diff --git a/app/api/internal/lists/route.ts b/app/api/internal/lists/route.ts index c70551e..8509018 100644 --- a/app/api/internal/lists/route.ts +++ b/app/api/internal/lists/route.ts @@ -13,10 +13,12 @@ export async function GET() { description: lists.description, createdAt: lists.createdAt, updatedAt: lists.updatedAt, + requireDoubleOptIn: lists.requireDoubleOptIn, total: sql`cast(count(${contacts.id}) as int)`, active: sql`cast(count(case when ${contacts.status} = 'active' then 1 end) as int)`, bounced: sql`cast(count(case when ${contacts.status} = 'bounced' then 1 end) as int)`, unsubscribed: sql`cast(count(case when ${contacts.status} = 'unsubscribed' then 1 end) as int)`, + pending: sql`cast(count(case when ${contacts.status} = 'pending' then 1 end) as int)`, }) .from(lists) .leftJoin(contacts, eq(contacts.listId, lists.id)) @@ -45,6 +47,7 @@ export async function POST(req: NextRequest) { .values({ name: parsed.data.name, description: parsed.data.description, + requireDoubleOptIn: parsed.data.requireDoubleOptIn ?? false, }) .returning() diff --git a/app/api/v1/lists/[listId]/contacts/bulk/route.ts b/app/api/v1/lists/[listId]/contacts/bulk/route.ts index ff2141e..31bf17e 100644 --- a/app/api/v1/lists/[listId]/contacts/bulk/route.ts +++ b/app/api/v1/lists/[listId]/contacts/bulk/route.ts @@ -1,10 +1,13 @@ import { NextRequest, NextResponse } from 'next/server' +import { randomUUID } from 'crypto' import { db } from '@/lib/db' -import { contacts } from '@/lib/db/schema' -import { sql } from 'drizzle-orm' +import { contacts, lists } from '@/lib/db/schema' +import { eq, sql } from 'drizzle-orm' import { withApiAuth } from '@/lib/api-auth' import { bulkContactsSchema } from '@/lib/validations/contacts' import { auditFromApiKey, logAudit } from '@/lib/audit' +import { getQueue, JOBS } from '@/lib/queue' +import { logger } from '@/lib/logger' export async function POST( req: NextRequest, @@ -26,9 +29,16 @@ export async function POST( ) } + const [list] = await db.select().from(lists).where(eq(lists.id, params.listId)) + if (!list) { + return NextResponse.json({ error: 'List not found', data: null, meta: {} }, { status: 404 }) + } + const requireDoubleOptIn = list.requireDoubleOptIn + let inserted = 0 - const updated = 0 + let updated = 0 let skipped = 0 + const newContactIds: string[] = [] const batchSize = 500 const items = parsed.data.contacts @@ -41,6 +51,9 @@ export async function POST( firstName: c.firstName, lastName: c.lastName, metadata: c.metadata ?? {}, + ...(requireDoubleOptIn + ? { status: 'pending', confirmationToken: randomUUID() } + : {}), })) try { @@ -56,20 +69,39 @@ export async function POST( updatedAt: new Date(), }, }) - .returning({ id: contacts.id }) + .returning({ id: contacts.id, isInsert: sql`xmax = 0` }) - // Approximate: all returned rows are either inserted or updated - inserted += result.length + for (const row of result) { + if (row.isInsert) { + inserted++ + if (requireDoubleOptIn) newContactIds.push(row.id) + } else { + updated++ + } + } } catch { skipped += batch.length } } + if (requireDoubleOptIn && newContactIds.length > 0) { + try { + const queue = await getQueue() + await Promise.all( + newContactIds.map((contactId) => + queue.send(JOBS.SEND_CONFIRMATION, { contactId }), + ), + ) + } catch (err) { + logger.error({ err, listId: params.listId, count: newContactIds.length }, 'Failed to enqueue confirmation jobs') + } + } + await logAudit( auditFromApiKey(req, auth), 'contact.upsert_bulk', { type: 'list', id: params.listId }, - { inserted, updated, skipped, total: items.length }, + { inserted, updated, skipped, total: items.length, requireDoubleOptIn }, ) return NextResponse.json({ diff --git a/app/api/v1/lists/[listId]/contacts/route.ts b/app/api/v1/lists/[listId]/contacts/route.ts index ec3ed1d..0ab63a5 100644 --- a/app/api/v1/lists/[listId]/contacts/route.ts +++ b/app/api/v1/lists/[listId]/contacts/route.ts @@ -1,10 +1,13 @@ import { NextRequest, NextResponse } from 'next/server' +import { randomUUID } from 'crypto' import { db } from '@/lib/db' -import { contacts } from '@/lib/db/schema' +import { contacts, lists } from '@/lib/db/schema' import { eq, ilike, and, count, SQL } from 'drizzle-orm' import { withApiAuth } from '@/lib/api-auth' import { createContactSchema } from '@/lib/validations/contacts' import { auditFromApiKey, logAudit } from '@/lib/audit' +import { getQueue, JOBS } from '@/lib/queue' +import { logger } from '@/lib/logger' export async function GET( req: NextRequest, @@ -76,6 +79,12 @@ export async function POST( ) } + const [list] = await db.select().from(lists).where(eq(lists.id, params.listId)) + if (!list) { + return NextResponse.json({ error: 'List not found', data: null, meta: {} }, { status: 404 }) + } + const requireDoubleOptIn = list.requireDoubleOptIn + const [created] = await db .insert(contacts) .values({ @@ -84,14 +93,26 @@ export async function POST( firstName: parsed.data.firstName, lastName: parsed.data.lastName, metadata: parsed.data.metadata ?? {}, + ...(requireDoubleOptIn + ? { status: 'pending', confirmationToken: randomUUID() } + : {}), }) .returning() + if (requireDoubleOptIn && created) { + try { + const queue = await getQueue() + await queue.send(JOBS.SEND_CONFIRMATION, { contactId: created.id }) + } catch (err) { + logger.error({ err, contactId: created.id }, 'Failed to enqueue confirmation job') + } + } + await logAudit( auditFromApiKey(req, auth), 'contact.create', { type: 'contact', id: created.id }, - { listId: params.listId, email: created.email }, + { listId: params.listId, email: created.email, requireDoubleOptIn }, ) return NextResponse.json({ data: created, meta: {}, error: null }, { status: 201 }) diff --git a/app/confirm/[token]/page.tsx b/app/confirm/[token]/page.tsx new file mode 100644 index 0000000..d480616 --- /dev/null +++ b/app/confirm/[token]/page.tsx @@ -0,0 +1,159 @@ +import { db } from '@/lib/db' +import { contacts, lists } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' +import { redirect } from 'next/navigation' + +async function getContactByConfirmationToken(token: string) { + const result = await db + .select({ + id: contacts.id, + email: contacts.email, + status: contacts.status, + confirmationToken: contacts.confirmationToken, + listId: contacts.listId, + listName: lists.name, + }) + .from(contacts) + .innerJoin(lists, eq(contacts.listId, lists.id)) + .where(eq(contacts.confirmationToken, token)) + .limit(1) + + return result[0] || null +} + +async function confirmAction(formData: FormData) { + 'use server' + + const token = formData.get('token') as string + if (!token) return + + const contact = await db.query.contacts.findFirst({ + where: eq(contacts.confirmationToken, token), + }) + + if (!contact || contact.status !== 'pending') return + + await db + .update(contacts) + .set({ status: 'active', confirmationToken: null, updatedAt: new Date() }) + .where(eq(contacts.id, contact.id)) + + redirect(`/confirm/${token}?confirmed=1`) +} + +export default async function ConfirmPage({ + params, + searchParams, +}: { + params: { token: string } + searchParams: { confirmed?: string } +}) { + const { token } = params + const appName = process.env.APP_NAME || 'Mailpost' + const justConfirmed = searchParams.confirmed === '1' + + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + if (!uuidRegex.test(token)) { + return ( + +

Invalid Link

+

+ This confirmation link is invalid or has already been used. +

+
+ ) + } + + const contact = await getContactByConfirmationToken(token) + + if (!contact) { + if (justConfirmed) { + return ( + +

Subscription Confirmed

+

+ Thanks, you are now subscribed. +

+
+ ) + } + return ( + +

Invalid Link

+

+ This confirmation link is invalid or has already been used. +

+
+ ) + } + + if (contact.status === 'active') { + return ( + +

Already Confirmed

+

+ {contact.email} is already subscribed + to {contact.listName}. +

+
+ ) + } + + if (contact.status !== 'pending') { + return ( + +

Invalid Link

+

+ This link is no longer active. +

+
+ ) + } + + return ( + +

Confirm Subscription

+

+ Click below to confirm{' '} + {contact.email} for{' '} + {contact.listName}. +

+
+ + +
+
+ ) +} + +function ConfirmLayout({ + appName, + children, +}: { + appName: string + children: React.ReactNode +}) { + return ( +
+
+
+
+ + + + +
+

+ {appName} +

+ {children} +
+
+
+ ) +} diff --git a/drizzle/migrations/0002_spooky_shiva.sql b/drizzle/migrations/0002_spooky_shiva.sql new file mode 100644 index 0000000..2c5eab5 --- /dev/null +++ b/drizzle/migrations/0002_spooky_shiva.sql @@ -0,0 +1,3 @@ +ALTER TABLE "contacts" ADD COLUMN "confirmation_token" uuid;--> statement-breakpoint +ALTER TABLE "lists" ADD COLUMN "require_double_opt_in" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "contacts" ADD CONSTRAINT "contacts_confirmation_token_unique" UNIQUE("confirmation_token"); diff --git a/drizzle/migrations/meta/0002_snapshot.json b/drizzle/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..d24c50c --- /dev/null +++ b/drizzle/migrations/meta/0002_snapshot.json @@ -0,0 +1,841 @@ +{ + "id": "29c4494c-ae72-454e-94d4-1a9577e2d550", + "prevId": "378e93f9-e1c7-4ee8-bdaf-4e645c039b90", + "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 + }, + "rate_limit_per_minute": { + "name": "rate_limit_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "rate_limit_tokens": { + "name": "rate_limit_tokens", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "rate_limit_updated_at": { + "name": "rate_limit_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "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": { + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_resource_idx": { + "name": "audit_logs_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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()" + }, + "confirmation_token": { + "name": "confirmation_token", + "type": "uuid", + "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": { + "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_confirmation_token_unique": { + "name": "contacts_confirmation_token_unique", + "nullsNotDistinct": false, + "columns": [ + "confirmation_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 + }, + "require_double_opt_in": { + "name": "require_double_opt_in", + "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": {}, + "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 7c2334f..112e10b 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1777752836747, "tag": "0001_woozy_silvermane", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1777840613777, + "tag": "0002_spooky_shiva", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 10c7786..7155cad 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -6,6 +6,7 @@ export const lists = pgTable('lists', { id: uuid('id').primaryKey().defaultRandom(), name: text('name').notNull(), description: text('description'), + requireDoubleOptIn: boolean('require_double_opt_in').notNull().default(false), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }) @@ -17,8 +18,9 @@ export const contacts = pgTable('contacts', { firstName: text('first_name'), lastName: text('last_name'), metadata: jsonb('metadata').default({}).$type>(), - status: text('status').notNull().default('active'), // active | bounced | unsubscribed + status: text('status').notNull().default('active'), // active | bounced | unsubscribed | pending unsubscribeToken: uuid('unsubscribe_token').notNull().defaultRandom().unique(), + confirmationToken: uuid('confirmation_token').unique(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ diff --git a/lib/email/sendConfirmation.ts b/lib/email/sendConfirmation.ts new file mode 100644 index 0000000..e3f0135 --- /dev/null +++ b/lib/email/sendConfirmation.ts @@ -0,0 +1,105 @@ +import { db } from '@/lib/db' +import { contacts, lists, emailProviders } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' +import { createProviderAdapter } from '@/lib/providers/factory' +import { renderPlainText } from '@/lib/renderer' +import { logger } from '@/lib/logger' + +export async function sendConfirmation(contactId: string): Promise { + const fromEmail = process.env.CONFIRMATION_FROM_EMAIL + if (!fromEmail) { + throw new Error('CONFIRMATION_FROM_EMAIL is not set') + } + + const appUrl = process.env.APP_URL + if (!appUrl) { + throw new Error('APP_URL is not set') + } + + const appName = process.env.APP_NAME || 'Mailpost' + + const row = await db + .select({ + id: contacts.id, + email: contacts.email, + firstName: contacts.firstName, + status: contacts.status, + confirmationToken: contacts.confirmationToken, + listName: lists.name, + requireDoubleOptIn: lists.requireDoubleOptIn, + }) + .from(contacts) + .innerJoin(lists, eq(contacts.listId, lists.id)) + .where(eq(contacts.id, contactId)) + .limit(1) + + const contact = row[0] + if (!contact) { + logger.warn({ contactId }, 'Confirmation: contact not found, skipping') + return + } + + if (contact.status !== 'pending' || !contact.confirmationToken) { + logger.info({ contactId, status: contact.status }, 'Confirmation: contact no longer pending, skipping') + return + } + + const [provider] = await db + .select() + .from(emailProviders) + .where(eq(emailProviders.isDefault, true)) + .limit(1) + + if (!provider) { + throw new Error('No default email provider configured') + } + + const adapter = createProviderAdapter(provider.type, provider.configEncrypted) + const confirmUrl = `${appUrl}/confirm/${contact.confirmationToken}` + const greetName = contact.firstName?.trim() || 'there' + const safeListName = escapeHtml(contact.listName) + + const html = ` + + + +
+ + +
+

Confirm your subscription

+

Hi ${escapeHtml(greetName)},

+

Please confirm that you want to receive emails from ${safeListName}. Click the button below to complete your subscription.

+
+ Confirm subscription +
+

If the button does not work, paste this link into your browser:
${confirmUrl}

+

If you did not request this, you can ignore this message and you will not be subscribed.

+
+
${escapeHtml(appName)}
+
+` + + const subject = `Confirm your subscription to ${contact.listName}` + + const { messageId } = await adapter.send({ + to: contact.email, + from: fromEmail, + fromName: appName, + subject, + html, + text: renderPlainText(html), + }) + + logger.info({ contactId, messageId, providerType: provider.type }, 'Confirmation email sent') +} + +function escapeHtml(input: string): string { + return input + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + diff --git a/lib/queue/index.ts b/lib/queue/index.ts index 5971e22..d20cb1a 100644 --- a/lib/queue/index.ts +++ b/lib/queue/index.ts @@ -10,7 +10,7 @@ export async function getQueue(): Promise { }) await boss.start() // Create queues if they don't exist (required in pg-boss v12+) - for (const queue of [JOBS.SEND_EMAIL, JOBS.FINALIZE_CAMPAIGN]) { + for (const queue of [JOBS.SEND_EMAIL, JOBS.FINALIZE_CAMPAIGN, JOBS.SEND_CONFIRMATION]) { try { await boss.createQueue(queue) } catch (e: unknown) { @@ -26,4 +26,5 @@ export const JOBS = { SEND_CAMPAIGN: 'send-campaign', SEND_EMAIL: 'send-email', FINALIZE_CAMPAIGN: 'finalize-campaign', + SEND_CONFIRMATION: 'send-confirmation', } as const diff --git a/lib/validations/lists.ts b/lib/validations/lists.ts index dd55d7e..c441da9 100644 --- a/lib/validations/lists.ts +++ b/lib/validations/lists.ts @@ -3,6 +3,13 @@ import { z } from 'zod' export const createListSchema = z.object({ name: z.string().min(1, 'Name is required'), description: z.string().optional(), + requireDoubleOptIn: z.boolean().optional(), +}) + +export const updateListSchema = z.object({ + name: z.string().min(1, 'Name is required').optional(), + description: z.string().nullable().optional(), + requireDoubleOptIn: z.boolean().optional(), }) export const uploadConfirmSchema = z.object({ @@ -19,4 +26,5 @@ export const uploadConfirmSchema = z.object({ }) export type CreateListInput = z.infer +export type UpdateListInput = z.infer export type UploadConfirmInput = z.infer diff --git a/middleware.ts b/middleware.ts index 39274d4..58da846 100644 --- a/middleware.ts +++ b/middleware.ts @@ -2,6 +2,6 @@ export { default } from 'next-auth/middleware' export const config = { matcher: [ - '/((?!api/auth|api/v1|api/webhooks|img|login|unsubscribe|t|r|_next/static|_next/image|favicon.ico).*)', + '/((?!api/auth|api/v1|api/webhooks|img|login|unsubscribe|confirm|t|r|_next/static|_next/image|favicon.ico).*)', ], } diff --git a/worker.ts b/worker.ts index ae417d3..581a66b 100644 --- a/worker.ts +++ b/worker.ts @@ -7,6 +7,7 @@ import { renderTemplate, renderPlainText } from './lib/renderer' import { JOBS } from './lib/queue' import { logger, trackEvent, trackError, shutdownTracking } from './lib/logger' import { isSuppressed } from './lib/suppressions' +import { sendConfirmation } from './lib/email/sendConfirmation' const APP_URL = process.env.APP_URL! const CONCURRENCY = parseInt(process.env.WORKER_CONCURRENCY || '5') @@ -159,7 +160,7 @@ async function main() { logger.info('pg-boss started') // Create queues if they don't exist (required in pg-boss v12+) - for (const queue of [JOBS.SEND_EMAIL, JOBS.FINALIZE_CAMPAIGN]) { + for (const queue of [JOBS.SEND_EMAIL, JOBS.FINALIZE_CAMPAIGN, JOBS.SEND_CONFIRMATION]) { try { await boss.createQueue(queue) logger.info({ queue }, 'Queue created') @@ -216,6 +217,22 @@ async function main() { } }) + // Send double opt-in confirmation emails + await boss.work<{ contactId: string }>(JOBS.SEND_CONFIRMATION, async (jobs) => { + for (const job of jobs) { + const { contactId } = job.data + logger.info({ contactId, jobId: job.id }, 'Processing confirmation send job') + try { + await sendConfirmation(contactId) + trackEvent('confirmation_email_sent', { contactId }) + } catch (err) { + logger.error({ err, contactId, jobId: job.id }, 'Confirmation send job failed') + trackError(err, { action: 'send_confirmation', contactId }) + throw err + } + } + }) + logger.info('Worker ready, processing jobs') trackEvent('worker_started', { concurrency: CONCURRENCY })