feat: add customizable contact double opt-in flow (#350)

* feat: add customizable contact double opt-in flow

* test: add double opt-in service coverage

* fix: address review comments for double opt-in PR

- Make pending status conditional on doubleOptInEnabled flag
- Backfill legacy unsubscribeReason for reliable pending detection
- Add doubleOptInContent to contact book listing select
- Fix duplicate toast on DOI editor subject save failure
- Harden searchParams parsing against string[] values
- Make default DOI template use link mark for clickable URL
- Make public API create+update atomic via transaction
- Prevent contact upsert failure when DOI email send fails
- Fix empty string template variable replacement

Co-authored-by: opencode <opencode@anthropic.com>

* fix: harden double opt-in confirmation safeguards

Preserve explicit unsubscribe intent in DOI flows and prevent confirmation links from re-subscribing opted-out contacts. Also sanitize subscribe-page error messaging and use timing-safe hash comparison for link verification.

* ui stuff

* fix: require doubleOptInUrl in double opt-in templates

* feat: add configurable from address for double opt-in emails

* feat: add resend confirmation flow for pending contacts

* fix: move subscribe confirmation to explicit POST flow

* test: add contact book public API endpoint coverage

* docs: add double opt-in documentation and update OpenAPI spec

Add a user guide for the double opt-in feature covering setup, contact
statuses, email customization, template variables, and best practices.
Update the OpenAPI spec to include doubleOptIn fields in all contactBook
request/response schemas.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: opencode <opencode@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
KM Koushik
2026-03-01 00:34:20 +11:00
committed by GitHub
parent edcd32a4ea
commit e3e9635a5f
27 changed files with 3500 additions and 288 deletions
@@ -1,7 +1,15 @@
import { CampaignStatus, type ContactBook } from "@prisma/client";
import { CampaignStatus } from "@prisma/client";
import { db } from "../db";
import { LimitService } from "./limit-service";
import { UnsendApiError } from "../public-api/api-error";
import {
DEFAULT_DOUBLE_OPT_IN_CONTENT,
DEFAULT_DOUBLE_OPT_IN_SUBJECT,
hasDoubleOptInUrlPlaceholder,
} from "~/lib/constants/double-opt-in";
import { validateDomainFromEmail } from "./domain-service";
type ContactBookDbClient = Pick<typeof db, "contactBook">;
export async function getContactBooks(teamId: number, search?: string) {
return db.contactBook.findMany({
@@ -9,7 +17,18 @@ export async function getContactBooks(teamId: number, search?: string) {
teamId,
...(search ? { name: { contains: search, mode: "insensitive" } } : {}),
},
include: {
select: {
id: true,
name: true,
teamId: true,
properties: true,
emoji: true,
createdAt: true,
updatedAt: true,
doubleOptInEnabled: true,
doubleOptInFrom: true,
doubleOptInSubject: true,
doubleOptInContent: true,
_count: {
select: { contacts: true },
},
@@ -17,7 +36,11 @@ export async function getContactBooks(teamId: number, search?: string) {
});
}
export async function createContactBook(teamId: number, name: string) {
export async function createContactBook(
teamId: number,
name: string,
client: ContactBookDbClient = db,
) {
const { isLimitReached, reason } =
await LimitService.checkContactBookLimit(teamId);
@@ -28,11 +51,14 @@ export async function createContactBook(teamId: number, name: string) {
});
}
const created = await db.contactBook.create({
const created = await client.contactBook.create({
data: {
name,
teamId,
properties: {},
doubleOptInEnabled: true,
doubleOptInSubject: DEFAULT_DOUBLE_OPT_IN_SUBJECT,
doubleOptInContent: DEFAULT_DOUBLE_OPT_IN_CONTENT,
},
});
@@ -72,11 +98,82 @@ export async function updateContactBook(
name?: string;
properties?: Record<string, string>;
emoji?: string;
}
doubleOptInEnabled?: boolean;
doubleOptInFrom?: string | null;
doubleOptInSubject?: string;
doubleOptInContent?: string;
},
client: ContactBookDbClient = db,
) {
return db.contactBook.update({
const updateData = { ...data };
if (data.doubleOptInFrom !== undefined) {
const normalizedFrom = data.doubleOptInFrom?.trim() ?? "";
if (!normalizedFrom) {
updateData.doubleOptInFrom = null;
} else {
const contactBook = await client.contactBook.findUnique({
where: { id: contactBookId },
select: { teamId: true },
});
if (!contactBook) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "Contact book not found",
});
}
await validateDomainFromEmail(normalizedFrom, contactBook.teamId);
updateData.doubleOptInFrom = normalizedFrom;
}
}
if (
data.doubleOptInContent !== undefined &&
!data.doubleOptInContent.trim()
) {
updateData.doubleOptInContent = DEFAULT_DOUBLE_OPT_IN_CONTENT;
} else if (
data.doubleOptInContent !== undefined &&
!hasDoubleOptInUrlPlaceholder(data.doubleOptInContent)
) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message:
"Double opt-in email content must include the {{doubleOptInUrl}} placeholder",
});
}
if (
data.doubleOptInSubject !== undefined &&
!data.doubleOptInSubject.trim()
) {
updateData.doubleOptInSubject = DEFAULT_DOUBLE_OPT_IN_SUBJECT;
}
if (data.doubleOptInEnabled === true) {
const contactBook = await client.contactBook.findUnique({
where: { id: contactBookId },
select: {
doubleOptInSubject: true,
doubleOptInContent: true,
},
});
if (!updateData.doubleOptInSubject && !contactBook?.doubleOptInSubject) {
updateData.doubleOptInSubject = DEFAULT_DOUBLE_OPT_IN_SUBJECT;
}
if (!updateData.doubleOptInContent && !contactBook?.doubleOptInContent) {
updateData.doubleOptInContent = DEFAULT_DOUBLE_OPT_IN_CONTENT;
}
}
return client.contactBook.update({
where: { id: contactBookId },
data,
data: updateData,
});
}