feat: add customizable contact double opt-in flow (#350)

* feat: add customizable contact double opt-in flow

* test: add double opt-in service coverage

* fix: address review comments for double opt-in PR

- Make pending status conditional on doubleOptInEnabled flag
- Backfill legacy unsubscribeReason for reliable pending detection
- Add doubleOptInContent to contact book listing select
- Fix duplicate toast on DOI editor subject save failure
- Harden searchParams parsing against string[] values
- Make default DOI template use link mark for clickable URL
- Make public API create+update atomic via transaction
- Prevent contact upsert failure when DOI email send fails
- Fix empty string template variable replacement

Co-authored-by: opencode <opencode@anthropic.com>

* fix: harden double opt-in confirmation safeguards

Preserve explicit unsubscribe intent in DOI flows and prevent confirmation links from re-subscribing opted-out contacts. Also sanitize subscribe-page error messaging and use timing-safe hash comparison for link verification.

* ui stuff

* fix: require doubleOptInUrl in double opt-in templates

* feat: add configurable from address for double opt-in emails

* feat: add resend confirmation flow for pending contacts

* fix: move subscribe confirmation to explicit POST flow

* test: add contact book public API endpoint coverage

* docs: add double opt-in documentation and update OpenAPI spec

Add a user guide for the double opt-in feature covering setup, contact
statuses, email customization, template variables, and best practices.
Update the OpenAPI spec to include doubleOptIn fields in all contactBook
request/response schemas.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: opencode <opencode@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
KM Koushik
2026-03-01 00:34:20 +11:00
committed by GitHub
parent edcd32a4ea
commit e3e9635a5f
27 changed files with 3500 additions and 288 deletions
-5
View File
@@ -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
+68
View File
@@ -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 <hello@example.com>"
},
"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" }
},
+1 -1
View File
@@ -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"]
}
]
},
+171
View File
@@ -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
<Steps>
<Step title="Contact is added">
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.
</Step>
<Step title="Confirmation email is sent">
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.
</Step>
<Step title="Contact confirms">
The contact clicks the verification link in the email and confirms their
subscription on the confirmation page.
</Step>
<Step title="Contact is subscribed">
The contact's status changes from **Pending** to **Subscribed** and they
will now receive your emails.
</Step>
</Steps>
## 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 <hello@yourdomain.com>",
"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 |
<Warning>
Contacts with **Pending** status will not receive campaign emails. They will
only receive the double opt-in confirmation email.
</Warning>
## 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 <hello@yourdomain.com>"
}
```
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 |
<Warning>
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.
</Warning>
## 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
<AccordionGroup>
<Accordion title="Set up a verified domain first">
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.
</Accordion>
<Accordion title="Keep the confirmation email simple">
The confirmation email should be clear and concise. Include a prominent
confirmation button and a brief explanation of what the subscriber is
confirming.
</Accordion>
<Accordion title="Use a recognizable from address">
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.
</Accordion>
<Accordion title="Monitor pending contacts">
Regularly check for contacts stuck in Pending status. If many contacts
aren't confirming, consider improving your confirmation email or resending
confirmations.
</Accordion>
</AccordionGroup>
@@ -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;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "ContactBook"
ADD COLUMN "doubleOptInFrom" TEXT;
+13 -9
View File
@@ -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])
}
@@ -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({
</TableCell>
</TableRow>
) : contactsQuery.data?.contacts.length ? (
contactsQuery.data?.contacts.map((contact) => (
<TableRow key={contact.id} className="">
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Image
src={getGravatarUrl(contact.email, {
size: 75,
defaultImage: "robohash",
})}
alt={contact.email + "'s gravatar"}
width={35}
height={35}
className="rounded-full"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">
{contact.email}
</span>
<span className="text-xs text-muted-foreground">
{contact.firstName} {contact.lastName}
</span>
contactsQuery.data?.contacts.map((contact) => {
const isPendingConfirmation =
Boolean(doubleOptInEnabled) &&
!contact.subscribed &&
!contact.unsubscribeReason;
return (
<TableRow key={contact.id} className="">
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Image
src={getGravatarUrl(contact.email, {
size: 75,
defaultImage: "robohash",
})}
alt={contact.email + "'s gravatar"}
width={35}
height={35}
className="rounded-full"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">
{contact.email}
</span>
<span className="text-xs text-muted-foreground">
{contact.firstName} {contact.lastName}
</span>
</div>
</div>
</div>
</TableCell>
<TableCell>
{contact.subscribed ? (
<div className="text-center w-[130px] rounded capitalize py-1 text-xs bg-green/15 text-green border border-green/25">
Subscribed
</TableCell>
<TableCell>
{contact.subscribed ? (
<div className="text-center w-[130px] rounded capitalize py-1 text-xs bg-green/15 text-green border border-green/25">
Subscribed
</div>
) : isPendingConfirmation ? (
<div className="text-center w-[130px] rounded capitalize py-1 text-xs bg-yellow/20 text-yellow border border-yellow/20">
Pending
</div>
) : (
<Tooltip>
<TooltipTrigger>
<div className="text-center w-[130px] rounded capitalize py-1 text-xs bg-red/10 text-red border border-red/10">
Unsubscribed
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{getUnsubscribeReason(
contact.unsubscribeReason ??
UnsubscribeReason.UNSUBSCRIBED,
)}
</p>
</TooltipContent>
</Tooltip>
)}
</TableCell>
<TableCell className="">
{formatDistanceToNow(new Date(contact.createdAt), {
addSuffix: true,
})}
</TableCell>
<TableCell>
<div className="flex gap-2">
{isPendingConfirmation ? (
<ResendDoubleOptInConfirmation
contactBookId={contactBookId}
contactId={contact.id}
email={contact.email}
/>
) : null}
<EditContact contact={contact} />
<DeleteContact contact={contact} />
</div>
) : (
<Tooltip>
<TooltipTrigger>
<div className="text-center w-[130px] rounded capitalize py-1 text-xs bg-red/10 text-red border border-red/10">
Unsubscribed
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{getUnsubscribeReason(
contact.unsubscribeReason ??
UnsubscribeReason.UNSUBSCRIBED,
)}
</p>
</TooltipContent>
</Tooltip>
)}
</TableCell>
<TableCell className="">
{formatDistanceToNow(new Date(contact.createdAt), {
addSuffix: true,
})}
</TableCell>
<TableCell>
<div className="flex gap-2">
<EditContact contact={contact} />
<DeleteContact contact={contact} />
</div>
</TableCell>
</TableRow>
))
</TableCell>
</TableRow>
);
})
) : (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
@@ -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<string, any>;
} 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 (
<div className="flex justify-center items-center h-full">
<Spinner className="w-6 h-6" />
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center h-full">
<p className="text-red">Failed to load double opt-in settings</p>
</div>
);
}
if (!contactBook) {
return <div>Contact book not found</div>;
}
return <DoubleOptInEditor contactBook={contactBook} />;
}
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<Record<string, any>>(
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 (
<div className="p-4 container mx-auto">
<div className="mx-auto">
<div className="mb-4 flex justify-between items-center w-full sm:w-[700px] mx-auto">
<div className="flex items-center gap-3">
<Link href={`/contacts/${contactBook.id}`}>
<ArrowLeft className="h-4 w-4" />
</Link>
<div>
<div className="text-sm text-muted-foreground">
Double opt-in email
</div>
<div className="text-base font-medium">{contactBook.name}</div>
</div>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground whitespace-nowrap">
{isSaving ? (
<div className="h-2 w-2 bg-yellow rounded-full" />
) : (
<div className="h-2 w-2 bg-green rounded-full" />
)}
{formatDistanceToNow(contactBook.updatedAt) === "less than a minute"
? "just now"
: `${formatDistanceToNow(contactBook.updatedAt)} ago`}
</div>
</div>
<div className="flex flex-col mt-4 mb-4 p-4 w-full sm:w-[700px] mx-auto z-50 border rounded-lg shadow">
<div className="flex items-center gap-4">
<label className="block text-sm w-[80px] text-muted-foreground">
Subject
</label>
<Input
type="text"
value={subject}
onChange={(e) => {
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"
/>
</div>
<div className="flex items-center gap-4 mt-4">
<label className="block text-sm w-[80px] text-muted-foreground">
From
</label>
<Input
type="text"
value={from}
onChange={(e) => {
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<hello@example.com>"
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent"
/>
</div>
<p className="text-xs text-muted-foreground mt-3">
Use the variable <code>{"{{doubleOptInUrl}}"}</code> for the
confirmation link.
</p>
</div>
<div className="rounded-lg bg-gray-50 w-full sm:w-[700px] mx-auto p-4 sm:p-10">
<div className="w-full sm:w-[600px] mx-auto">
<Editor
initialContent={json}
onUpdate={(content) => {
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}
/>
</div>
</div>
</div>
</div>
);
}
@@ -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({
<AddContact contactBookId={contactBookId} />
</div>
</div>
<div className="mt-16">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 lg:gap-8">
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold mb-1">Metrics</p>
<div className="flex items-center gap-2">
<div className="text-muted-foreground w-[130px] text-sm">
Total Contacts
<div className="mt-10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Metrics Card */}
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="p-2 bg-muted rounded-md">
<Users className="w-4 h-4 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-medium">Metrics</CardTitle>
</div>
<div className="font-mono text-sm">
{contactBookDetailQuery.data?.totalContacts !== undefined
? contactBookDetailQuery.data?.totalContacts
: "--"}
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<Users className="w-3.5 h-3.5" />
Total Contacts
</span>
<span className="text-lg font-semibold font-mono">
{contactBookDetailQuery.data?.totalContacts !== undefined
? contactBookDetailQuery.data?.totalContacts.toLocaleString()
: "--"}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="text-muted-foreground w-[130px] text-sm">
Unsubscribed
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<MailX className="w-3.5 h-3.5" />
Unsubscribed
</span>
<span className="text-lg font-semibold font-mono text-destructive">
{contactBookDetailQuery.data?.unsubscribedContacts !==
undefined
? contactBookDetailQuery.data?.unsubscribedContacts.toLocaleString()
: "--"}
</span>
</div>
<div className="font-mono text-sm">
{contactBookDetailQuery.data?.unsubscribedContacts !== undefined
? contactBookDetailQuery.data?.unsubscribedContacts
: "--"}
</CardContent>
</Card>
{/* Details Card */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="p-2 bg-muted rounded-md">
<Hash className="w-4 h-4 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-medium">Details</CardTitle>
</div>
</div>
</div>
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold">Details</p>
<div className="flex items-center gap-2">
<div className="text-muted-foreground w-[130px] text-sm">
Contact book ID
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Contact book ID</p>
<TextWithCopyButton
value={contactBookId}
alwaysShowCopy
className="text-sm font-mono bg-muted px-2 py-1 rounded w-full"
/>
</div>
<TextWithCopyButton
value={contactBookId}
alwaysShowCopy
className="text-sm w-[130px] overflow-hidden text-ellipsis font-mono"
/>
</div>
<div className="flex items-center gap-2">
<div className="text-muted-foreground w-[130px] text-sm">
Created at
<div className="space-y-1">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Calendar className="w-3 h-3" />
Created
</p>
<p className="text-sm">
{contactBookDetailQuery.data?.createdAt
? formatDistanceToNow(
contactBookDetailQuery.data.createdAt,
{
addSuffix: true,
},
)
: "--"}
</p>
</div>
<div className="text-sm">
{contactBookDetailQuery.data?.createdAt
? formatDistanceToNow(contactBookDetailQuery.data.createdAt, {
addSuffix: true,
})
: "--"}
</CardContent>
</Card>
{/* Recent Campaigns Card */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="p-2 bg-muted rounded-md">
<Megaphone className="w-4 h-4 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-medium">
Recent Campaigns
</CardTitle>
</div>
</div>
</div>
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold">Recent campaigns</p>
{!contactBookDetailQuery.isLoading &&
contactBookDetailQuery.data?.campaigns.length === 0 ? (
<div className="text-muted-foreground text-sm">
No campaigns yet.
</div>
) : null}
{contactBookDetailQuery.data?.campaigns.map((campaign) => (
<div key={campaign.id} className="flex items-center gap-2">
<Link href={`/campaigns/${campaign.id}`}>
<div className="text-sm hover:underline hover:decoration-dashed text-nowrap w-[200px] overflow-hidden text-ellipsis">
{campaign.name}
</div>
</Link>
<div className="text-muted-foreground text-xs">
{formatDistanceToNow(campaign.createdAt, {})}
</CardHeader>
<CardContent>
{!contactBookDetailQuery.isLoading &&
contactBookDetailQuery.data?.campaigns.length === 0 ? (
<div className="text-muted-foreground text-sm py-4 text-center">
No campaigns yet.
</div>
) : (
<div className="space-y-2">
{contactBookDetailQuery.data?.campaigns
.slice(0, 5)
.map((campaign) => (
<Link
key={campaign.id}
href={`/campaigns/${campaign.id}`}
className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/50 transition-colors group"
>
<div className="flex items-center gap-2 min-w-0">
<Megaphone className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium truncate">
{campaign.name}
</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatDistanceToNow(campaign.createdAt, {
addSuffix: true,
})}
</span>
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</Link>
))}
{(contactBookDetailQuery.data?.campaigns.length || 0) > 5 && (
<Link
href="/campaigns"
className="flex items-center justify-center p-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
View all campaigns
</Link>
)}
</div>
)}
</CardContent>
</Card>
</div>
{/* Double Opt-in Section */}
<Card className="mt-6">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-muted rounded-md">
<Shield className="w-5 h-5 text-muted-foreground" />
</div>
<div>
<CardTitle className="text-base font-medium">
Double Opt-in
</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5">
Require email confirmation for new contacts
</p>
</div>
</div>
))}
</div>
</div>
<div className="mt-16">
<ContactList
contactBookId={contactBookId}
contactBookName={contactBookDetailQuery.data?.name}
/>
</div>
<Switch
checked={
contactBookDetailQuery.data?.doubleOptInEnabled ?? false
}
onCheckedChange={(checked) => {
updateContactBookMutation.mutate({
contactBookId,
doubleOptInEnabled: checked,
});
}}
className="data-[state=checked]:bg-green-500"
/>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center gap-4">
<p className="text-sm text-muted-foreground">
{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."}
</p>
<Button asChild variant="outline" size="sm">
<Link href={`/contacts/${contactBookId}/double-opt-in`}>
{contactBookDetailQuery.data?.doubleOptInEnabled
? "Edit confirmation email"
: "Preview confirmation email"}
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
<div className="mt-10">
<ContactList
contactBookId={contactBookId}
contactBookName={contactBookDetailQuery.data?.name}
doubleOptInEnabled={contactBookDetailQuery.data?.doubleOptInEnabled}
/>
</div>
</div>
);
@@ -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 (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setOpen(true)}
disabled={resendMutation.isPending}
>
{resendMutation.isPending ? (
<Spinner className="h-4 w-4" innerSvgClass="stroke-primary" />
) : (
<Send className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Resend confirmation email</p>
</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Resend Confirmation Email</DialogTitle>
<DialogDescription>
Send a new double opt-in confirmation email to{" "}
<strong>{email}</strong>?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={resendMutation.isPending}
>
Cancel
</Button>
<Button
onClick={() => {
resendMutation.mutate(
{
contactBookId,
contactId,
},
{
onSuccess: async () => {
await utils.contacts.contacts.invalidate();
toast.success(`Confirmation email resent to ${email}`);
setOpen(false);
},
onError: (error) => {
toast.error(error.message);
},
},
);
}}
disabled={resendMutation.isPending}
>
{resendMutation.isPending ? "Resending..." : "Resend"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+205
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="max-w-md w-full space-y-4 p-8 shadow rounded-xl border">
<h1 className="text-2xl font-semibold text-center">
Subscription Confirmed
</h1>
<p className="text-sm text-muted-foreground text-center">
Your subscription is confirmed and you will receive future emails.
</p>
</div>
</div>
);
}
if (status === "error" && (!contactId || !expiresAt || !hash)) {
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="max-w-md w-full space-y-4 p-8 shadow rounded-xl border">
<h1 className="text-2xl font-semibold text-center">
Confirmation Failed
</h1>
<p className="text-sm text-muted-foreground text-center">
{normalizedError ?? "Unable to confirm your subscription."}
</p>
</div>
</div>
);
}
if (!contactId || !expiresAt || !hash || !hasValidExpiry) {
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="max-w-md w-full space-y-4 p-8 shadow rounded-xl border">
<h1 className="text-2xl font-semibold text-center">Invalid Link</h1>
<p className="text-sm text-muted-foreground text-center">
This confirmation link is invalid. Please request a new one.
</p>
</div>
</div>
);
}
if (isExpired) {
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="max-w-md w-full space-y-4 p-8 shadow rounded-xl border">
<h1 className="text-2xl font-semibold text-center">
Confirmation Failed
</h1>
<p className="text-sm text-muted-foreground text-center">
Confirmation link has expired
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="max-w-md w-full space-y-4 p-8 shadow rounded-xl border">
<h1 className="text-2xl font-semibold text-center">
Confirm Subscription
</h1>
<p className="text-sm text-muted-foreground text-center">
Click the button below to confirm your subscription.
</p>
{normalizedError ? (
<p className="text-sm text-red text-center">{normalizedError}</p>
) : null}
{!isFatalError ? (
<form action={confirmSubscriptionAction} className="pt-2">
<input type="hidden" name="contactId" value={contactId} />
<input type="hidden" name="expiresAt" value={expiresAt} />
<input type="hidden" name="hash" value={hash} />
<button
type="submit"
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Confirm subscription
</button>
</form>
) : null}
</div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
export const DEFAULT_DOUBLE_OPT_IN_SUBJECT = "Please confirm your subscription";
const DEFAULT_DOUBLE_OPT_IN_CONTENT_JSON = {
type: "doc",
content: [
{
type: "paragraph",
attrs: { textAlign: "left" },
content: [
{
type: "text",
text: "Hello, Thank you for sigining up. Please confirm that you want to receive emails from us.",
},
],
},
{
type: "paragraph",
attrs: { textAlign: "left" },
content: [
{
type: "text",
text: "If you did not request this, you can ignore this email.",
},
],
},
{
type: "button",
attrs: {
component: "button",
text: "Confirm",
url: "{{doubleOptInUrl}}",
alignment: "left",
borderRadius: "8",
borderWidth: "1",
buttonColor: "#000000",
borderColor: "#000000",
textColor: "#ffffff",
},
},
{
type: "horizontalRule",
},
{
type: "paragraph",
attrs: { textAlign: "left" },
content: [
{
type: "text",
text: "You are receiving this email because you opted in via our site.",
},
],
},
],
};
export const DEFAULT_DOUBLE_OPT_IN_CONTENT = JSON.stringify(
DEFAULT_DOUBLE_OPT_IN_CONTENT_JSON,
);
export const DOUBLE_OPT_IN_EDITOR_VARIABLES = [
"email",
"firstName",
"lastName",
"doubleOptInUrl",
];
const DOUBLE_OPT_IN_URL_PLACEHOLDER_REGEX =
/\{\{\s*doubleOptInUrl(?:\s*,\s*fallback=[^}]+)?\s*\}\}/i;
function valueIncludesDoubleOptInUrl(value: unknown): boolean {
if (typeof value === "string") {
const normalizedValue = value.trim().toLowerCase();
return (
DOUBLE_OPT_IN_URL_PLACEHOLDER_REGEX.test(value) ||
normalizedValue === "doubleoptinurl"
);
}
if (Array.isArray(value)) {
return value.some(valueIncludesDoubleOptInUrl);
}
if (value && typeof value === "object") {
return Object.values(value).some(valueIncludesDoubleOptInUrl);
}
return false;
}
export function hasDoubleOptInUrlPlaceholder(content: string): boolean {
if (DOUBLE_OPT_IN_URL_PLACEHOLDER_REGEX.test(content)) {
return true;
}
try {
return valueIncludesDoubleOptInUrl(JSON.parse(content));
} catch {
return false;
}
}
export function getDefaultDoubleOptInContent() {
return structuredClone(DEFAULT_DOUBLE_OPT_IN_CONTENT_JSON) as Record<
string,
any
>;
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_DOUBLE_OPT_IN_CONTENT,
getDefaultDoubleOptInContent,
hasDoubleOptInUrlPlaceholder,
} from "~/lib/constants/double-opt-in";
describe("double opt-in defaults", () => {
it("uses a confirmation button placeholder in the default editor content", () => {
const content = JSON.parse(DEFAULT_DOUBLE_OPT_IN_CONTENT) as {
content?: Array<{
type?: string;
attrs?: { url?: string };
}>;
};
const hasButtonPlaceholder = content.content?.some(
(node) =>
node.type === "button" && node.attrs?.url === "{{doubleOptInUrl}}",
);
expect(hasButtonPlaceholder).toBe(true);
expect(hasDoubleOptInUrlPlaceholder(DEFAULT_DOUBLE_OPT_IN_CONTENT)).toBe(
true,
);
});
it("returns a clone when requesting default content", () => {
const first = getDefaultDoubleOptInContent();
const second = getDefaultDoubleOptInContent();
first.content = [];
expect(second.content).not.toEqual([]);
});
it("detects placeholder tokens in raw string content", () => {
expect(
hasDoubleOptInUrlPlaceholder(
'<p>Click <a href="{{ doubleOptInUrl }}">confirm</a></p>',
),
).toBe(true);
});
it("detects variable nodes using doubleOptInUrl", () => {
expect(
hasDoubleOptInUrlPlaceholder(
JSON.stringify({
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "variable", attrs: { id: "doubleOptInUrl" } }],
},
],
}),
),
).toBe(true);
});
it("returns false when placeholder is missing", () => {
expect(
hasDoubleOptInUrlPlaceholder(
JSON.stringify({
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Confirm your subscription" }],
},
],
}),
),
).toBe(false);
});
});
+36 -12
View File
@@ -1,23 +1,47 @@
import { z } from "zod";
export const ContactBookSchema = z.object({
id: z
.string()
.openapi({ description: "The ID of the contact book", example: "clx1234567890" }),
name: z
.string()
.openapi({ description: "The name of the contact book", example: "Newsletter Subscribers" }),
id: z.string().openapi({
description: "The ID of the contact book",
example: "clx1234567890",
}),
name: z.string().openapi({
description: "The name of the contact book",
example: "Newsletter Subscribers",
}),
teamId: z.number().openapi({ description: "The ID of the team", example: 1 }),
properties: z.record(z.string()).openapi({
description: "Custom properties for the contact book",
example: { customField1: "value1" },
}),
emoji: z
.string()
.openapi({ description: "The emoji associated with the contact book", example: "📙" }),
emoji: z.string().openapi({
description: "The emoji associated with the contact book",
example: "📙",
}),
doubleOptInEnabled: z.boolean().optional().openapi({
description: "Whether double opt-in is enabled for new contacts",
example: true,
}),
doubleOptInFrom: z.string().nullable().optional().openapi({
description:
"From address used for double opt-in emails (must use a verified domain)",
example: "Newsletter <hello@example.com>",
}),
doubleOptInSubject: z.string().nullable().optional().openapi({
description: "Subject line used for double opt-in confirmation email",
example: "Please confirm your subscription",
}),
doubleOptInContent: z.string().nullable().optional().openapi({
description:
"Email editor JSON content used for double opt-in confirmation",
}),
createdAt: z.string().openapi({ description: "The creation timestamp" }),
updatedAt: z.string().openapi({ description: "The last update timestamp" }),
_count: z.object({
contacts: z.number().openapi({ description: "The number of contacts in the contact book" }),
}).optional(),
_count: z
.object({
contacts: z
.number()
.openapi({ description: "The number of contacts in the contact book" }),
})
.optional(),
});
@@ -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({
@@ -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<string, unknown>) {
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 <hello@example.com>",
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 <hello@example.com>",
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 <hello@example.com>",
doubleOptInSubject: "Confirm your subscription",
doubleOptInContent: '{"type":"doc","content":[]}',
},
mockTransactionClient,
);
const body = await response.json();
expect(body).toMatchObject({
id: "cb_2",
doubleOptInEnabled: false,
doubleOptInFrom: "Marketing <hello@example.com>",
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 <hello@unverified.example>",
}),
});
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",
},
});
});
});
@@ -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<string, string>,
});
}
if (!hasOptionalFields) {
return created;
}
return c.json({
...contactBook,
properties: contactBook.properties as Record<string, string>,
});
});
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<string, string>,
});
});
}
export default createContactBook;
@@ -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<string, unknown>) {
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 <hello@example.com>",
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 <hello@example.com>",
doubleOptInSubject: "Confirm your subscription",
doubleOptInContent: '{"type":"doc","content":[]}',
}),
},
);
expect(response.status).toBe(200);
expect(mockUpdateContactBook).toHaveBeenCalledWith("cb_1", {
doubleOptInEnabled: false,
doubleOptInFrom: "Marketing <hello@example.com>",
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",
},
});
});
});
@@ -5,79 +5,83 @@ import { updateContactBook as updateContactBookService } from "~/server/service/
import { getContactBook } from "../../api-utils";
const route = createRoute({
method: "patch",
path: "/v1/contactBooks/{contactBookId}",
request: {
params: z.object({
contactBookId: z.string().openapi({
param: {
name: "contactBookId",
in: "path",
},
example: "clx1234567890",
}),
}),
body: {
required: true,
content: {
"application/json": {
schema: z.object({
name: z.string().min(1).optional(),
emoji: z.string().optional(),
properties: z.record(z.string()).optional(),
}),
},
},
},
},
responses: {
200: {
content: {
"application/json": {
schema: ContactBookSchema,
},
},
description: "Update the contact book",
},
403: {
content: {
"application/json": {
schema: z.object({
error: z.string(),
}),
},
},
description:
"Forbidden - API key doesn't have access to this contact book",
},
404: {
content: {
"application/json": {
schema: z.object({
error: z.string(),
}),
},
},
description: "Contact book not found",
},
},
method: "patch",
path: "/v1/contactBooks/{contactBookId}",
request: {
params: z.object({
contactBookId: z.string().openapi({
param: {
name: "contactBookId",
in: "path",
},
example: "clx1234567890",
}),
}),
body: {
required: true,
content: {
"application/json": {
schema: z.object({
name: z.string().min(1).optional(),
emoji: z.string().optional(),
properties: z.record(z.string()).optional(),
doubleOptInEnabled: z.boolean().optional(),
doubleOptInFrom: z.string().nullable().optional(),
doubleOptInSubject: z.string().optional(),
doubleOptInContent: z.string().optional(),
}),
},
},
},
},
responses: {
200: {
content: {
"application/json": {
schema: ContactBookSchema,
},
},
description: "Update the contact book",
},
403: {
content: {
"application/json": {
schema: z.object({
error: z.string(),
}),
},
},
description:
"Forbidden - API key doesn't have access to this contact book",
},
404: {
content: {
"application/json": {
schema: z.object({
error: z.string(),
}),
},
},
description: "Contact book not found",
},
},
});
function updateContactBook(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = c.var.team;
const contactBookId = c.req.valid("param").contactBookId;
const body = c.req.valid("json");
app.openapi(route, async (c) => {
const team = c.var.team;
const contactBookId = c.req.valid("param").contactBookId;
const body = c.req.valid("json");
await getContactBook(c, team.id);
await getContactBook(c, team.id);
const updated = await updateContactBookService(contactBookId, body);
const updated = await updateContactBookService(contactBookId, body);
return c.json({
...updated,
properties: updated.properties as Record<string, string>,
});
});
return c.json({
...updated,
properties: updated.properties as Record<string, string>,
});
});
}
export default updateContactBook;
@@ -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<typeof db, "contactBook">;
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<string, string>;
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,
});
}
@@ -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 <hello@example.com> ",
});
expect(mockValidateDomainFromEmail).toHaveBeenCalledWith(
"Newsletter <hello@example.com>",
12,
);
expect(mockDb.contactBook.update).toHaveBeenCalledWith({
where: { id: "book_1" },
data: {
doubleOptInFrom: "Newsletter <hello@example.com>",
},
});
});
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,
},
});
});
});
+124 -4
View File
@@ -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<ContactInput>,
@@ -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,
},
});
}
@@ -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();
});
});
@@ -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<string, string | undefined>,
) {
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<string, string> = {
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 = `<p>Please confirm your subscription by clicking <a href="${confirmationUrl}">this link</a>.</p>`;
}
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,
},
});
}
@@ -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(
'<p>Click <a href="{{doubleOptInUrl}}">confirm</a></p>',
);
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("<p>Test</p>");
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 <hello@example.com>",
doubleOptInSubject: "Confirm {{firstName}}",
doubleOptInContent: JSON.stringify({ type: "doc", content: [] }),
},
});
mockRendererRender.mockResolvedValue("<p>Test</p>");
await sendDoubleOptInConfirmationEmail({
contactId: "contact_1",
contactBookId: "book_1",
teamId: 7,
});
expect(mockDb.domain.findFirst).not.toHaveBeenCalled();
expect(mockValidateDomainFromEmail).toHaveBeenCalledWith(
"Newsletter <hello@example.com>",
7,
);
const sendArgs = mockSendEmail.mock.calls[0]?.[0];
expect(sendArgs.from).toBe("Newsletter <hello@example.com>");
});
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,
});
});
});
+13 -7
View File
@@ -33,7 +33,7 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
},
);
export interface ButtonProps
@@ -56,7 +56,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
showSpinner = false,
...props
},
ref
ref,
) => {
const Comp = asChild ? Slot : "button";
@@ -67,13 +67,19 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
disabled={isLoading || props.disabled}
{...props}
>
{isLoading && showSpinner ? (
<Spinner className="h-4 w-4 mr-2 " innerSvgClass="stroke-white" />
) : null}
{children}
{asChild ? (
children
) : (
<>
{isLoading && showSpinner ? (
<Spinner className="h-4 w-4 mr-2 " innerSvgClass="stroke-white" />
) : null}
{children}
</>
)}
</Comp>
);
}
},
);
Button.displayName = "Button";