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