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
+108
View File
@@ -0,0 +1,108 @@
export const DEFAULT_DOUBLE_OPT_IN_SUBJECT = "Please confirm your subscription";
const DEFAULT_DOUBLE_OPT_IN_CONTENT_JSON = {
type: "doc",
content: [
{
type: "paragraph",
attrs: { textAlign: "left" },
content: [
{
type: "text",
text: "Hello, Thank you for sigining up. Please confirm that you want to receive emails from us.",
},
],
},
{
type: "paragraph",
attrs: { textAlign: "left" },
content: [
{
type: "text",
text: "If you did not request this, you can ignore this email.",
},
],
},
{
type: "button",
attrs: {
component: "button",
text: "Confirm",
url: "{{doubleOptInUrl}}",
alignment: "left",
borderRadius: "8",
borderWidth: "1",
buttonColor: "#000000",
borderColor: "#000000",
textColor: "#ffffff",
},
},
{
type: "horizontalRule",
},
{
type: "paragraph",
attrs: { textAlign: "left" },
content: [
{
type: "text",
text: "You are receiving this email because you opted in via our site.",
},
],
},
],
};
export const DEFAULT_DOUBLE_OPT_IN_CONTENT = JSON.stringify(
DEFAULT_DOUBLE_OPT_IN_CONTENT_JSON,
);
export const DOUBLE_OPT_IN_EDITOR_VARIABLES = [
"email",
"firstName",
"lastName",
"doubleOptInUrl",
];
const DOUBLE_OPT_IN_URL_PLACEHOLDER_REGEX =
/\{\{\s*doubleOptInUrl(?:\s*,\s*fallback=[^}]+)?\s*\}\}/i;
function valueIncludesDoubleOptInUrl(value: unknown): boolean {
if (typeof value === "string") {
const normalizedValue = value.trim().toLowerCase();
return (
DOUBLE_OPT_IN_URL_PLACEHOLDER_REGEX.test(value) ||
normalizedValue === "doubleoptinurl"
);
}
if (Array.isArray(value)) {
return value.some(valueIncludesDoubleOptInUrl);
}
if (value && typeof value === "object") {
return Object.values(value).some(valueIncludesDoubleOptInUrl);
}
return false;
}
export function hasDoubleOptInUrlPlaceholder(content: string): boolean {
if (DOUBLE_OPT_IN_URL_PLACEHOLDER_REGEX.test(content)) {
return true;
}
try {
return valueIncludesDoubleOptInUrl(JSON.parse(content));
} catch {
return false;
}
}
export function getDefaultDoubleOptInContent() {
return structuredClone(DEFAULT_DOUBLE_OPT_IN_CONTENT_JSON) as Record<
string,
any
>;
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_DOUBLE_OPT_IN_CONTENT,
getDefaultDoubleOptInContent,
hasDoubleOptInUrlPlaceholder,
} from "~/lib/constants/double-opt-in";
describe("double opt-in defaults", () => {
it("uses a confirmation button placeholder in the default editor content", () => {
const content = JSON.parse(DEFAULT_DOUBLE_OPT_IN_CONTENT) as {
content?: Array<{
type?: string;
attrs?: { url?: string };
}>;
};
const hasButtonPlaceholder = content.content?.some(
(node) =>
node.type === "button" && node.attrs?.url === "{{doubleOptInUrl}}",
);
expect(hasButtonPlaceholder).toBe(true);
expect(hasDoubleOptInUrlPlaceholder(DEFAULT_DOUBLE_OPT_IN_CONTENT)).toBe(
true,
);
});
it("returns a clone when requesting default content", () => {
const first = getDefaultDoubleOptInContent();
const second = getDefaultDoubleOptInContent();
first.content = [];
expect(second.content).not.toEqual([]);
});
it("detects placeholder tokens in raw string content", () => {
expect(
hasDoubleOptInUrlPlaceholder(
'<p>Click <a href="{{ doubleOptInUrl }}">confirm</a></p>',
),
).toBe(true);
});
it("detects variable nodes using doubleOptInUrl", () => {
expect(
hasDoubleOptInUrlPlaceholder(
JSON.stringify({
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "variable", attrs: { id: "doubleOptInUrl" } }],
},
],
}),
),
).toBe(true);
});
it("returns false when placeholder is missing", () => {
expect(
hasDoubleOptInUrlPlaceholder(
JSON.stringify({
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Confirm your subscription" }],
},
],
}),
),
).toBe(false);
});
});
+36 -12
View File
@@ -1,23 +1,47 @@
import { z } from "zod";
export const ContactBookSchema = z.object({
id: z
.string()
.openapi({ description: "The ID of the contact book", example: "clx1234567890" }),
name: z
.string()
.openapi({ description: "The name of the contact book", example: "Newsletter Subscribers" }),
id: z.string().openapi({
description: "The ID of the contact book",
example: "clx1234567890",
}),
name: z.string().openapi({
description: "The name of the contact book",
example: "Newsletter Subscribers",
}),
teamId: z.number().openapi({ description: "The ID of the team", example: 1 }),
properties: z.record(z.string()).openapi({
description: "Custom properties for the contact book",
example: { customField1: "value1" },
}),
emoji: z
.string()
.openapi({ description: "The emoji associated with the contact book", example: "📙" }),
emoji: z.string().openapi({
description: "The emoji associated with the contact book",
example: "📙",
}),
doubleOptInEnabled: z.boolean().optional().openapi({
description: "Whether double opt-in is enabled for new contacts",
example: true,
}),
doubleOptInFrom: z.string().nullable().optional().openapi({
description:
"From address used for double opt-in emails (must use a verified domain)",
example: "Newsletter <hello@example.com>",
}),
doubleOptInSubject: z.string().nullable().optional().openapi({
description: "Subject line used for double opt-in confirmation email",
example: "Please confirm your subscription",
}),
doubleOptInContent: z.string().nullable().optional().openapi({
description:
"Email editor JSON content used for double opt-in confirmation",
}),
createdAt: z.string().openapi({ description: "The creation timestamp" }),
updatedAt: z.string().openapi({ description: "The last update timestamp" }),
_count: z.object({
contacts: z.number().openapi({ description: "The number of contacts in the contact book" }),
}).optional(),
_count: z
.object({
contacts: z
.number()
.openapi({ description: "The number of contacts in the contact book" }),
})
.optional(),
});