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
@@ -5,79 +5,83 @@ import { updateContactBook as updateContactBookService } from "~/server/service/
import { getContactBook } from "../../api-utils";
const route = createRoute({
method: "patch",
path: "/v1/contactBooks/{contactBookId}",
request: {
params: z.object({
contactBookId: z.string().openapi({
param: {
name: "contactBookId",
in: "path",
},
example: "clx1234567890",
}),
}),
body: {
required: true,
content: {
"application/json": {
schema: z.object({
name: z.string().min(1).optional(),
emoji: z.string().optional(),
properties: z.record(z.string()).optional(),
}),
},
},
},
},
responses: {
200: {
content: {
"application/json": {
schema: ContactBookSchema,
},
},
description: "Update the contact book",
},
403: {
content: {
"application/json": {
schema: z.object({
error: z.string(),
}),
},
},
description:
"Forbidden - API key doesn't have access to this contact book",
},
404: {
content: {
"application/json": {
schema: z.object({
error: z.string(),
}),
},
},
description: "Contact book not found",
},
},
method: "patch",
path: "/v1/contactBooks/{contactBookId}",
request: {
params: z.object({
contactBookId: z.string().openapi({
param: {
name: "contactBookId",
in: "path",
},
example: "clx1234567890",
}),
}),
body: {
required: true,
content: {
"application/json": {
schema: z.object({
name: z.string().min(1).optional(),
emoji: z.string().optional(),
properties: z.record(z.string()).optional(),
doubleOptInEnabled: z.boolean().optional(),
doubleOptInFrom: z.string().nullable().optional(),
doubleOptInSubject: z.string().optional(),
doubleOptInContent: z.string().optional(),
}),
},
},
},
},
responses: {
200: {
content: {
"application/json": {
schema: ContactBookSchema,
},
},
description: "Update the contact book",
},
403: {
content: {
"application/json": {
schema: z.object({
error: z.string(),
}),
},
},
description:
"Forbidden - API key doesn't have access to this contact book",
},
404: {
content: {
"application/json": {
schema: z.object({
error: z.string(),
}),
},
},
description: "Contact book not found",
},
},
});
function updateContactBook(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = c.var.team;
const contactBookId = c.req.valid("param").contactBookId;
const body = c.req.valid("json");
app.openapi(route, async (c) => {
const team = c.var.team;
const contactBookId = c.req.valid("param").contactBookId;
const body = c.req.valid("json");
await getContactBook(c, team.id);
await getContactBook(c, team.id);
const updated = await updateContactBookService(contactBookId, body);
const updated = await updateContactBookService(contactBookId, body);
return c.json({
...updated,
properties: updated.properties as Record<string, string>,
});
});
return c.json({
...updated,
properties: updated.properties as Record<string, string>,
});
});
}
export default updateContactBook;