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