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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user