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