From e3e9635a5f4529413d16ceb246854134c80d0cb1 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Sun, 1 Mar 2026 00:34:20 +1100 Subject: [PATCH] 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 * 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 --------- Co-authored-by: opencode Co-authored-by: Claude Opus 4.6 --- AGENTS.md | 5 - apps/docs/api-reference/openapi.json | 68 ++++ apps/docs/docs.json | 2 +- apps/docs/guides/double-opt-in.mdx | 171 ++++++++ .../migration.sql | 11 + .../migration.sql | 3 + apps/web/prisma/schema.prisma | 22 +- .../contacts/[contactBookId]/contact-list.tsx | 137 ++++--- .../[contactBookId]/double-opt-in/page.tsx | 270 +++++++++++++ .../contacts/[contactBookId]/page.tsx | 267 +++++++++---- .../resend-double-opt-in-confirmation.tsx | 104 +++++ apps/web/src/app/subscribe/page.tsx | 205 ++++++++++ apps/web/src/lib/constants/double-opt-in.ts | 108 +++++ .../lib/constants/double-opt-in.unit.test.ts | 76 ++++ apps/web/src/lib/zod/contact-book-schema.ts | 48 ++- apps/web/src/server/api/routers/contacts.ts | 43 ++ .../contacts/create-contact-book.api.test.ts | 324 +++++++++++++++ .../api/contacts/create-contact-book.ts | 115 +++--- .../contacts/update-contact-book.api.test.ts | 291 ++++++++++++++ .../api/contacts/update-contact-book.ts | 138 +++---- .../server/service/contact-book-service.ts | 111 +++++- .../service/contact-book-service.unit.test.ts | 206 ++++++++++ .../web/src/server/service/contact-service.ts | 128 +++++- .../service/contact-service.unit.test.ts | 328 +++++++++++++++ .../server/service/double-opt-in-service.ts | 215 ++++++++++ .../double-opt-in-service.unit.test.ts | 372 ++++++++++++++++++ packages/ui/src/button.tsx | 20 +- 27 files changed, 3500 insertions(+), 288 deletions(-) create mode 100644 apps/docs/guides/double-opt-in.mdx create mode 100644 apps/web/prisma/migrations/20260208000100_add_double_opt_in_to_contact_book/migration.sql create mode 100644 apps/web/prisma/migrations/20260228100000_add_double_opt_in_from_to_contact_book/migration.sql create mode 100644 apps/web/src/app/(dashboard)/contacts/[contactBookId]/double-opt-in/page.tsx create mode 100644 apps/web/src/app/(dashboard)/contacts/[contactBookId]/resend-double-opt-in-confirmation.tsx create mode 100644 apps/web/src/app/subscribe/page.tsx create mode 100644 apps/web/src/lib/constants/double-opt-in.ts create mode 100644 apps/web/src/lib/constants/double-opt-in.unit.test.ts create mode 100644 apps/web/src/server/public-api/api/contacts/create-contact-book.api.test.ts create mode 100644 apps/web/src/server/public-api/api/contacts/update-contact-book.api.test.ts create mode 100644 apps/web/src/server/service/contact-book-service.unit.test.ts create mode 100644 apps/web/src/server/service/contact-service.unit.test.ts create mode 100644 apps/web/src/server/service/double-opt-in-service.ts create mode 100644 apps/web/src/server/service/double-opt-in-service.unit.test.ts diff --git a/AGENTS.md b/AGENTS.md index 3394a29..ab2aaa0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,17 +15,12 @@ - `pnpm dev`: Turbo dev for all relevant apps (loads `.env`). - `pnpm start:web:local`: Run only `apps/web` locally on port 3000. - `pnpm build`: Turbo build across the monorepo. -- `pnpm lint`: Run ESLint via shared config; fail on warnings. -- `pnpm format`: Prettier over ts/tsx/md. - `pnpm dx` / `pnpm dx:up` / `pnpm dx:down`: Spin up/down local infra via Docker Compose, then run migrations. - Database (apps/web filter): `pnpm db:generate` | `db:migrate-dev` | `db:push` | `db:studio`. - Never run migrations unless users explicitly asked ## Coding Style & Naming Conventions -- TypeScript-first; 2-space indent; semicolons enabled by Prettier. -- Linting: `@usesend/eslint-config`; run `pnpm lint` before PRs. -- Formatting: Prettier 3; run `pnpm format`. - Files: React components PascalCase (e.g., `AppSideBar.tsx`); folders kebab/lowercase. - Paths (web): use alias `~/` for src imports (e.g., `import { x } from "~/utils/x"`). - NEVER USE DYNAMIC IMPORTS. ALWAYS IMPORT ON THE TOP diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index ea7ec2e..61040e5 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -1095,6 +1095,28 @@ "description": "The emoji associated with the contact book", "example": "๐Ÿ“™" }, + "doubleOptInEnabled": { + "type": "boolean", + "description": "Whether double opt-in is enabled for new contacts", + "example": true + }, + "doubleOptInFrom": { + "type": "string", + "nullable": true, + "description": "From address used for double opt-in emails (must use a verified domain)", + "example": "Newsletter " + }, + "doubleOptInSubject": { + "type": "string", + "nullable": true, + "description": "Subject line used for double opt-in confirmation email", + "example": "Please confirm your subscription" + }, + "doubleOptInContent": { + "type": "string", + "nullable": true, + "description": "Email editor JSON content used for double opt-in confirmation" + }, "createdAt": { "type": "string", "description": "The creation timestamp" @@ -1142,6 +1164,23 @@ "properties": { "type": "object", "additionalProperties": { "type": "string" } + }, + "doubleOptInEnabled": { + "type": "boolean", + "description": "Whether double opt-in is enabled for new contacts" + }, + "doubleOptInFrom": { + "type": "string", + "nullable": true, + "description": "From address used for double opt-in emails (must use a verified domain)" + }, + "doubleOptInSubject": { + "type": "string", + "description": "Subject line used for double opt-in confirmation email" + }, + "doubleOptInContent": { + "type": "string", + "description": "Email editor JSON content used for double opt-in confirmation" } }, "required": ["name"] @@ -1165,6 +1204,10 @@ "additionalProperties": { "type": "string" } }, "emoji": { "type": "string" }, + "doubleOptInEnabled": { "type": "boolean", "description": "Whether double opt-in is enabled for new contacts" }, + "doubleOptInFrom": { "type": "string", "nullable": true, "description": "From address used for double opt-in emails" }, + "doubleOptInSubject": { "type": "string", "nullable": true, "description": "Subject line used for double opt-in confirmation email" }, + "doubleOptInContent": { "type": "string", "nullable": true, "description": "Email editor JSON content used for double opt-in confirmation" }, "createdAt": { "type": "string" }, "updatedAt": { "type": "string" } }, @@ -1210,6 +1253,10 @@ "additionalProperties": { "type": "string" } }, "emoji": { "type": "string" }, + "doubleOptInEnabled": { "type": "boolean", "description": "Whether double opt-in is enabled for new contacts" }, + "doubleOptInFrom": { "type": "string", "nullable": true, "description": "From address used for double opt-in emails" }, + "doubleOptInSubject": { "type": "string", "nullable": true, "description": "Subject line used for double opt-in confirmation email" }, + "doubleOptInContent": { "type": "string", "nullable": true, "description": "Email editor JSON content used for double opt-in confirmation" }, "createdAt": { "type": "string" }, "updatedAt": { "type": "string" }, "_count": { @@ -1279,6 +1326,23 @@ "properties": { "type": "object", "additionalProperties": { "type": "string" } + }, + "doubleOptInEnabled": { + "type": "boolean", + "description": "Whether double opt-in is enabled for new contacts" + }, + "doubleOptInFrom": { + "type": "string", + "nullable": true, + "description": "From address used for double opt-in emails (must use a verified domain)" + }, + "doubleOptInSubject": { + "type": "string", + "description": "Subject line used for double opt-in confirmation email" + }, + "doubleOptInContent": { + "type": "string", + "description": "Email editor JSON content used for double opt-in confirmation" } } } @@ -1301,6 +1365,10 @@ "additionalProperties": { "type": "string" } }, "emoji": { "type": "string" }, + "doubleOptInEnabled": { "type": "boolean", "description": "Whether double opt-in is enabled for new contacts" }, + "doubleOptInFrom": { "type": "string", "nullable": true, "description": "From address used for double opt-in emails" }, + "doubleOptInSubject": { "type": "string", "nullable": true, "description": "Subject line used for double opt-in confirmation email" }, + "doubleOptInContent": { "type": "string", "nullable": true, "description": "Email editor JSON content used for double opt-in confirmation" }, "createdAt": { "type": "string" }, "updatedAt": { "type": "string" } }, diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 0f15ef7..4a68fba 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -39,7 +39,7 @@ }, { "group": "Guides", - "pages": ["guides/webhooks", "guides/use-with-react-email"] + "pages": ["guides/webhooks", "guides/double-opt-in", "guides/use-with-react-email"] } ] }, diff --git a/apps/docs/guides/double-opt-in.mdx b/apps/docs/guides/double-opt-in.mdx new file mode 100644 index 0000000..1173eb7 --- /dev/null +++ b/apps/docs/guides/double-opt-in.mdx @@ -0,0 +1,171 @@ +--- +title: Double Opt-In +description: "Verify new subscribers with a confirmation email before adding them to your contact book" +--- + +## Overview + +Double opt-in requires new contacts to confirm their email address before they become subscribed. When enabled on a contact book, newly added contacts receive a confirmation email with a verification link. Only after clicking the link do they become fully subscribed. + +**Why use double opt-in?** + +- Ensures email addresses are valid and owned by the subscriber +- Reduces bounce rates and spam complaints +- Improves deliverability and sender reputation +- Helps comply with email marketing regulations (GDPR, CAN-SPAM) + +## How it works + + + + When a contact is added to a contact book with double opt-in enabled (via + dashboard, API, or CSV import), they are created with a **Pending** status + instead of being immediately subscribed. + + + A confirmation email is automatically sent to the contact with a unique + verification link. The link is signed with HMAC-SHA256 and expires after 7 + days. + + + The contact clicks the verification link in the email and confirms their + subscription on the confirmation page. + + + The contact's status changes from **Pending** to **Subscribed** and they + will now receive your emails. + + + +## Enabling double opt-in + +### Via the dashboard + +1. Go to [Contacts](https://app.usesend.com/contacts) and select a contact book +2. Click on the **Double Opt-In** tab +3. Toggle double opt-in on +4. Customize the confirmation email (optional) +5. Save your changes + +### Via the API + +Create a contact book with double opt-in enabled: + +```bash +curl -X POST https://app.usesend.com/api/v1/contactBooks \ + -H "Authorization: Bearer us_your_api_key" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Newsletter Subscribers", + "doubleOptInEnabled": true, + "doubleOptInFrom": "Newsletter ", + "doubleOptInSubject": "Please confirm your subscription" + }' +``` + +Or enable it on an existing contact book: + +```bash +curl -X PATCH https://app.usesend.com/api/v1/contactBooks/{contactBookId} \ + -H "Authorization: Bearer us_your_api_key" \ + -H "Content-Type: application/json" \ + -d '{ + "doubleOptInEnabled": true + }' +``` + +## Contact statuses + +When double opt-in is enabled, contacts have three possible statuses: + +| Status | Description | +| ---------------- | ---------------------------------------------------------------- | +| **Subscribed** | Contact has confirmed their subscription and will receive emails | +| **Pending** | Contact has been added but hasn't confirmed yet | +| **Unsubscribed** | Contact has explicitly unsubscribed | + + + Contacts with **Pending** status will not receive campaign emails. They will + only receive the double opt-in confirmation email. + + +## Customizing the confirmation email + +You can customize three aspects of the confirmation email: + +### From address + +Set a custom sender address for confirmation emails. The address must use one of your verified domains. + +```json +{ + "doubleOptInFrom": "Newsletter " +} +``` + +If not set, the confirmation email will be sent from the first available verified domain. + +### Subject line + +Customize the email subject. The default is "Please confirm your subscription". + +```json +{ + "doubleOptInSubject": "Confirm your subscription to our newsletter" +} +``` + +### Email template + +The confirmation email body can be customized using the useSend email editor (via the dashboard) or by providing the editor JSON content via the API. + +#### Template variables + +The following variables can be used in the confirmation email template: + +| Variable | Description | +| -------------------- | -------------------------------- | +| `{{doubleOptInUrl}}` | The confirmation link (required) | +| `{{email}}` | The contact's email address | +| `{{firstName}}` | The contact's first name | +| `{{lastName}}` | The contact's last name | + + + The `{{doubleOptInUrl}}` variable is **required** in the email template. The + confirmation email cannot be saved without it. This ensures every confirmation + email contains a working verification link. + + +## Resending confirmation emails + +If a contact hasn't confirmed their subscription, you can resend the confirmation email from the dashboard: + +1. Go to your contact book and find the pending contact +2. Click the **Resend** button next to the contact + +Each resend generates a new confirmation link with a fresh 7-day expiration window. + +## Best practices + + + + Double opt-in requires at least one verified domain to send confirmation + emails. Make sure to [verify your domain](https://app.usesend.com/domains) + before enabling double opt-in. + + + The confirmation email should be clear and concise. Include a prominent + confirmation button and a brief explanation of what the subscriber is + confirming. + + + Use a from address that your subscribers will recognize, such as + `newsletter@yourdomain.com` or `hello@yourdomain.com`. This reduces the + chance of the confirmation email being marked as spam. + + + Regularly check for contacts stuck in Pending status. If many contacts + aren't confirming, consider improving your confirmation email or resending + confirmations. + + diff --git a/apps/web/prisma/migrations/20260208000100_add_double_opt_in_to_contact_book/migration.sql b/apps/web/prisma/migrations/20260208000100_add_double_opt_in_to_contact_book/migration.sql new file mode 100644 index 0000000..7eb94e1 --- /dev/null +++ b/apps/web/prisma/migrations/20260208000100_add_double_opt_in_to_contact_book/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "ContactBook" +ADD COLUMN "doubleOptInEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "doubleOptInSubject" TEXT, +ADD COLUMN "doubleOptInContent" TEXT; + +-- Backfill legacy unsubscribed contacts so pending state can rely on NULL +UPDATE "Contact" +SET "unsubscribeReason" = 'UNSUBSCRIBED' +WHERE "subscribed" = false + AND "unsubscribeReason" IS NULL; diff --git a/apps/web/prisma/migrations/20260228100000_add_double_opt_in_from_to_contact_book/migration.sql b/apps/web/prisma/migrations/20260228100000_add_double_opt_in_from_to_contact_book/migration.sql new file mode 100644 index 0000000..3367b1f --- /dev/null +++ b/apps/web/prisma/migrations/20260228100000_add_double_opt_in_from_to_contact_book/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "ContactBook" +ADD COLUMN "doubleOptInFrom" TEXT; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 9ef292b..da4479f 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -293,15 +293,19 @@ model EmailEvent { } model ContactBook { - id String @id @default(cuid()) - name String - teamId Int - properties Json - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - emoji String @default("๐Ÿ“™") - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) - contacts Contact[] + id String @id @default(cuid()) + name String + teamId Int + properties Json + doubleOptInEnabled Boolean @default(false) + doubleOptInFrom String? + doubleOptInSubject String? + doubleOptInContent String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + emoji String @default("๐Ÿ“™") + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + contacts Contact[] @@index([teamId]) } diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx index 281e54a..f72064b 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx @@ -23,6 +23,7 @@ import { api } from "~/trpc/react"; import { getGravatarUrl } from "~/utils/gravatar-utils"; import DeleteContact from "./delete-contact"; import EditContact from "./edit-contact"; +import { ResendDoubleOptInConfirmation } from "./resend-double-opt-in-confirmation"; import { Input } from "@usesend/ui/src/input"; import { useDebouncedCallback } from "use-debounce"; import { @@ -70,9 +71,11 @@ function getUnsubscribeReason(reason: UnsubscribeReason) { export default function ContactList({ contactBookId, contactBookName, + doubleOptInEnabled, }: { contactBookId: string; contactBookName?: string; + doubleOptInEnabled?: boolean; }) { const [page, setPage] = useUrlState("page", "1"); const [status, setStatus] = useUrlState("status"); @@ -237,66 +240,84 @@ export default function ContactList({ ) : contactsQuery.data?.contacts.length ? ( - contactsQuery.data?.contacts.map((contact) => ( - - -
- {contact.email -
- - {contact.email} - - - {contact.firstName} {contact.lastName} - + contactsQuery.data?.contacts.map((contact) => { + const isPendingConfirmation = + Boolean(doubleOptInEnabled) && + !contact.subscribed && + !contact.unsubscribeReason; + + return ( + + +
+ {contact.email +
+ + {contact.email} + + + {contact.firstName} {contact.lastName} + +
-
- - - {contact.subscribed ? ( -
- Subscribed + + + {contact.subscribed ? ( +
+ Subscribed +
+ ) : isPendingConfirmation ? ( +
+ Pending +
+ ) : ( + + +
+ Unsubscribed +
+
+ +

+ {getUnsubscribeReason( + contact.unsubscribeReason ?? + UnsubscribeReason.UNSUBSCRIBED, + )} +

+
+
+ )} +
+ + {formatDistanceToNow(new Date(contact.createdAt), { + addSuffix: true, + })} + + +
+ {isPendingConfirmation ? ( + + ) : null} + +
- ) : ( - - -
- Unsubscribed -
-
- -

- {getUnsubscribeReason( - contact.unsubscribeReason ?? - UnsubscribeReason.UNSUBSCRIBED, - )} -

-
-
- )} -
- - {formatDistanceToNow(new Date(contact.createdAt), { - addSuffix: true, - })} - - -
- - -
-
- - )) + + + ); + }) ) : ( diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/double-opt-in/page.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/double-opt-in/page.tsx new file mode 100644 index 0000000..ff461de --- /dev/null +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/double-opt-in/page.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { Editor } from "@usesend/email-editor"; +import { Spinner } from "@usesend/ui/src/spinner"; +import { Input } from "@usesend/ui/src/input"; +import { toast } from "@usesend/ui/src/toaster"; +import { formatDistanceToNow } from "date-fns"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; +import { use, useRef, useState } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import { + DEFAULT_DOUBLE_OPT_IN_SUBJECT, + DOUBLE_OPT_IN_EDITOR_VARIABLES, + getDefaultDoubleOptInContent, + hasDoubleOptInUrlPlaceholder, +} from "~/lib/constants/double-opt-in"; +import { api } from "~/trpc/react"; + +const DOUBLE_OPT_IN_URL_REQUIRED_MESSAGE = + "Double opt-in email content must include {{doubleOptInUrl}}."; + +function parseEditorContent(content: string | null | undefined) { + if (!content) { + return getDefaultDoubleOptInContent(); + } + + try { + return JSON.parse(content) as Record; + } catch { + return getDefaultDoubleOptInContent(); + } +} + +export default function DoubleOptInEditorPage({ + params, +}: { + params: Promise<{ contactBookId: string }>; +}) { + const { contactBookId } = use(params); + + const { + data: contactBook, + isLoading, + error, + } = api.contacts.getContactBookDetails.useQuery({ + contactBookId, + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

Failed to load double opt-in settings

+
+ ); + } + + if (!contactBook) { + return
Contact book not found
; + } + + return ; +} + +function DoubleOptInEditor({ + contactBook, +}: { + contactBook: { + id: string; + name: string; + updatedAt: Date; + doubleOptInFrom: string | null; + doubleOptInSubject: string | null; + doubleOptInContent: string | null; + }; +}) { + const utils = api.useUtils(); + + const [json, setJson] = useState>( + parseEditorContent(contactBook.doubleOptInContent), + ); + const [subject, setSubject] = useState( + contactBook.doubleOptInSubject ?? DEFAULT_DOUBLE_OPT_IN_SUBJECT, + ); + const [from, setFrom] = useState(contactBook.doubleOptInFrom ?? ""); + const [isSaving, setIsSaving] = useState(false); + const hasShownMissingPlaceholderToast = useRef(false); + + const updateContactBook = api.contacts.updateContactBook.useMutation({ + onSuccess: async () => { + await utils.contacts.getContactBookDetails.invalidate({ + contactBookId: contactBook.id, + }); + setIsSaving(false); + }, + }); + + function updateContent(contentValue: string) { + updateContactBook.mutate( + { + contactBookId: contactBook.id, + doubleOptInContent: contentValue, + }, + { + onError: (error) => { + toast.error(error.message); + setIsSaving(false); + }, + }, + ); + } + + const debouncedUpdateContent = useDebouncedCallback(updateContent, 1000); + + return ( +
+
+
+
+ + + +
+
+ Double opt-in email +
+
{contactBook.name}
+
+
+ +
+ {isSaving ? ( +
+ ) : ( +
+ )} + {formatDistanceToNow(contactBook.updatedAt) === "less than a minute" + ? "just now" + : `${formatDistanceToNow(contactBook.updatedAt)} ago`} +
+
+ +
+
+ + { + setSubject(e.target.value); + }} + onBlur={() => { + const normalizedSubject = + subject.trim() || DEFAULT_DOUBLE_OPT_IN_SUBJECT; + const currentSubject = + contactBook.doubleOptInSubject ?? + DEFAULT_DOUBLE_OPT_IN_SUBJECT; + + if (normalizedSubject === currentSubject) { + return; + } + + setIsSaving(true); + updateContactBook.mutate( + { + contactBookId: contactBook.id, + doubleOptInSubject: normalizedSubject, + }, + { + onError: (error) => { + toast.error(error.message); + setIsSaving(false); + setSubject( + contactBook.doubleOptInSubject ?? + DEFAULT_DOUBLE_OPT_IN_SUBJECT, + ); + }, + }, + ); + }} + className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent" + /> +
+
+ + { + setFrom(e.target.value); + }} + onBlur={() => { + const normalizedFrom = from.trim(); + const currentFrom = contactBook.doubleOptInFrom ?? ""; + + if (normalizedFrom === currentFrom) { + return; + } + + setIsSaving(true); + updateContactBook.mutate( + { + contactBookId: contactBook.id, + doubleOptInFrom: normalizedFrom || null, + }, + { + onError: (error) => { + toast.error(error.message); + setIsSaving(false); + setFrom(contactBook.doubleOptInFrom ?? ""); + }, + }, + ); + }} + placeholder="Friendly name" + className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent" + /> +
+

+ Use the variable {"{{doubleOptInUrl}}"} for the + confirmation link. +

+
+ +
+
+ { + const nextContent = content.getJSON(); + const serializedContent = JSON.stringify(nextContent); + + setJson(nextContent); + + if (!hasDoubleOptInUrlPlaceholder(serializedContent)) { + debouncedUpdateContent.cancel(); + setIsSaving(false); + + if (!hasShownMissingPlaceholderToast.current) { + toast.error(DOUBLE_OPT_IN_URL_REQUIRED_MESSAGE); + hasShownMissingPlaceholderToast.current = true; + } + + return; + } + + hasShownMissingPlaceholderToast.current = false; + setIsSaving(true); + debouncedUpdateContent(serializedContent); + }} + variables={DOUBLE_OPT_IN_EDITOR_VARIABLES} + /> +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx index 57135b4..7ea9f5e 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx @@ -13,7 +13,6 @@ import Link from "next/link"; import AddContact from "./add-contact"; import BulkUploadContacts from "./bulk-upload-contacts"; import ContactList from "./contact-list"; -import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy"; import { formatDistanceToNow } from "date-fns"; import EmojiPicker, { Theme } from "emoji-picker-react"; import { @@ -22,8 +21,21 @@ import { PopoverTrigger, } from "@usesend/ui/src/popover"; import { Button } from "@usesend/ui/src/button"; +import { Switch } from "@usesend/ui/src/switch"; import { useTheme } from "@usesend/ui"; import { use } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@usesend/ui/src/card"; +import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy"; +import { + Users, + MailX, + Clock, + Hash, + Calendar, + Megaphone, + Shield, + ChevronRight, +} from "lucide-react"; export default function ContactsPage({ params, @@ -125,84 +137,199 @@ export default function ContactsPage({
-
-
-
-

Metrics

-
-
- Total Contacts + +
+
+ {/* Metrics Card */} + + +
+
+ +
+ Metrics
-
- {contactBookDetailQuery.data?.totalContacts !== undefined - ? contactBookDetailQuery.data?.totalContacts - : "--"} + + +
+ + + Total Contacts + + + {contactBookDetailQuery.data?.totalContacts !== undefined + ? contactBookDetailQuery.data?.totalContacts.toLocaleString() + : "--"} +
-
-
-
- Unsubscribed +
+ + + Unsubscribed + + + {contactBookDetailQuery.data?.unsubscribedContacts !== + undefined + ? contactBookDetailQuery.data?.unsubscribedContacts.toLocaleString() + : "--"} +
-
- {contactBookDetailQuery.data?.unsubscribedContacts !== undefined - ? contactBookDetailQuery.data?.unsubscribedContacts - : "--"} + + + + {/* Details Card */} + + +
+
+ +
+ Details
-
-
-
-

Details

-
-
- Contact book ID + + +
+

Contact book ID

+
- -
-
-
- Created at +
+

+ + Created +

+

+ {contactBookDetailQuery.data?.createdAt + ? formatDistanceToNow( + contactBookDetailQuery.data.createdAt, + { + addSuffix: true, + }, + ) + : "--"} +

-
- {contactBookDetailQuery.data?.createdAt - ? formatDistanceToNow(contactBookDetailQuery.data.createdAt, { - addSuffix: true, - }) - : "--"} + + + + {/* Recent Campaigns Card */} + + +
+
+ +
+ + Recent Campaigns +
-
-
-
-

Recent campaigns

- {!contactBookDetailQuery.isLoading && - contactBookDetailQuery.data?.campaigns.length === 0 ? ( -
- No campaigns yet. -
- ) : null} - {contactBookDetailQuery.data?.campaigns.map((campaign) => ( -
- -
- {campaign.name} -
- -
- {formatDistanceToNow(campaign.createdAt, {})} + + + {!contactBookDetailQuery.isLoading && + contactBookDetailQuery.data?.campaigns.length === 0 ? ( +
+ No campaigns yet. +
+ ) : ( +
+ {contactBookDetailQuery.data?.campaigns + .slice(0, 5) + .map((campaign) => ( + +
+ + + {campaign.name} + +
+
+ + + {formatDistanceToNow(campaign.createdAt, { + addSuffix: true, + })} + + +
+ + ))} + {(contactBookDetailQuery.data?.campaigns.length || 0) > 5 && ( + + View all campaigns + + )} +
+ )} +
+ +
+ + {/* Double Opt-in Section */} + + +
+
+
+ +
+
+ + Double Opt-in + +

+ Require email confirmation for new contacts +

- ))} -
-
-
- -
+ { + updateContactBookMutation.mutate({ + contactBookId, + doubleOptInEnabled: checked, + }); + }} + className="data-[state=checked]:bg-green-500" + /> +
+ + +
+

+ {contactBookDetailQuery.data?.doubleOptInEnabled + ? "New contacts will receive a confirmation email before being added to this list." + : "New contacts will be immediately added to this list without confirmation."} +

+ +
+
+ +
+ +
+
); diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/resend-double-opt-in-confirmation.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/resend-double-opt-in-confirmation.tsx new file mode 100644 index 0000000..1c4b281 --- /dev/null +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/resend-double-opt-in-confirmation.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { Button } from "@usesend/ui/src/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@usesend/ui/src/dialog"; +import Spinner from "@usesend/ui/src/spinner"; +import { toast } from "@usesend/ui/src/toaster"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@usesend/ui/src/tooltip"; +import { Send } from "lucide-react"; +import { useState } from "react"; +import { api } from "~/trpc/react"; + +export function ResendDoubleOptInConfirmation({ + contactBookId, + contactId, + email, +}: { + contactBookId: string; + contactId: string; + email: string; +}) { + const [open, setOpen] = useState(false); + const utils = api.useUtils(); + const resendMutation = + api.contacts.resendDoubleOptInConfirmation.useMutation(); + + return ( + <> + + + + + +

Resend confirmation email

+
+
+ + + + + Resend Confirmation Email + + Send a new double opt-in confirmation email to{" "} + {email}? + + + + + + + + + + ); +} diff --git a/apps/web/src/app/subscribe/page.tsx b/apps/web/src/app/subscribe/page.tsx new file mode 100644 index 0000000..442446f --- /dev/null +++ b/apps/web/src/app/subscribe/page.tsx @@ -0,0 +1,205 @@ +import { confirmDoubleOptInSubscription } from "~/server/service/double-opt-in-service"; +import { redirect } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +const PUBLIC_CONFIRMATION_ERRORS = new Set([ + "Invalid confirmation link", + "Confirmation link has expired", + "Contact not found", +]); + +function getConfirmationErrorMessage(error: unknown) { + if (error instanceof Error && PUBLIC_CONFIRMATION_ERRORS.has(error.message)) { + return error.message; + } + + return "Unable to confirm your subscription."; +} + +function buildSubscribeUrl({ + contactId, + expiresAt, + hash, + status, + error, +}: { + contactId?: string; + expiresAt?: string; + hash?: string; + status?: "success" | "error"; + error?: string; +}) { + const searchParams = new URLSearchParams(); + + if (contactId) searchParams.set("contactId", contactId); + if (expiresAt) searchParams.set("expiresAt", expiresAt); + if (hash) searchParams.set("hash", hash); + if (status) searchParams.set("status", status); + if (error) searchParams.set("error", error); + + const queryString = searchParams.toString(); + return queryString ? `/subscribe?${queryString}` : "/subscribe"; +} + +async function confirmSubscriptionAction(formData: FormData) { + "use server"; + + const contactId = formData.get("contactId"); + const expiresAt = formData.get("expiresAt"); + const hash = formData.get("hash"); + + if ( + typeof contactId !== "string" || + typeof expiresAt !== "string" || + typeof hash !== "string" + ) { + redirect( + buildSubscribeUrl({ + status: "error", + error: "Invalid confirmation link", + }), + ); + } + + let redirectUrl: string; + + try { + await confirmDoubleOptInSubscription({ + contactId, + expiresAt, + hash, + }); + + redirectUrl = buildSubscribeUrl({ + status: "success", + }); + } catch (error) { + redirectUrl = buildSubscribeUrl({ + contactId, + expiresAt, + hash, + status: "error", + error: getConfirmationErrorMessage(error), + }); + } + + redirect(redirectUrl); +} + +export default async function SubscribePage({ + searchParams, +}: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const getSingleValue = (value: string | string[] | undefined) => + Array.isArray(value) ? value[0] : value; + + const params = await searchParams; + const contactId = getSingleValue(params.contactId); + const expiresAt = getSingleValue(params.expiresAt); + const hash = getSingleValue(params.hash); + const status = getSingleValue(params.status); + const error = getSingleValue(params.error); + + const expiresAtTimestamp = Number(expiresAt); + const hasValidExpiry = Number.isFinite(expiresAtTimestamp); + const isExpired = hasValidExpiry && Date.now() > expiresAtTimestamp; + const normalizedError = + status === "error" + ? getConfirmationErrorMessage(error ? new Error(error) : null) + : null; + const isFatalError = + normalizedError === "Invalid confirmation link" || + normalizedError === "Confirmation link has expired" || + normalizedError === "Contact not found"; + + if (status === "success") { + return ( +
+
+

+ Subscription Confirmed +

+

+ Your subscription is confirmed and you will receive future emails. +

+
+
+ ); + } + + if (status === "error" && (!contactId || !expiresAt || !hash)) { + return ( +
+
+

+ Confirmation Failed +

+

+ {normalizedError ?? "Unable to confirm your subscription."} +

+
+
+ ); + } + + if (!contactId || !expiresAt || !hash || !hasValidExpiry) { + return ( +
+
+

Invalid Link

+

+ This confirmation link is invalid. Please request a new one. +

+
+
+ ); + } + + if (isExpired) { + return ( +
+
+

+ Confirmation Failed +

+

+ Confirmation link has expired +

+
+
+ ); + } + + return ( +
+
+

+ Confirm Subscription +

+

+ Click the button below to confirm your subscription. +

+ + {normalizedError ? ( +

{normalizedError}

+ ) : null} + + {!isFatalError ? ( +
+ + + + +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/lib/constants/double-opt-in.ts b/apps/web/src/lib/constants/double-opt-in.ts new file mode 100644 index 0000000..b82aa43 --- /dev/null +++ b/apps/web/src/lib/constants/double-opt-in.ts @@ -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 + >; +} diff --git a/apps/web/src/lib/constants/double-opt-in.unit.test.ts b/apps/web/src/lib/constants/double-opt-in.unit.test.ts new file mode 100644 index 0000000..1345fa5 --- /dev/null +++ b/apps/web/src/lib/constants/double-opt-in.unit.test.ts @@ -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( + '

Click confirm

', + ), + ).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); + }); +}); diff --git a/apps/web/src/lib/zod/contact-book-schema.ts b/apps/web/src/lib/zod/contact-book-schema.ts index d5e0c0c..ee9f20f 100644 --- a/apps/web/src/lib/zod/contact-book-schema.ts +++ b/apps/web/src/lib/zod/contact-book-schema.ts @@ -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 ", + }), + 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(), }); diff --git a/apps/web/src/server/api/routers/contacts.ts b/apps/web/src/server/api/routers/contacts.ts index 30baa34..3390389 100644 --- a/apps/web/src/server/api/routers/contacts.ts +++ b/apps/web/src/server/api/routers/contacts.ts @@ -49,6 +49,10 @@ export const contactsRouter = createTRPCRouter({ name: z.string().optional(), properties: z.record(z.string()).optional(), emoji: z.string().optional(), + doubleOptInEnabled: z.boolean().optional(), + doubleOptInFrom: z.string().nullable().optional(), + doubleOptInSubject: z.string().optional(), + doubleOptInContent: z.string().optional(), }), ) .mutation(async ({ ctx: { contactBook }, input }) => { @@ -190,6 +194,45 @@ export const contactsRouter = createTRPCRouter({ return deletedContact; }), + resendDoubleOptInConfirmation: contactBookProcedure + .input(z.object({ contactId: z.string() })) + .mutation(async ({ ctx: { contactBook, team }, input }) => { + try { + const contact = + await contactService.resendDoubleOptInConfirmationInContactBook( + input.contactId, + contactBook.id, + team.id, + ); + + if (!contact) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Contact not found", + }); + } + + return { success: true }; + } catch (error) { + if (error instanceof TRPCError) { + throw error; + } + + if ( + error instanceof Error && + error.message === + "Double opt-in confirmation can only be resent to pending contacts" + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error.message, + }); + } + + throw error; + } + }), + exportContacts: contactBookProcedure .input( z.object({ diff --git a/apps/web/src/server/public-api/api/contacts/create-contact-book.api.test.ts b/apps/web/src/server/public-api/api/contacts/create-contact-book.api.test.ts new file mode 100644 index 0000000..27b3f54 --- /dev/null +++ b/apps/web/src/server/public-api/api/contacts/create-contact-book.api.test.ts @@ -0,0 +1,324 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UnsendApiError } from "~/server/public-api/api-error"; + +const { + mockGetTeamFromToken, + mockRedis, + mockDb, + mockCreateContactBook, + mockUpdateContactBook, + mockTransactionClient, +} = vi.hoisted(() => ({ + mockGetTeamFromToken: vi.fn(), + mockRedis: { + incr: vi.fn(), + expire: vi.fn(), + ttl: vi.fn(), + }, + mockDb: { + $transaction: vi.fn(), + }, + mockCreateContactBook: vi.fn(), + mockUpdateContactBook: vi.fn(), + mockTransactionClient: { + contactBook: {}, + }, +})); + +vi.mock("~/server/public-api/auth", () => ({ + getTeamFromToken: mockGetTeamFromToken, +})); + +vi.mock("~/server/redis", () => ({ + getRedis: () => mockRedis, +})); + +vi.mock("~/server/db", () => ({ + db: mockDb, +})); + +vi.mock("~/server/service/contact-book-service", () => ({ + createContactBook: mockCreateContactBook, + updateContactBook: mockUpdateContactBook, +})); + +vi.mock("~/utils/common", () => ({ + isSelfHosted: () => false, +})); + +import { getApp } from "~/server/public-api/hono"; +import createContactBookRoute from "~/server/public-api/api/contacts/create-contact-book"; + +function buildContactBook(overrides?: Record) { + return { + id: "cb_1", + name: "Newsletter", + teamId: 1, + properties: {}, + emoji: "๐Ÿ“™", + doubleOptInEnabled: true, + doubleOptInFrom: null, + doubleOptInSubject: "Please confirm your subscription", + doubleOptInContent: '{"type":"doc"}', + createdAt: new Date("2026-03-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + ...overrides, + }; +} + +describe("POST /v1/contactBooks", () => { + beforeEach(() => { + mockGetTeamFromToken.mockReset(); + mockRedis.incr.mockReset(); + mockRedis.expire.mockReset(); + mockRedis.ttl.mockReset(); + mockDb.$transaction.mockReset(); + mockCreateContactBook.mockReset(); + mockUpdateContactBook.mockReset(); + + mockGetTeamFromToken.mockResolvedValue({ + id: 1, + apiRateLimit: 20, + apiKeyId: 11, + apiKey: { domainId: null }, + }); + + mockRedis.incr.mockResolvedValue(1); + mockRedis.expire.mockResolvedValue(1); + mockRedis.ttl.mockResolvedValue(1); + + mockDb.$transaction.mockImplementation(async (callback: any) => + callback(mockTransactionClient), + ); + }); + + it("creates a contact book with only the required name", async () => { + const created = buildContactBook(); + mockCreateContactBook.mockResolvedValue(created); + + const app = getApp(); + createContactBookRoute(app); + + const response = await app.request("http://localhost/api/v1/contactBooks", { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Newsletter", + }), + }); + + expect(response.status).toBe(200); + expect(mockDb.$transaction).toHaveBeenCalledTimes(1); + expect(mockCreateContactBook).toHaveBeenCalledWith( + 1, + "Newsletter", + mockTransactionClient, + ); + expect(mockUpdateContactBook).not.toHaveBeenCalled(); + + const body = await response.json(); + expect(body).toMatchObject({ + id: "cb_1", + name: "Newsletter", + properties: {}, + teamId: 1, + }); + }); + + it("applies optional fields via update inside the same transaction", async () => { + const created = buildContactBook({ id: "cb_2", name: "Product Updates" }); + const updated = buildContactBook({ + id: "cb_2", + name: "Product Updates", + emoji: "๐Ÿ“ฌ", + properties: { tier: "gold" }, + doubleOptInEnabled: false, + doubleOptInFrom: "Marketing ", + doubleOptInSubject: "Confirm your subscription", + doubleOptInContent: '{"type":"doc","content":[]}', + }); + + mockCreateContactBook.mockResolvedValue(created); + mockUpdateContactBook.mockResolvedValue(updated); + + const app = getApp(); + createContactBookRoute(app); + + const response = await app.request("http://localhost/api/v1/contactBooks", { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Product Updates", + emoji: "๐Ÿ“ฌ", + properties: { tier: "gold" }, + doubleOptInEnabled: false, + doubleOptInFrom: "Marketing ", + doubleOptInSubject: "Confirm your subscription", + doubleOptInContent: '{"type":"doc","content":[]}', + }), + }); + + expect(response.status).toBe(200); + expect(mockCreateContactBook).toHaveBeenCalledWith( + 1, + "Product Updates", + mockTransactionClient, + ); + expect(mockUpdateContactBook).toHaveBeenCalledWith( + "cb_2", + { + emoji: "๐Ÿ“ฌ", + properties: { tier: "gold" }, + doubleOptInEnabled: false, + doubleOptInFrom: "Marketing ", + doubleOptInSubject: "Confirm your subscription", + doubleOptInContent: '{"type":"doc","content":[]}', + }, + mockTransactionClient, + ); + + const body = await response.json(); + expect(body).toMatchObject({ + id: "cb_2", + doubleOptInEnabled: false, + doubleOptInFrom: "Marketing ", + properties: { tier: "gold" }, + }); + }); + + it("treats null doubleOptInFrom as an explicit optional update", async () => { + const created = buildContactBook({ id: "cb_3" }); + const updated = buildContactBook({ id: "cb_3", doubleOptInFrom: null }); + mockCreateContactBook.mockResolvedValue(created); + mockUpdateContactBook.mockResolvedValue(updated); + + const app = getApp(); + createContactBookRoute(app); + + const response = await app.request("http://localhost/api/v1/contactBooks", { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Announcements", + doubleOptInFrom: null, + }), + }); + + expect(response.status).toBe(200); + expect(mockUpdateContactBook).toHaveBeenCalledWith( + "cb_3", + expect.objectContaining({ + doubleOptInFrom: null, + }), + mockTransactionClient, + ); + }); + + it("returns BAD_REQUEST when name is missing", async () => { + const app = getApp(); + createContactBookRoute(app); + + const response = await app.request("http://localhost/api/v1/contactBooks", { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + emoji: "๐Ÿ“ฌ", + }), + }); + + expect(response.status).toBe(400); + expect(mockCreateContactBook).not.toHaveBeenCalled(); + + const body = await response.json(); + expect(body).toMatchObject({ + error: { + name: "ZodError", + issues: [ + expect.objectContaining({ + path: ["name"], + }), + ], + }, + }); + }); + + it("returns BAD_REQUEST when name is empty", async () => { + const app = getApp(); + createContactBookRoute(app); + + const response = await app.request("http://localhost/api/v1/contactBooks", { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "", + }), + }); + + expect(response.status).toBe(400); + expect(mockCreateContactBook).not.toHaveBeenCalled(); + + const body = await response.json(); + expect(body).toMatchObject({ + error: { + name: "ZodError", + issues: [ + expect.objectContaining({ + path: ["name"], + }), + ], + }, + }); + }); + + it("returns service-level errors from optional field updates", async () => { + mockCreateContactBook.mockResolvedValue(buildContactBook({ id: "cb_4" })); + mockUpdateContactBook.mockRejectedValue( + new UnsendApiError({ + code: "BAD_REQUEST", + message: "doubleOptInFrom must use a verified domain", + }), + ); + + const app = getApp(); + createContactBookRoute(app); + + const response = await app.request("http://localhost/api/v1/contactBooks", { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Announcements", + doubleOptInFrom: "News ", + }), + }); + + expect(response.status).toBe(400); + expect(mockCreateContactBook).toHaveBeenCalledTimes(1); + expect(mockUpdateContactBook).toHaveBeenCalledTimes(1); + + const body = await response.json(); + expect(body).toMatchObject({ + error: { + code: "BAD_REQUEST", + message: "doubleOptInFrom must use a verified domain", + }, + }); + }); +}); diff --git a/apps/web/src/server/public-api/api/contacts/create-contact-book.ts b/apps/web/src/server/public-api/api/contacts/create-contact-book.ts index 9ac0a12..1733ab7 100644 --- a/apps/web/src/server/public-api/api/contacts/create-contact-book.ts +++ b/apps/web/src/server/public-api/api/contacts/create-contact-book.ts @@ -1,65 +1,84 @@ import { createRoute, z } from "@hono/zod-openapi"; import { ContactBookSchema } from "~/lib/zod/contact-book-schema"; +import { db } from "~/server/db"; import { PublicAPIApp } from "~/server/public-api/hono"; import { - createContactBook as createContactBookService, - updateContactBook, + createContactBook as createContactBookService, + updateContactBook, } from "~/server/service/contact-book-service"; const route = createRoute({ - method: "post", - path: "/v1/contactBooks", - request: { - body: { - required: true, - content: { - "application/json": { - schema: z.object({ - name: z.string().min(1), - emoji: z.string().optional(), - properties: z.record(z.string()).optional(), - }), - }, - }, - }, - }, - responses: { - 200: { - content: { - "application/json": { - schema: ContactBookSchema, - }, - }, - description: "Create a new contact book", - }, - }, + method: "post", + path: "/v1/contactBooks", + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + name: z.string().min(1), + 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: "Create a new contact book", + }, + }, }); function createContactBook(app: PublicAPIApp) { - app.openapi(route, async (c) => { - const team = c.var.team; - const body = c.req.valid("json"); + app.openapi(route, async (c) => { + const team = c.var.team; + const body = c.req.valid("json"); - const contactBook = await createContactBookService(team.id, body.name); + const hasOptionalFields = + body.emoji !== undefined || + body.properties !== undefined || + body.doubleOptInEnabled !== undefined || + body.doubleOptInFrom !== undefined || + body.doubleOptInSubject !== undefined || + body.doubleOptInContent !== undefined; - // Update emoji and properties if provided - if (body.emoji || body.properties) { - const updated = await updateContactBook(contactBook.id, { - emoji: body.emoji, - properties: body.properties, - }); + const contactBook = await db.$transaction(async (tx) => { + const created = await createContactBookService(team.id, body.name, tx); - return c.json({ - ...updated, - properties: updated.properties as Record, - }); - } + if (!hasOptionalFields) { + return created; + } - return c.json({ - ...contactBook, - properties: contactBook.properties as Record, - }); - }); + return updateContactBook( + created.id, + { + emoji: body.emoji, + properties: body.properties, + doubleOptInEnabled: body.doubleOptInEnabled, + doubleOptInFrom: body.doubleOptInFrom, + doubleOptInSubject: body.doubleOptInSubject, + doubleOptInContent: body.doubleOptInContent, + }, + tx, + ); + }); + + return c.json({ + ...contactBook, + properties: contactBook.properties as Record, + }); + }); } export default createContactBook; diff --git a/apps/web/src/server/public-api/api/contacts/update-contact-book.api.test.ts b/apps/web/src/server/public-api/api/contacts/update-contact-book.api.test.ts new file mode 100644 index 0000000..8bb66d8 --- /dev/null +++ b/apps/web/src/server/public-api/api/contacts/update-contact-book.api.test.ts @@ -0,0 +1,291 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UnsendApiError } from "~/server/public-api/api-error"; + +const { mockGetTeamFromToken, mockRedis, mockDb, mockUpdateContactBook } = + vi.hoisted(() => ({ + mockGetTeamFromToken: vi.fn(), + mockRedis: { + incr: vi.fn(), + expire: vi.fn(), + ttl: vi.fn(), + }, + mockDb: { + contactBook: { + findUnique: vi.fn(), + }, + }, + mockUpdateContactBook: vi.fn(), + })); + +vi.mock("~/server/public-api/auth", () => ({ + getTeamFromToken: mockGetTeamFromToken, +})); + +vi.mock("~/server/redis", () => ({ + getRedis: () => mockRedis, +})); + +vi.mock("~/server/db", () => ({ + db: mockDb, +})); + +vi.mock("~/server/service/contact-book-service", () => ({ + updateContactBook: mockUpdateContactBook, +})); + +vi.mock("~/utils/common", () => ({ + isSelfHosted: () => false, +})); + +import { getApp } from "~/server/public-api/hono"; +import updateContactBookRoute from "~/server/public-api/api/contacts/update-contact-book"; + +function buildContactBook(overrides?: Record) { + return { + id: "cb_1", + name: "Newsletter", + teamId: 1, + properties: {}, + emoji: "๐Ÿ“™", + doubleOptInEnabled: true, + doubleOptInFrom: null, + doubleOptInSubject: "Please confirm your subscription", + doubleOptInContent: '{"type":"doc"}', + createdAt: new Date("2026-03-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + ...overrides, + }; +} + +describe("PATCH /v1/contactBooks/{contactBookId}", () => { + beforeEach(() => { + mockGetTeamFromToken.mockReset(); + mockRedis.incr.mockReset(); + mockRedis.expire.mockReset(); + mockRedis.ttl.mockReset(); + mockDb.contactBook.findUnique.mockReset(); + mockUpdateContactBook.mockReset(); + + mockGetTeamFromToken.mockResolvedValue({ + id: 1, + apiRateLimit: 20, + apiKeyId: 11, + apiKey: { domainId: null }, + }); + + mockRedis.incr.mockResolvedValue(1); + mockRedis.expire.mockResolvedValue(1); + mockRedis.ttl.mockResolvedValue(1); + + mockDb.contactBook.findUnique.mockResolvedValue({ + id: "cb_1", + teamId: 1, + }); + }); + + it("updates contact book name", async () => { + mockUpdateContactBook.mockResolvedValue( + buildContactBook({ + name: "Leads", + }), + ); + + const app = getApp(); + updateContactBookRoute(app); + + const response = await app.request( + "http://localhost/api/v1/contactBooks/cb_1", + { + method: "PATCH", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Leads", + }), + }, + ); + + expect(response.status).toBe(200); + expect(mockDb.contactBook.findUnique).toHaveBeenCalledWith({ + where: { id: "cb_1", teamId: 1 }, + }); + expect(mockUpdateContactBook).toHaveBeenCalledWith("cb_1", { + name: "Leads", + }); + + const body = await response.json(); + expect(body).toMatchObject({ + id: "cb_1", + name: "Leads", + properties: {}, + }); + }); + + it("updates double opt-in optional fields", async () => { + mockUpdateContactBook.mockResolvedValue( + buildContactBook({ + doubleOptInEnabled: false, + doubleOptInFrom: "Marketing ", + doubleOptInSubject: "Confirm your subscription", + doubleOptInContent: '{"type":"doc","content":[]}', + }), + ); + + const app = getApp(); + updateContactBookRoute(app); + + const response = await app.request( + "http://localhost/api/v1/contactBooks/cb_1", + { + method: "PATCH", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + doubleOptInEnabled: false, + doubleOptInFrom: "Marketing ", + doubleOptInSubject: "Confirm your subscription", + doubleOptInContent: '{"type":"doc","content":[]}', + }), + }, + ); + + expect(response.status).toBe(200); + expect(mockUpdateContactBook).toHaveBeenCalledWith("cb_1", { + doubleOptInEnabled: false, + doubleOptInFrom: "Marketing ", + doubleOptInSubject: "Confirm your subscription", + doubleOptInContent: '{"type":"doc","content":[]}', + }); + }); + + it("allows empty JSON body and forwards no-op update", async () => { + mockUpdateContactBook.mockResolvedValue(buildContactBook()); + + const app = getApp(); + updateContactBookRoute(app); + + const response = await app.request( + "http://localhost/api/v1/contactBooks/cb_1", + { + method: "PATCH", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }, + ); + + expect(response.status).toBe(200); + expect(mockUpdateContactBook).toHaveBeenCalledWith("cb_1", {}); + }); + + it("returns NOT_FOUND when contact book is outside team scope", async () => { + mockDb.contactBook.findUnique.mockResolvedValue(null); + + const app = getApp(); + updateContactBookRoute(app); + + const response = await app.request( + "http://localhost/api/v1/contactBooks/cb_not_mine", + { + method: "PATCH", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Should fail", + }), + }, + ); + + expect(response.status).toBe(404); + expect(mockUpdateContactBook).not.toHaveBeenCalled(); + + const body = await response.json(); + expect(body).toMatchObject({ + error: { + code: "NOT_FOUND", + message: "Contact book not found for this team", + }, + }); + }); + + it("returns BAD_REQUEST when name is empty", async () => { + const app = getApp(); + updateContactBookRoute(app); + + const response = await app.request( + "http://localhost/api/v1/contactBooks/cb_1", + { + method: "PATCH", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "", + }), + }, + ); + + expect(response.status).toBe(400); + expect(mockUpdateContactBook).not.toHaveBeenCalled(); + + const body = await response.json(); + expect(body).toMatchObject({ + error: { + name: "ZodError", + issues: [ + expect.objectContaining({ + path: ["name"], + }), + ], + }, + }); + }); + + it("returns service errors from update", async () => { + mockUpdateContactBook.mockRejectedValue( + new UnsendApiError({ + code: "BAD_REQUEST", + message: + "Double opt-in email content must include the {{doubleOptInUrl}} placeholder", + }), + ); + + const app = getApp(); + updateContactBookRoute(app); + + const response = await app.request( + "http://localhost/api/v1/contactBooks/cb_1", + { + method: "PATCH", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + doubleOptInContent: '{"type":"doc","content":[]}', + }), + }, + ); + + expect(response.status).toBe(400); + expect(mockUpdateContactBook).toHaveBeenCalledTimes(1); + + const body = await response.json(); + expect(body).toMatchObject({ + error: { + code: "BAD_REQUEST", + message: + "Double opt-in email content must include the {{doubleOptInUrl}} placeholder", + }, + }); + }); +}); diff --git a/apps/web/src/server/public-api/api/contacts/update-contact-book.ts b/apps/web/src/server/public-api/api/contacts/update-contact-book.ts index 0adc284..4a77757 100644 --- a/apps/web/src/server/public-api/api/contacts/update-contact-book.ts +++ b/apps/web/src/server/public-api/api/contacts/update-contact-book.ts @@ -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, - }); - }); + return c.json({ + ...updated, + properties: updated.properties as Record, + }); + }); } export default updateContactBook; diff --git a/apps/web/src/server/service/contact-book-service.ts b/apps/web/src/server/service/contact-book-service.ts index 2e7043e..7b7a8c9 100644 --- a/apps/web/src/server/service/contact-book-service.ts +++ b/apps/web/src/server/service/contact-book-service.ts @@ -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; 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; 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, }); } diff --git a/apps/web/src/server/service/contact-book-service.unit.test.ts b/apps/web/src/server/service/contact-book-service.unit.test.ts new file mode 100644 index 0000000..5997127 --- /dev/null +++ b/apps/web/src/server/service/contact-book-service.unit.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + DEFAULT_DOUBLE_OPT_IN_CONTENT, + DEFAULT_DOUBLE_OPT_IN_SUBJECT, +} from "~/lib/constants/double-opt-in"; + +const { mockDb, mockCheckContactBookLimit, mockValidateDomainFromEmail } = + vi.hoisted(() => ({ + mockDb: { + contactBook: { + create: vi.fn(), + update: vi.fn(), + findUnique: vi.fn(), + findMany: vi.fn(), + delete: vi.fn(), + }, + contact: { + count: vi.fn(), + }, + campaign: { + findMany: vi.fn(), + }, + }, + mockCheckContactBookLimit: vi.fn(), + mockValidateDomainFromEmail: vi.fn(), + })); + +vi.mock("~/server/db", () => ({ + db: mockDb, +})); + +vi.mock("~/server/service/limit-service", () => ({ + LimitService: { + checkContactBookLimit: mockCheckContactBookLimit, + }, +})); + +vi.mock("~/server/service/domain-service", () => ({ + validateDomainFromEmail: mockValidateDomainFromEmail, +})); + +import { + createContactBook, + getContactBooks, + updateContactBook, +} from "~/server/service/contact-book-service"; + +describe("contact-book-service", () => { + beforeEach(() => { + mockCheckContactBookLimit.mockReset(); + mockDb.contactBook.create.mockReset(); + mockDb.contactBook.findMany.mockReset(); + mockDb.contactBook.update.mockReset(); + mockDb.contactBook.findUnique.mockReset(); + mockValidateDomainFromEmail.mockReset(); + }); + + it("returns double opt-in content in contact book listings", async () => { + mockDb.contactBook.findMany.mockResolvedValue([]); + + await getContactBooks(12); + + expect(mockDb.contactBook.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: expect.objectContaining({ + doubleOptInContent: true, + doubleOptInFrom: true, + }), + }), + ); + }); + + it("creates contact books with double opt-in defaults", async () => { + mockCheckContactBookLimit.mockResolvedValue({ + isLimitReached: false, + reason: null, + }); + mockDb.contactBook.create.mockResolvedValue({ id: "book_1" }); + + await createContactBook(12, "Newsletter"); + + expect(mockDb.contactBook.create).toHaveBeenCalledWith({ + data: { + name: "Newsletter", + teamId: 12, + properties: {}, + doubleOptInEnabled: true, + doubleOptInSubject: DEFAULT_DOUBLE_OPT_IN_SUBJECT, + doubleOptInContent: DEFAULT_DOUBLE_OPT_IN_CONTENT, + }, + }); + }); + + it("throws when the contact book limit is reached", async () => { + mockCheckContactBookLimit.mockResolvedValue({ + isLimitReached: true, + reason: "limit reached", + }); + + await expect(createContactBook(12, "Newsletter")).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "limit reached", + }); + expect(mockDb.contactBook.create).not.toHaveBeenCalled(); + }); + + it("normalizes empty double opt-in content to defaults", async () => { + mockDb.contactBook.update.mockResolvedValue({ id: "book_1" }); + + await updateContactBook("book_1", { + doubleOptInContent: " ", + }); + + expect(mockDb.contactBook.update).toHaveBeenCalledWith({ + where: { id: "book_1" }, + data: { + doubleOptInContent: DEFAULT_DOUBLE_OPT_IN_CONTENT, + }, + }); + }); + + it("rejects double opt-in content without confirmation placeholder", async () => { + await expect( + updateContactBook("book_1", { + doubleOptInContent: JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Missing link" }], + }, + ], + }), + }), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: + "Double opt-in email content must include the {{doubleOptInUrl}} placeholder", + }); + + expect(mockDb.contactBook.update).not.toHaveBeenCalled(); + }); + + it("backfills default subject and content when enabling double opt-in", async () => { + mockDb.contactBook.findUnique.mockResolvedValue({ + doubleOptInSubject: null, + doubleOptInContent: null, + }); + mockDb.contactBook.update.mockResolvedValue({ id: "book_1" }); + + await updateContactBook("book_1", { + doubleOptInEnabled: true, + }); + + expect(mockDb.contactBook.update).toHaveBeenCalledWith({ + where: { id: "book_1" }, + data: { + doubleOptInEnabled: true, + doubleOptInSubject: DEFAULT_DOUBLE_OPT_IN_SUBJECT, + doubleOptInContent: DEFAULT_DOUBLE_OPT_IN_CONTENT, + }, + }); + }); + + it("validates and stores a configured double opt-in from address", async () => { + mockDb.contactBook.findUnique.mockResolvedValue({ + teamId: 12, + }); + mockValidateDomainFromEmail.mockResolvedValue({ + id: 1, + name: "example.com", + }); + mockDb.contactBook.update.mockResolvedValue({ id: "book_1" }); + + await updateContactBook("book_1", { + doubleOptInFrom: " Newsletter ", + }); + + expect(mockValidateDomainFromEmail).toHaveBeenCalledWith( + "Newsletter ", + 12, + ); + expect(mockDb.contactBook.update).toHaveBeenCalledWith({ + where: { id: "book_1" }, + data: { + doubleOptInFrom: "Newsletter ", + }, + }); + }); + + it("clears configured double opt-in from when empty", async () => { + mockDb.contactBook.update.mockResolvedValue({ id: "book_1" }); + + await updateContactBook("book_1", { + doubleOptInFrom: " ", + }); + + expect(mockValidateDomainFromEmail).not.toHaveBeenCalled(); + expect(mockDb.contactBook.update).toHaveBeenCalledWith({ + where: { id: "book_1" }, + data: { + doubleOptInFrom: null, + }, + }); + }); +}); diff --git a/apps/web/src/server/service/contact-service.ts b/apps/web/src/server/service/contact-service.ts index f0772fe..a5b00d4 100644 --- a/apps/web/src/server/service/contact-service.ts +++ b/apps/web/src/server/service/contact-service.ts @@ -1,4 +1,4 @@ -import { type Contact } from "@prisma/client"; +import { type Contact, UnsubscribeReason } from "@prisma/client"; import { type ContactPayload, type ContactWebhookEventType, @@ -7,6 +7,7 @@ import { db } from "../db"; import { ContactQueueService } from "./contact-queue-service"; import { WebhookService } from "./webhook-service"; import { logger } from "../logger/log"; +import { sendDoubleOptInConfirmationEmail } from "./double-opt-in-service"; export type ContactInput = { email: string; @@ -21,6 +22,20 @@ export async function addOrUpdateContact( contact: ContactInput, teamId?: number, ) { + const contactBook = await db.contactBook.findUnique({ + where: { + id: contactBookId, + }, + select: { + doubleOptInEnabled: true, + teamId: true, + }, + }); + + if (!contactBook) { + throw new Error("Contact book not found"); + } + // Check if contact exists to handle subscribed logic const existingContact = await db.contact.findUnique({ where: { @@ -31,6 +46,7 @@ export async function addOrUpdateContact( }, select: { subscribed: true, + unsubscribeReason: true, }, }); @@ -45,6 +61,20 @@ export async function addOrUpdateContact( // All other cases (Yesโ†’No, Yesโ†’Yes, Noโ†’No) are allowed naturally } + const isExplicitUnsubscribeRequest = contact.subscribed === false; + + const shouldSendDoubleOptIn = + contactBook.doubleOptInEnabled && + !isExplicitUnsubscribeRequest && + (!existingContact || + (!existingContact.subscribed && + existingContact.unsubscribeReason === null)); + + const shouldCreatePendingContact = + contactBook.doubleOptInEnabled && + existingContact === null && + !isExplicitUnsubscribeRequest; + const savedContact = await db.contact.upsert({ where: { contactBookId_email: { @@ -58,16 +88,50 @@ export async function addOrUpdateContact( firstName: contact.firstName, lastName: contact.lastName, properties: contact.properties ?? {}, - subscribed: contact.subscribed ?? true, // Default to subscribed for new contacts + subscribed: shouldCreatePendingContact + ? false + : (contact.subscribed ?? true), + unsubscribeReason: shouldCreatePendingContact + ? null + : contact.subscribed === false + ? UnsubscribeReason.UNSUBSCRIBED + : null, }, update: { firstName: contact.firstName, lastName: contact.lastName, properties: contact.properties ?? {}, - ...(subscribedValue !== undefined ? { subscribed: subscribedValue } : {}), + ...(subscribedValue !== undefined + ? { + subscribed: subscribedValue, + unsubscribeReason: subscribedValue + ? null + : UnsubscribeReason.UNSUBSCRIBED, + } + : {}), }, }); + if (shouldSendDoubleOptIn) { + try { + await sendDoubleOptInConfirmationEmail({ + contactId: savedContact.id, + contactBookId, + teamId: teamId ?? contactBook.teamId, + }); + } catch (error) { + logger.error( + { + error, + contactId: savedContact.id, + contactBookId, + teamId: teamId ?? contactBook.teamId, + }, + "[ContactService]: Failed to send double opt-in confirmation email", + ); + } + } + const eventType: ContactWebhookEventType = existingContact ? "contact.updated" : "contact.created"; @@ -108,7 +172,16 @@ export async function updateContactInContactBook( where: { id: contactId, }, - data: contact, + data: { + ...contact, + ...(contact.subscribed !== undefined + ? { + unsubscribeReason: contact.subscribed + ? null + : UnsubscribeReason.UNSUBSCRIBED, + } + : {}), + }, }); await emitContactEvent(updatedContact, "contact.updated", teamId); @@ -141,6 +214,51 @@ export async function deleteContactInContactBook( return deletedContact; } +export async function resendDoubleOptInConfirmationInContactBook( + contactId: string, + contactBookId: string, + teamId?: number, +) { + const existingContact = await getContactInContactBook( + contactId, + contactBookId, + ); + + if (!existingContact) { + return null; + } + + const isPendingConfirmation = + !existingContact.subscribed && existingContact.unsubscribeReason === null; + + if (!isPendingConfirmation) { + throw new Error( + "Double opt-in confirmation can only be resent to pending contacts", + ); + } + + const resolvedTeamId = + teamId ?? + (await db.contactBook + .findUnique({ + where: { id: contactBookId }, + select: { teamId: true }, + }) + .then((contactBook) => contactBook?.teamId)); + + if (!resolvedTeamId) { + throw new Error("Team not found for contact book"); + } + + await sendDoubleOptInConfirmationEmail({ + contactId: existingContact.id, + contactBookId, + teamId: resolvedTeamId, + }); + + return existingContact; +} + export async function bulkAddContacts( contactBookId: string, contacts: Array, @@ -161,6 +279,7 @@ export async function unsubscribeContact(contactId: string) { }, data: { subscribed: false, + unsubscribeReason: UnsubscribeReason.UNSUBSCRIBED, }, }); } @@ -172,6 +291,7 @@ export async function subscribeContact(contactId: string) { }, data: { subscribed: true, + unsubscribeReason: null, }, }); } diff --git a/apps/web/src/server/service/contact-service.unit.test.ts b/apps/web/src/server/service/contact-service.unit.test.ts new file mode 100644 index 0000000..9ce9d41 --- /dev/null +++ b/apps/web/src/server/service/contact-service.unit.test.ts @@ -0,0 +1,328 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockDb, + mockWebhookEmit, + mockSendDoubleOptInConfirmationEmail, + mockAddBulkContactJobs, + mockLogger, +} = vi.hoisted(() => ({ + mockDb: { + contactBook: { + findUnique: vi.fn(), + }, + contact: { + findFirst: vi.fn(), + findUnique: vi.fn(), + upsert: vi.fn(), + }, + }, + mockWebhookEmit: vi.fn(), + mockSendDoubleOptInConfirmationEmail: vi.fn(), + mockAddBulkContactJobs: vi.fn(), + mockLogger: { + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("~/server/db", () => ({ + db: mockDb, +})); + +vi.mock("~/server/service/webhook-service", () => ({ + WebhookService: { + emit: mockWebhookEmit, + }, +})); + +vi.mock("~/server/service/double-opt-in-service", () => ({ + sendDoubleOptInConfirmationEmail: mockSendDoubleOptInConfirmationEmail, +})); + +vi.mock("~/server/service/contact-queue-service", () => ({ + ContactQueueService: { + addBulkContactJobs: mockAddBulkContactJobs, + }, +})); + +vi.mock("~/server/logger/log", () => ({ + logger: mockLogger, +})); + +import { + addOrUpdateContact, + resendDoubleOptInConfirmationInContactBook, +} from "~/server/service/contact-service"; + +const createdAt = new Date("2026-02-08T00:00:00.000Z"); + +describe("contact-service addOrUpdateContact", () => { + beforeEach(() => { + mockDb.contactBook.findUnique.mockReset(); + mockDb.contact.findFirst.mockReset(); + mockDb.contact.findUnique.mockReset(); + mockDb.contact.upsert.mockReset(); + mockWebhookEmit.mockReset(); + mockSendDoubleOptInConfirmationEmail.mockReset(); + mockLogger.warn.mockReset(); + mockLogger.error.mockReset(); + }); + + it("creates pending contacts and sends double opt-in confirmation", async () => { + mockDb.contactBook.findUnique.mockResolvedValue({ + doubleOptInEnabled: true, + teamId: 7, + }); + mockDb.contact.findUnique.mockResolvedValue(null); + mockDb.contact.upsert.mockResolvedValue({ + id: "contact_1", + email: "alice@example.com", + contactBookId: "book_1", + subscribed: false, + properties: {}, + firstName: "Alice", + lastName: "Smith", + createdAt, + updatedAt: createdAt, + }); + + await addOrUpdateContact("book_1", { email: "alice@example.com" }, 7); + + const upsertArgs = mockDb.contact.upsert.mock.calls[0]?.[0]; + expect(upsertArgs.create.subscribed).toBe(false); + expect(upsertArgs.create.unsubscribeReason).toBeNull(); + expect(mockSendDoubleOptInConfirmationEmail).toHaveBeenCalledWith({ + contactId: "contact_1", + contactBookId: "book_1", + teamId: 7, + }); + expect(mockWebhookEmit).toHaveBeenCalledWith( + 7, + "contact.created", + expect.objectContaining({ + id: "contact_1", + subscribed: false, + }), + ); + }); + + it("creates subscribed contacts immediately when double opt-in is disabled", async () => { + mockDb.contactBook.findUnique.mockResolvedValue({ + doubleOptInEnabled: false, + teamId: 7, + }); + mockDb.contact.findUnique.mockResolvedValue(null); + mockDb.contact.upsert.mockResolvedValue({ + id: "contact_1", + email: "alice@example.com", + contactBookId: "book_1", + subscribed: true, + properties: {}, + firstName: null, + lastName: null, + createdAt, + updatedAt: createdAt, + }); + + await addOrUpdateContact("book_1", { email: "alice@example.com" }, 7); + + const upsertArgs = mockDb.contact.upsert.mock.calls[0]?.[0]; + expect(upsertArgs.create.subscribed).toBe(true); + expect(upsertArgs.create.unsubscribeReason).toBeNull(); + expect(mockSendDoubleOptInConfirmationEmail).not.toHaveBeenCalled(); + }); + + it("stores unsubscribe reason when creating unsubscribed contacts", async () => { + mockDb.contactBook.findUnique.mockResolvedValue({ + doubleOptInEnabled: false, + teamId: 7, + }); + mockDb.contact.findUnique.mockResolvedValue(null); + mockDb.contact.upsert.mockResolvedValue({ + id: "contact_3", + email: "carol@example.com", + contactBookId: "book_1", + subscribed: false, + properties: {}, + firstName: null, + lastName: null, + createdAt, + updatedAt: createdAt, + }); + + await addOrUpdateContact( + "book_1", + { email: "carol@example.com", subscribed: false }, + 7, + ); + + const upsertArgs = mockDb.contact.upsert.mock.calls[0]?.[0]; + expect(upsertArgs.create).toMatchObject({ + subscribed: false, + unsubscribeReason: "UNSUBSCRIBED", + }); + }); + + it("does not create pending contacts for explicit unsubscribes", async () => { + mockDb.contactBook.findUnique.mockResolvedValue({ + doubleOptInEnabled: true, + teamId: 7, + }); + mockDb.contact.findUnique.mockResolvedValue(null); + mockDb.contact.upsert.mockResolvedValue({ + id: "contact_5", + email: "erin@example.com", + contactBookId: "book_1", + subscribed: false, + properties: {}, + firstName: null, + lastName: null, + createdAt, + updatedAt: createdAt, + }); + + await addOrUpdateContact( + "book_1", + { email: "erin@example.com", subscribed: false }, + 7, + ); + + const upsertArgs = mockDb.contact.upsert.mock.calls[0]?.[0]; + expect(upsertArgs.create).toMatchObject({ + subscribed: false, + unsubscribeReason: "UNSUBSCRIBED", + }); + expect(mockSendDoubleOptInConfirmationEmail).not.toHaveBeenCalled(); + }); + + it("does not re-subscribe contacts that already unsubscribed", async () => { + mockDb.contactBook.findUnique.mockResolvedValue({ + doubleOptInEnabled: true, + teamId: 7, + }); + mockDb.contact.findUnique.mockResolvedValue({ + subscribed: false, + unsubscribeReason: "manual", + }); + mockDb.contact.upsert.mockResolvedValue({ + id: "contact_2", + email: "bob@example.com", + contactBookId: "book_1", + subscribed: false, + properties: {}, + firstName: null, + lastName: null, + createdAt, + updatedAt: createdAt, + }); + + await addOrUpdateContact( + "book_1", + { email: "bob@example.com", subscribed: true }, + 7, + ); + + const upsertArgs = mockDb.contact.upsert.mock.calls[0]?.[0]; + expect(upsertArgs.update).not.toHaveProperty("subscribed"); + expect(mockSendDoubleOptInConfirmationEmail).not.toHaveBeenCalled(); + expect(mockWebhookEmit).toHaveBeenCalledWith( + 7, + "contact.updated", + expect.objectContaining({ id: "contact_2" }), + ); + }); + + it("throws when contact book does not exist", async () => { + mockDb.contactBook.findUnique.mockResolvedValue(null); + + await expect( + addOrUpdateContact("missing-book", { email: "alice@example.com" }, 7), + ).rejects.toThrow("Contact book not found"); + expect(mockDb.contact.upsert).not.toHaveBeenCalled(); + }); + + it("persists contact when double opt-in email send fails", async () => { + mockDb.contactBook.findUnique.mockResolvedValue({ + doubleOptInEnabled: true, + teamId: 7, + }); + mockDb.contact.findUnique.mockResolvedValue(null); + mockDb.contact.upsert.mockResolvedValue({ + id: "contact_4", + email: "dana@example.com", + contactBookId: "book_1", + subscribed: false, + properties: {}, + firstName: null, + lastName: null, + createdAt, + updatedAt: createdAt, + }); + mockSendDoubleOptInConfirmationEmail.mockRejectedValue( + new Error("send failed"), + ); + + await expect( + addOrUpdateContact("book_1", { email: "dana@example.com" }, 7), + ).resolves.toMatchObject({ + id: "contact_4", + }); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + contactId: "contact_4", + contactBookId: "book_1", + teamId: 7, + }), + "[ContactService]: Failed to send double opt-in confirmation email", + ); + }); + + it("resends double opt-in confirmation for pending contacts", async () => { + mockDb.contact.findFirst.mockResolvedValue({ + id: "contact_6", + contactBookId: "book_1", + subscribed: false, + unsubscribeReason: null, + }); + + const result = await resendDoubleOptInConfirmationInContactBook( + "contact_6", + "book_1", + 7, + ); + + expect(mockSendDoubleOptInConfirmationEmail).toHaveBeenCalledWith({ + contactId: "contact_6", + contactBookId: "book_1", + teamId: 7, + }); + expect(result).toMatchObject({ id: "contact_6" }); + }); + + it("rejects resending confirmation for non-pending contacts", async () => { + mockDb.contact.findFirst.mockResolvedValue({ + id: "contact_7", + contactBookId: "book_1", + subscribed: true, + unsubscribeReason: null, + }); + + await expect( + resendDoubleOptInConfirmationInContactBook("contact_7", "book_1", 7), + ).rejects.toThrow( + "Double opt-in confirmation can only be resent to pending contacts", + ); + + expect(mockSendDoubleOptInConfirmationEmail).not.toHaveBeenCalled(); + }); + + it("returns null when resending confirmation for missing contact", async () => { + mockDb.contact.findFirst.mockResolvedValue(null); + + await expect( + resendDoubleOptInConfirmationInContactBook("missing", "book_1", 7), + ).resolves.toBeNull(); + expect(mockSendDoubleOptInConfirmationEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/server/service/double-opt-in-service.ts b/apps/web/src/server/service/double-opt-in-service.ts new file mode 100644 index 0000000..dbdf737 --- /dev/null +++ b/apps/web/src/server/service/double-opt-in-service.ts @@ -0,0 +1,215 @@ +import { DomainStatus } from "@prisma/client"; +import { createHash, timingSafeEqual } from "crypto"; +import { EmailRenderer } from "@usesend/email-editor/src/renderer"; +import { env } from "~/env"; +import { + DEFAULT_DOUBLE_OPT_IN_CONTENT, + DEFAULT_DOUBLE_OPT_IN_SUBJECT, +} from "~/lib/constants/double-opt-in"; +import { db } from "../db"; +import { logger } from "../logger/log"; +import { sendEmail } from "./email-service"; +import { validateDomainFromEmail } from "./domain-service"; + +const DOUBLE_OPT_IN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; + +function createDoubleOptInHash(contactId: string, expiresAt: number) { + return createHash("sha256") + .update(`${contactId}-${expiresAt}-${env.NEXTAUTH_SECRET}`) + .digest("hex"); +} + +function replaceTemplateTokens( + value: string, + variables: Record, +) { + return Object.entries(variables).reduce((acc, [key, replacement]) => { + if (replacement === undefined) { + return acc; + } + + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const tokenRegex = new RegExp(`\\{\\{\\s*${escapedKey}\\s*\\}\\}`, "gi"); + return acc.replace(tokenRegex, replacement); + }, value); +} + +function createDoubleOptInConfirmationUrl(contactId: string) { + const expiresAt = Date.now() + DOUBLE_OPT_IN_EXPIRY_MS; + const hash = createDoubleOptInHash(contactId, expiresAt); + const searchParams = new URLSearchParams({ + contactId, + expiresAt: String(expiresAt), + hash, + }); + + return `${env.NEXTAUTH_URL}/subscribe?${searchParams.toString()}`; +} + +export async function sendDoubleOptInConfirmationEmail({ + contactId, + contactBookId, + teamId, +}: { + contactId: string; + contactBookId: string; + teamId: number; +}) { + const contact = await db.contact.findUnique({ + where: { id: contactId }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + contactBookId: true, + contactBook: { + select: { + id: true, + name: true, + doubleOptInEnabled: true, + doubleOptInFrom: true, + doubleOptInSubject: true, + doubleOptInContent: true, + }, + }, + }, + }); + + if (!contact || contact.contactBookId !== contactBookId) { + throw new Error("Contact not found for double opt-in email"); + } + + if (!contact.contactBook.doubleOptInEnabled) { + return; + } + + const configuredFrom = contact.contactBook.doubleOptInFrom?.trim(); + let from: string; + + if (!configuredFrom) { + const domain = await db.domain.findFirst({ + where: { + teamId, + status: DomainStatus.SUCCESS, + }, + select: { + name: true, + }, + orderBy: { + createdAt: "asc", + }, + }); + + if (!domain) { + throw new Error( + "Double opt-in requires at least one verified domain to send confirmation emails", + ); + } + + from = `hello@${domain.name}`; + } else { + from = configuredFrom; + } + + const confirmationUrl = createDoubleOptInConfirmationUrl(contact.id); + + const variableValues: Record = { + email: contact.email, + firstName: contact.firstName ?? "", + lastName: contact.lastName ?? "", + doubleOptInUrl: confirmationUrl, + }; + + const content = + contact.contactBook.doubleOptInContent ?? DEFAULT_DOUBLE_OPT_IN_CONTENT; + + let html: string; + + try { + const renderer = new EmailRenderer(JSON.parse(content)); + html = await renderer.render({ + shouldReplaceVariableValues: true, + variableValues, + linkValues: { + "{{doubleOptInUrl}}": confirmationUrl, + doubleOptInUrl: confirmationUrl, + }, + }); + } catch (error) { + logger.error( + { + error, + contactBookId, + }, + "[DoubleOptInService]: Failed to render custom template, using fallback HTML", + ); + + html = `

Please confirm your subscription by clicking this link.

`; + } + + const subject = replaceTemplateTokens( + contact.contactBook.doubleOptInSubject ?? DEFAULT_DOUBLE_OPT_IN_SUBJECT, + variableValues, + ); + + await validateDomainFromEmail(from, teamId); + + await sendEmail({ + to: contact.email, + from, + subject, + html: replaceTemplateTokens(html, { doubleOptInUrl: confirmationUrl }), + teamId, + }); +} + +export async function confirmDoubleOptInSubscription({ + contactId, + expiresAt, + hash, +}: { + contactId: string; + expiresAt: string; + hash: string; +}) { + const expiresAtTimestamp = Number(expiresAt); + + if (!Number.isFinite(expiresAtTimestamp)) { + throw new Error("Invalid confirmation link"); + } + + if (Date.now() > expiresAtTimestamp) { + throw new Error("Confirmation link has expired"); + } + + const expectedHash = createDoubleOptInHash(contactId, expiresAtTimestamp); + const providedHashBuffer = Buffer.from(hash, "utf-8"); + const expectedHashBuffer = Buffer.from(expectedHash, "utf-8"); + if ( + providedHashBuffer.length !== expectedHashBuffer.length || + !timingSafeEqual(providedHashBuffer, expectedHashBuffer) + ) { + throw new Error("Invalid confirmation link"); + } + + const existingContact = await db.contact.findUnique({ + where: { id: contactId }, + }); + + if (!existingContact) { + throw new Error("Contact not found"); + } + + if (existingContact.subscribed || existingContact.unsubscribeReason != null) { + return existingContact; + } + + return db.contact.update({ + where: { id: contactId }, + data: { + subscribed: true, + unsubscribeReason: null, + }, + }); +} diff --git a/apps/web/src/server/service/double-opt-in-service.unit.test.ts b/apps/web/src/server/service/double-opt-in-service.unit.test.ts new file mode 100644 index 0000000..cb2ac3f --- /dev/null +++ b/apps/web/src/server/service/double-opt-in-service.unit.test.ts @@ -0,0 +1,372 @@ +import { createHash } from "crypto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockDb, + mockSendEmail, + mockRendererRender, + mockLogger, + mockValidateDomainFromEmail, +} = vi.hoisted(() => ({ + mockDb: { + contact: { + findUnique: vi.fn(), + update: vi.fn(), + }, + domain: { + findFirst: vi.fn(), + }, + }, + mockSendEmail: vi.fn(), + mockRendererRender: vi.fn(), + mockLogger: { + error: vi.fn(), + }, + mockValidateDomainFromEmail: vi.fn(), +})); + +vi.mock("~/server/db", () => ({ + db: mockDb, +})); + +vi.mock("~/server/service/email-service", () => ({ + sendEmail: mockSendEmail, +})); + +vi.mock("~/server/logger/log", () => ({ + logger: mockLogger, +})); + +vi.mock("~/server/service/domain-service", () => ({ + validateDomainFromEmail: mockValidateDomainFromEmail, +})); + +vi.mock("@usesend/email-editor/src/renderer", () => ({ + EmailRenderer: vi.fn().mockImplementation(() => ({ + render: mockRendererRender, + })), +})); + +import { + confirmDoubleOptInSubscription, + sendDoubleOptInConfirmationEmail, +} from "~/server/service/double-opt-in-service"; + +function getHash(contactId: string, expiresAt: number) { + const secret = process.env.NEXTAUTH_SECRET ?? ""; + return createHash("sha256") + .update(`${contactId}-${expiresAt}-${secret}`) + .digest("hex"); +} + +describe("double-opt-in-service", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-08T00:00:00.000Z")); + + mockDb.contact.findUnique.mockReset(); + mockDb.contact.update.mockReset(); + mockDb.domain.findFirst.mockReset(); + mockSendEmail.mockReset(); + mockRendererRender.mockReset(); + mockLogger.error.mockReset(); + mockValidateDomainFromEmail.mockReset(); + mockValidateDomainFromEmail.mockResolvedValue({ + id: 1, + name: "example.com", + status: "SUCCESS", + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("skips sending when double opt-in is disabled", async () => { + mockDb.contact.findUnique.mockResolvedValue({ + id: "contact_1", + email: "alice@example.com", + firstName: "Alice", + lastName: "Smith", + contactBookId: "book_1", + contactBook: { + id: "book_1", + name: "Newsletter", + doubleOptInEnabled: false, + doubleOptInFrom: null, + doubleOptInSubject: null, + doubleOptInContent: null, + }, + }); + + await sendDoubleOptInConfirmationEmail({ + contactId: "contact_1", + contactBookId: "book_1", + teamId: 7, + }); + + expect(mockDb.domain.findFirst).not.toHaveBeenCalled(); + expect(mockSendEmail).not.toHaveBeenCalled(); + }); + + it("throws when no verified domain exists", async () => { + mockDb.contact.findUnique.mockResolvedValue({ + id: "contact_1", + email: "alice@example.com", + firstName: "Alice", + lastName: "Smith", + contactBookId: "book_1", + contactBook: { + id: "book_1", + name: "Newsletter", + doubleOptInEnabled: true, + doubleOptInFrom: null, + doubleOptInSubject: "Confirm {{firstName}}", + doubleOptInContent: JSON.stringify({ type: "doc", content: [] }), + }, + }); + mockDb.domain.findFirst.mockResolvedValue(null); + + await expect( + sendDoubleOptInConfirmationEmail({ + contactId: "contact_1", + contactBookId: "book_1", + teamId: 7, + }), + ).rejects.toThrow( + "Double opt-in requires at least one verified domain to send confirmation emails", + ); + expect(mockSendEmail).not.toHaveBeenCalled(); + }); + + it("sends rendered confirmation email with template variables", async () => { + mockDb.contact.findUnique.mockResolvedValue({ + id: "contact_1", + email: "alice@example.com", + firstName: "Alice", + lastName: "Smith", + contactBookId: "book_1", + contactBook: { + id: "book_1", + name: "Newsletter", + doubleOptInEnabled: true, + doubleOptInFrom: null, + doubleOptInSubject: "Confirm {{firstName}}", + doubleOptInContent: JSON.stringify({ type: "doc", content: [] }), + }, + }); + mockDb.domain.findFirst.mockResolvedValue({ name: "example.com" }); + mockRendererRender.mockResolvedValue( + '

Click confirm

', + ); + + await sendDoubleOptInConfirmationEmail({ + contactId: "contact_1", + contactBookId: "book_1", + teamId: 7, + }); + + const sendArgs = mockSendEmail.mock.calls[0]?.[0]; + expect(sendArgs.from).toBe("hello@example.com"); + expect(sendArgs.subject).toBe("Confirm Alice"); + expect(sendArgs.html).toContain("contactId=contact_1"); + expect(sendArgs.html).not.toContain("{{doubleOptInUrl}}"); + expect(sendArgs.teamId).toBe(7); + }); + + it("falls back to plain HTML when template rendering fails", async () => { + mockDb.contact.findUnique.mockResolvedValue({ + id: "contact_1", + email: "alice@example.com", + firstName: "Alice", + lastName: "Smith", + contactBookId: "book_1", + contactBook: { + id: "book_1", + name: "Newsletter", + doubleOptInEnabled: true, + doubleOptInFrom: null, + doubleOptInSubject: "Confirm {{firstName}}", + doubleOptInContent: JSON.stringify({ type: "doc", content: [] }), + }, + }); + mockDb.domain.findFirst.mockResolvedValue({ name: "example.com" }); + mockRendererRender.mockRejectedValue(new Error("render failed")); + + await sendDoubleOptInConfirmationEmail({ + contactId: "contact_1", + contactBookId: "book_1", + teamId: 7, + }); + + const sendArgs = mockSendEmail.mock.calls[0]?.[0]; + expect(sendArgs.html).toContain("Please confirm your subscription"); + expect(sendArgs.html).toContain("contactId=contact_1"); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it("replaces empty template variables instead of leaving tokens", async () => { + mockDb.contact.findUnique.mockResolvedValue({ + id: "contact_1", + email: "alice@example.com", + firstName: null, + lastName: null, + contactBookId: "book_1", + contactBook: { + id: "book_1", + name: "Newsletter", + doubleOptInEnabled: true, + doubleOptInFrom: null, + doubleOptInSubject: "Confirm {{firstName}}", + doubleOptInContent: JSON.stringify({ type: "doc", content: [] }), + }, + }); + mockDb.domain.findFirst.mockResolvedValue({ name: "example.com" }); + mockRendererRender.mockResolvedValue("

Test

"); + + await sendDoubleOptInConfirmationEmail({ + contactId: "contact_1", + contactBookId: "book_1", + teamId: 7, + }); + + const sendArgs = mockSendEmail.mock.calls[0]?.[0]; + expect(sendArgs.subject).toBe("Confirm "); + }); + + it("uses configured double opt-in from address when present", async () => { + mockDb.contact.findUnique.mockResolvedValue({ + id: "contact_1", + email: "alice@example.com", + firstName: "Alice", + lastName: "Smith", + contactBookId: "book_1", + contactBook: { + id: "book_1", + name: "Newsletter", + doubleOptInEnabled: true, + doubleOptInFrom: "Newsletter ", + doubleOptInSubject: "Confirm {{firstName}}", + doubleOptInContent: JSON.stringify({ type: "doc", content: [] }), + }, + }); + mockRendererRender.mockResolvedValue("

Test

"); + + await sendDoubleOptInConfirmationEmail({ + contactId: "contact_1", + contactBookId: "book_1", + teamId: 7, + }); + + expect(mockDb.domain.findFirst).not.toHaveBeenCalled(); + expect(mockValidateDomainFromEmail).toHaveBeenCalledWith( + "Newsletter ", + 7, + ); + const sendArgs = mockSendEmail.mock.calls[0]?.[0]; + expect(sendArgs.from).toBe("Newsletter "); + }); + + it("rejects invalid confirmation links", async () => { + await expect( + confirmDoubleOptInSubscription({ + contactId: "contact_1", + expiresAt: "not-a-number", + hash: "abc", + }), + ).rejects.toThrow("Invalid confirmation link"); + }); + + it("rejects expired confirmation links", async () => { + await expect( + confirmDoubleOptInSubscription({ + contactId: "contact_1", + expiresAt: String(Date.now() - 1), + hash: "abc", + }), + ).rejects.toThrow("Confirmation link has expired"); + }); + + it("rejects links with invalid signatures", async () => { + await expect( + confirmDoubleOptInSubscription({ + contactId: "contact_1", + expiresAt: String(Date.now() + 60_000), + hash: "invalid-hash", + }), + ).rejects.toThrow("Invalid confirmation link"); + }); + + it("returns existing contact when already subscribed", async () => { + const expiresAt = Date.now() + 60_000; + const contact = { + id: "contact_1", + email: "alice@example.com", + subscribed: true, + }; + + mockDb.contact.findUnique.mockResolvedValue(contact); + + const result = await confirmDoubleOptInSubscription({ + contactId: "contact_1", + expiresAt: String(expiresAt), + hash: getHash("contact_1", expiresAt), + }); + + expect(result).toBe(contact); + expect(mockDb.contact.update).not.toHaveBeenCalled(); + }); + + it("does not re-subscribe contacts with explicit unsubscribe reasons", async () => { + const expiresAt = Date.now() + 60_000; + const contact = { + id: "contact_1", + email: "alice@example.com", + subscribed: false, + unsubscribeReason: "UNSUBSCRIBED", + }; + + mockDb.contact.findUnique.mockResolvedValue(contact); + + const result = await confirmDoubleOptInSubscription({ + contactId: "contact_1", + expiresAt: String(expiresAt), + hash: getHash("contact_1", expiresAt), + }); + + expect(result).toBe(contact); + expect(mockDb.contact.update).not.toHaveBeenCalled(); + }); + + it("activates pending contacts with a valid link", async () => { + const expiresAt = Date.now() + 60_000; + + mockDb.contact.findUnique.mockResolvedValue({ + id: "contact_1", + subscribed: false, + }); + mockDb.contact.update.mockResolvedValue({ + id: "contact_1", + subscribed: true, + unsubscribeReason: null, + }); + + const result = await confirmDoubleOptInSubscription({ + contactId: "contact_1", + expiresAt: String(expiresAt), + hash: getHash("contact_1", expiresAt), + }); + + expect(mockDb.contact.update).toHaveBeenCalledWith({ + where: { id: "contact_1" }, + data: { + subscribed: true, + unsubscribeReason: null, + }, + }); + expect(result).toMatchObject({ + id: "contact_1", + subscribed: true, + }); + }); +}); diff --git a/packages/ui/src/button.tsx b/packages/ui/src/button.tsx index a4cb9bd..1408626 100644 --- a/packages/ui/src/button.tsx +++ b/packages/ui/src/button.tsx @@ -33,7 +33,7 @@ const buttonVariants = cva( variant: "default", size: "default", }, - } + }, ); export interface ButtonProps @@ -56,7 +56,7 @@ const Button = React.forwardRef( showSpinner = false, ...props }, - ref + ref, ) => { const Comp = asChild ? Slot : "button"; @@ -67,13 +67,19 @@ const Button = React.forwardRef( disabled={isLoading || props.disabled} {...props} > - {isLoading && showSpinner ? ( - - ) : null} - {children} + {asChild ? ( + children + ) : ( + <> + {isLoading && showSpinner ? ( + + ) : null} + {children} + + )} ); - } + }, ); Button.displayName = "Button";