Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/<token>`.

**What the recipient sees:** a minimal page at `/confirm/<token>` 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:
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
80 changes: 77 additions & 3 deletions app/(dashboard)/lists/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -330,16 +364,50 @@ export default function ListDetailPage() {
{listInfo?.description && (
<p className="text-sm text-muted-foreground mt-1">{listInfo.description}</p>
)}
<div className="flex items-center gap-2 mt-2">
<div className="flex items-center gap-2 mt-2 flex-wrap">
<Badge variant="secondary">{listInfo?.counts.total ?? 0} total</Badge>
<Badge variant="default">{listInfo?.counts.active ?? 0} active</Badge>
{(listInfo?.counts.pending ?? 0) > 0 && (
<Badge variant="outline">{listInfo?.counts.pending} pending</Badge>
)}
{(listInfo?.counts.bounced ?? 0) > 0 && (
<Badge variant="destructive">{listInfo?.counts.bounced} bounced</Badge>
)}
{(listInfo?.counts.unsubscribed ?? 0) > 0 && (
<Badge variant="outline">{listInfo?.counts.unsubscribed} unsubscribed</Badge>
)}
</div>
<div className="flex items-center gap-3 mt-3 rounded-md border bg-card px-3 py-2 max-w-md">
<button
type="button"
role="switch"
aria-checked={!!listInfo?.requireDoubleOptIn}
disabled={togglingOptIn || !listInfo}
onClick={handleToggleDoubleOptIn}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50 ${
listInfo?.requireDoubleOptIn ? 'bg-primary' : 'bg-muted'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-background shadow transition-transform ${
listInfo?.requireDoubleOptIn ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
<div className="text-sm">
<div className="font-medium">
Double opt-in {listInfo?.requireDoubleOptIn ? 'enabled' : 'disabled'}
{togglingOptIn && (
<span className="ml-2 text-xs text-muted-foreground">Updating...</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{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.'}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button variant="outline" onClick={handleExport} disabled={exporting}>
Expand Down Expand Up @@ -408,6 +476,12 @@ export default function ListDetailPage() {
{listInfo?.counts.active ?? 0}
</Badge>
</TabsTrigger>
<TabsTrigger value="pending">
Pending
<Badge variant="secondary" className="ml-2">
{listInfo?.counts.pending ?? 0}
</Badge>
</TabsTrigger>
<TabsTrigger value="bounced">
Bounced
<Badge variant="secondary" className="ml-2">
Expand All @@ -431,7 +505,7 @@ export default function ListDetailPage() {
</TabsTrigger>
</TabsList>

{(['active', 'bounced', 'unsubscribed'] as TabStatus[]).map((tab) => (
{(['active', 'pending', 'bounced', 'unsubscribed'] as TabStatus[]).map((tab) => (
<TabsContent key={tab} value={tab} className="space-y-4">
{/* Search bar */}
<div className="flex items-center gap-2">
Expand Down
51 changes: 44 additions & 7 deletions app/(dashboard)/lists/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ interface ListWithCounts {
id: string
name: string
description: string | null
requireDoubleOptIn: boolean
createdAt: string
total: number
active: number
bounced: number
unsubscribed: number
pending: number
}

export default function ListsPage() {
Expand All @@ -46,6 +48,7 @@ export default function ListsPage() {
const [dialogOpen, setDialogOpen] = useState(false)
const [newName, setNewName] = useState('')
const [newDescription, setNewDescription] = useState('')
const [newRequireDoubleOptIn, setNewRequireDoubleOptIn] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)

Expand Down Expand Up @@ -75,7 +78,11 @@ export default function ListsPage() {
const res = await fetch('/api/internal/lists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName.trim(), description: newDescription.trim() || undefined }),
body: JSON.stringify({
name: newName.trim(),
description: newDescription.trim() || undefined,
requireDoubleOptIn: newRequireDoubleOptIn,
}),
})
if (!res.ok) {
const body = await res.json()
Expand All @@ -84,6 +91,7 @@ export default function ListsPage() {
toast({ title: 'List created', description: `"${newName.trim()}" has been created.` })
setNewName('')
setNewDescription('')
setNewRequireDoubleOptIn(false)
setDialogOpen(false)
await fetchLists()
} catch (err: unknown) {
Expand Down Expand Up @@ -150,6 +158,24 @@ export default function ListsPage() {
disabled={submitting}
/>
</div>
<div className="flex items-start gap-2 rounded-md border p-3">
<input
id="list-double-opt-in"
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-input"
checked={newRequireDoubleOptIn}
onChange={(e) => setNewRequireDoubleOptIn(e.target.checked)}
disabled={submitting}
/>
<div className="space-y-0.5">
<Label htmlFor="list-double-opt-in" className="font-medium">
Require double opt-in
</Label>
<p className="text-xs text-muted-foreground">
New contacts will receive a confirmation email and must click the link before they can be sent campaigns.
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
Expand Down Expand Up @@ -188,6 +214,7 @@ export default function ListsPage() {
<TableHead>List Name</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="text-right">Active</TableHead>
<TableHead className="text-right">Pending</TableHead>
<TableHead className="text-right">Bounced</TableHead>
<TableHead className="text-right">Unsubscribed</TableHead>
<TableHead>Created Date</TableHead>
Expand All @@ -198,12 +225,19 @@ export default function ListsPage() {
{lists.map((list) => (
<TableRow key={list.id}>
<TableCell>
<button
className="font-medium text-left hover:text-primary transition-colors focus:outline-none"
onClick={() => router.push(`/lists/${list.id}`)}
>
{list.name}
</button>
<div className="flex items-center gap-2">
<button
className="font-medium text-left hover:text-primary transition-colors focus:outline-none"
onClick={() => router.push(`/lists/${list.id}`)}
>
{list.name}
</button>
{list.requireDoubleOptIn && (
<Badge variant="outline" className="text-[10px] uppercase tracking-wide">
Double opt-in
</Badge>
)}
</div>
{list.description && (
<p className="text-xs text-muted-foreground mt-0.5">{list.description}</p>
)}
Expand All @@ -214,6 +248,9 @@ export default function ListsPage() {
<TableCell className="text-right">
<span className="text-sm text-emerald-600 dark:text-emerald-400 font-medium">{list.active ?? 0}</span>
</TableCell>
<TableCell className="text-right">
<span className="text-sm text-sky-600 dark:text-sky-400 font-medium">{list.pending ?? 0}</span>
</TableCell>
<TableCell className="text-right">
<span className="text-sm text-red-600 dark:text-red-400 font-medium">{list.bounced ?? 0}</span>
</TableCell>
Expand Down
25 changes: 23 additions & 2 deletions app/api/internal/lists/[id]/contacts/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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 })
Expand Down
Loading
Loading