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 dev`: Turbo dev for all relevant apps (loads `.env`).
|
||||||
- `pnpm start:web:local`: Run only `apps/web` locally on port 3000.
|
- `pnpm start:web:local`: Run only `apps/web` locally on port 3000.
|
||||||
- `pnpm build`: Turbo build across the monorepo.
|
- `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.
|
- `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`.
|
- Database (apps/web filter): `pnpm db:generate` | `db:migrate-dev` | `db:push` | `db:studio`.
|
||||||
- Never run migrations unless users explicitly asked
|
- Never run migrations unless users explicitly asked
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## 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.
|
- 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"`).
|
- Paths (web): use alias `~/` for src imports (e.g., `import { x } from "~/utils/x"`).
|
||||||
- NEVER USE DYNAMIC IMPORTS. ALWAYS IMPORT ON THE TOP
|
- NEVER USE DYNAMIC IMPORTS. ALWAYS IMPORT ON THE TOP
|
||||||
|
|||||||
@@ -1095,6 +1095,28 @@
|
|||||||
"description": "The emoji associated with the contact book",
|
"description": "The emoji associated with the contact book",
|
||||||
"example": "📙"
|
"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": {
|
"createdAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The creation timestamp"
|
"description": "The creation timestamp"
|
||||||
@@ -1142,6 +1164,23 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": { "type": "string" }
|
"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"]
|
"required": ["name"]
|
||||||
@@ -1165,6 +1204,10 @@
|
|||||||
"additionalProperties": { "type": "string" }
|
"additionalProperties": { "type": "string" }
|
||||||
},
|
},
|
||||||
"emoji": { "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" },
|
"createdAt": { "type": "string" },
|
||||||
"updatedAt": { "type": "string" }
|
"updatedAt": { "type": "string" }
|
||||||
},
|
},
|
||||||
@@ -1210,6 +1253,10 @@
|
|||||||
"additionalProperties": { "type": "string" }
|
"additionalProperties": { "type": "string" }
|
||||||
},
|
},
|
||||||
"emoji": { "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" },
|
"createdAt": { "type": "string" },
|
||||||
"updatedAt": { "type": "string" },
|
"updatedAt": { "type": "string" },
|
||||||
"_count": {
|
"_count": {
|
||||||
@@ -1279,6 +1326,23 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": { "type": "string" }
|
"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" }
|
"additionalProperties": { "type": "string" }
|
||||||
},
|
},
|
||||||
"emoji": { "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" },
|
"createdAt": { "type": "string" },
|
||||||
"updatedAt": { "type": "string" }
|
"updatedAt": { "type": "string" }
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": "Guides",
|
"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 {
|
model ContactBook {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
teamId Int
|
teamId Int
|
||||||
properties Json
|
properties Json
|
||||||
createdAt DateTime @default(now())
|
doubleOptInEnabled Boolean @default(false)
|
||||||
updatedAt DateTime @updatedAt
|
doubleOptInFrom String?
|
||||||
emoji String @default("📙")
|
doubleOptInSubject String?
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
doubleOptInContent String?
|
||||||
contacts Contact[]
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
emoji String @default("📙")
|
||||||
|
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
|
contacts Contact[]
|
||||||
|
|
||||||
@@index([teamId])
|
@@index([teamId])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { api } from "~/trpc/react";
|
|||||||
import { getGravatarUrl } from "~/utils/gravatar-utils";
|
import { getGravatarUrl } from "~/utils/gravatar-utils";
|
||||||
import DeleteContact from "./delete-contact";
|
import DeleteContact from "./delete-contact";
|
||||||
import EditContact from "./edit-contact";
|
import EditContact from "./edit-contact";
|
||||||
|
import { ResendDoubleOptInConfirmation } from "./resend-double-opt-in-confirmation";
|
||||||
import { Input } from "@usesend/ui/src/input";
|
import { Input } from "@usesend/ui/src/input";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import {
|
import {
|
||||||
@@ -70,9 +71,11 @@ function getUnsubscribeReason(reason: UnsubscribeReason) {
|
|||||||
export default function ContactList({
|
export default function ContactList({
|
||||||
contactBookId,
|
contactBookId,
|
||||||
contactBookName,
|
contactBookName,
|
||||||
|
doubleOptInEnabled,
|
||||||
}: {
|
}: {
|
||||||
contactBookId: string;
|
contactBookId: string;
|
||||||
contactBookName?: string;
|
contactBookName?: string;
|
||||||
|
doubleOptInEnabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [page, setPage] = useUrlState("page", "1");
|
const [page, setPage] = useUrlState("page", "1");
|
||||||
const [status, setStatus] = useUrlState("status");
|
const [status, setStatus] = useUrlState("status");
|
||||||
@@ -237,66 +240,84 @@ export default function ContactList({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : contactsQuery.data?.contacts.length ? (
|
) : contactsQuery.data?.contacts.length ? (
|
||||||
contactsQuery.data?.contacts.map((contact) => (
|
contactsQuery.data?.contacts.map((contact) => {
|
||||||
<TableRow key={contact.id} className="">
|
const isPendingConfirmation =
|
||||||
<TableCell className="font-medium">
|
Boolean(doubleOptInEnabled) &&
|
||||||
<div className="flex items-center gap-2">
|
!contact.subscribed &&
|
||||||
<Image
|
!contact.unsubscribeReason;
|
||||||
src={getGravatarUrl(contact.email, {
|
|
||||||
size: 75,
|
return (
|
||||||
defaultImage: "robohash",
|
<TableRow key={contact.id} className="">
|
||||||
})}
|
<TableCell className="font-medium">
|
||||||
alt={contact.email + "'s gravatar"}
|
<div className="flex items-center gap-2">
|
||||||
width={35}
|
<Image
|
||||||
height={35}
|
src={getGravatarUrl(contact.email, {
|
||||||
className="rounded-full"
|
size: 75,
|
||||||
/>
|
defaultImage: "robohash",
|
||||||
<div className="flex flex-col">
|
})}
|
||||||
<span className="text-sm font-medium">
|
alt={contact.email + "'s gravatar"}
|
||||||
{contact.email}
|
width={35}
|
||||||
</span>
|
height={35}
|
||||||
<span className="text-xs text-muted-foreground">
|
className="rounded-full"
|
||||||
{contact.firstName} {contact.lastName}
|
/>
|
||||||
</span>
|
<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>
|
||||||
</div>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell>
|
{contact.subscribed ? (
|
||||||
{contact.subscribed ? (
|
<div className="text-center w-[130px] rounded capitalize py-1 text-xs bg-green/15 text-green border border-green/25">
|
||||||
<div className="text-center w-[130px] rounded capitalize py-1 text-xs bg-green/15 text-green border border-green/25">
|
Subscribed
|
||||||
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>
|
</div>
|
||||||
) : (
|
</TableCell>
|
||||||
<Tooltip>
|
</TableRow>
|
||||||
<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>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<TableRow className="h-32">
|
<TableRow className="h-32">
|
||||||
<TableCell colSpan={4} className="text-center py-4">
|
<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 AddContact from "./add-contact";
|
||||||
import BulkUploadContacts from "./bulk-upload-contacts";
|
import BulkUploadContacts from "./bulk-upload-contacts";
|
||||||
import ContactList from "./contact-list";
|
import ContactList from "./contact-list";
|
||||||
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import EmojiPicker, { Theme } from "emoji-picker-react";
|
import EmojiPicker, { Theme } from "emoji-picker-react";
|
||||||
import {
|
import {
|
||||||
@@ -22,8 +21,21 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@usesend/ui/src/popover";
|
} from "@usesend/ui/src/popover";
|
||||||
import { Button } from "@usesend/ui/src/button";
|
import { Button } from "@usesend/ui/src/button";
|
||||||
|
import { Switch } from "@usesend/ui/src/switch";
|
||||||
import { useTheme } from "@usesend/ui";
|
import { useTheme } from "@usesend/ui";
|
||||||
import { use } from "react";
|
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({
|
export default function ContactsPage({
|
||||||
params,
|
params,
|
||||||
@@ -125,84 +137,199 @@ export default function ContactsPage({
|
|||||||
<AddContact contactBookId={contactBookId} />
|
<AddContact contactBookId={contactBookId} />
|
||||||
</div>
|
</div>
|
||||||
</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="mt-10">
|
||||||
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<p className="font-semibold mb-1">Metrics</p>
|
{/* Metrics Card */}
|
||||||
<div className="flex items-center gap-2">
|
<Card className="overflow-hidden">
|
||||||
<div className="text-muted-foreground w-[130px] text-sm">
|
<CardHeader className="pb-3">
|
||||||
Total Contacts
|
<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>
|
||||||
<div className="font-mono text-sm">
|
</CardHeader>
|
||||||
{contactBookDetailQuery.data?.totalContacts !== undefined
|
<CardContent className="space-y-4">
|
||||||
? contactBookDetailQuery.data?.totalContacts
|
<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>
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
<div className="text-muted-foreground w-[130px] text-sm">
|
<MailX className="w-3.5 h-3.5" />
|
||||||
Unsubscribed
|
Unsubscribed
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-semibold font-mono text-destructive">
|
||||||
|
{contactBookDetailQuery.data?.unsubscribedContacts !==
|
||||||
|
undefined
|
||||||
|
? contactBookDetailQuery.data?.unsubscribedContacts.toLocaleString()
|
||||||
|
: "--"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-sm">
|
</CardContent>
|
||||||
{contactBookDetailQuery.data?.unsubscribedContacts !== undefined
|
</Card>
|
||||||
? contactBookDetailQuery.data?.unsubscribedContacts
|
|
||||||
: "--"}
|
{/* 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>
|
</CardHeader>
|
||||||
</div>
|
<CardContent className="space-y-4">
|
||||||
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
|
<div className="space-y-1">
|
||||||
<p className="font-semibold">Details</p>
|
<p className="text-xs text-muted-foreground">Contact book ID</p>
|
||||||
<div className="flex items-center gap-2">
|
<TextWithCopyButton
|
||||||
<div className="text-muted-foreground w-[130px] text-sm">
|
value={contactBookId}
|
||||||
Contact book ID
|
alwaysShowCopy
|
||||||
|
className="text-sm font-mono bg-muted px-2 py-1 rounded w-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<TextWithCopyButton
|
<div className="space-y-1">
|
||||||
value={contactBookId}
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
alwaysShowCopy
|
<Calendar className="w-3 h-3" />
|
||||||
className="text-sm w-[130px] overflow-hidden text-ellipsis font-mono"
|
Created
|
||||||
/>
|
</p>
|
||||||
</div>
|
<p className="text-sm">
|
||||||
<div className="flex items-center gap-2">
|
{contactBookDetailQuery.data?.createdAt
|
||||||
<div className="text-muted-foreground w-[130px] text-sm">
|
? formatDistanceToNow(
|
||||||
Created at
|
contactBookDetailQuery.data.createdAt,
|
||||||
|
{
|
||||||
|
addSuffix: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: "--"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm">
|
</CardContent>
|
||||||
{contactBookDetailQuery.data?.createdAt
|
</Card>
|
||||||
? formatDistanceToNow(contactBookDetailQuery.data.createdAt, {
|
|
||||||
addSuffix: true,
|
{/* 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>
|
</CardHeader>
|
||||||
</div>
|
<CardContent>
|
||||||
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
|
{!contactBookDetailQuery.isLoading &&
|
||||||
<p className="font-semibold">Recent campaigns</p>
|
contactBookDetailQuery.data?.campaigns.length === 0 ? (
|
||||||
{!contactBookDetailQuery.isLoading &&
|
<div className="text-muted-foreground text-sm py-4 text-center">
|
||||||
contactBookDetailQuery.data?.campaigns.length === 0 ? (
|
No campaigns yet.
|
||||||
<div className="text-muted-foreground text-sm">
|
</div>
|
||||||
No campaigns yet.
|
) : (
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
) : null}
|
{contactBookDetailQuery.data?.campaigns
|
||||||
{contactBookDetailQuery.data?.campaigns.map((campaign) => (
|
.slice(0, 5)
|
||||||
<div key={campaign.id} className="flex items-center gap-2">
|
.map((campaign) => (
|
||||||
<Link href={`/campaigns/${campaign.id}`}>
|
<Link
|
||||||
<div className="text-sm hover:underline hover:decoration-dashed text-nowrap w-[200px] overflow-hidden text-ellipsis">
|
key={campaign.id}
|
||||||
{campaign.name}
|
href={`/campaigns/${campaign.id}`}
|
||||||
</div>
|
className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/50 transition-colors group"
|
||||||
</Link>
|
>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{formatDistanceToNow(campaign.createdAt, {})}
|
<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>
|
||||||
))}
|
<Switch
|
||||||
</div>
|
checked={
|
||||||
</div>
|
contactBookDetailQuery.data?.doubleOptInEnabled ?? false
|
||||||
<div className="mt-16">
|
}
|
||||||
<ContactList
|
onCheckedChange={(checked) => {
|
||||||
contactBookId={contactBookId}
|
updateContactBookMutation.mutate({
|
||||||
contactBookName={contactBookDetailQuery.data?.name}
|
contactBookId,
|
||||||
/>
|
doubleOptInEnabled: checked,
|
||||||
</div>
|
});
|
||||||
|
}}
|
||||||
|
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>
|
||||||
</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";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const ContactBookSchema = z.object({
|
export const ContactBookSchema = z.object({
|
||||||
id: z
|
id: z.string().openapi({
|
||||||
.string()
|
description: "The ID of the contact book",
|
||||||
.openapi({ description: "The ID of the contact book", example: "clx1234567890" }),
|
example: "clx1234567890",
|
||||||
name: z
|
}),
|
||||||
.string()
|
name: z.string().openapi({
|
||||||
.openapi({ description: "The name of the contact book", example: "Newsletter Subscribers" }),
|
description: "The name of the contact book",
|
||||||
|
example: "Newsletter Subscribers",
|
||||||
|
}),
|
||||||
teamId: z.number().openapi({ description: "The ID of the team", example: 1 }),
|
teamId: z.number().openapi({ description: "The ID of the team", example: 1 }),
|
||||||
properties: z.record(z.string()).openapi({
|
properties: z.record(z.string()).openapi({
|
||||||
description: "Custom properties for the contact book",
|
description: "Custom properties for the contact book",
|
||||||
example: { customField1: "value1" },
|
example: { customField1: "value1" },
|
||||||
}),
|
}),
|
||||||
emoji: z
|
emoji: z.string().openapi({
|
||||||
.string()
|
description: "The emoji associated with the contact book",
|
||||||
.openapi({ description: "The emoji associated with the contact book", example: "📙" }),
|
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" }),
|
createdAt: z.string().openapi({ description: "The creation timestamp" }),
|
||||||
updatedAt: z.string().openapi({ description: "The last update timestamp" }),
|
updatedAt: z.string().openapi({ description: "The last update timestamp" }),
|
||||||
_count: z.object({
|
_count: z
|
||||||
contacts: z.number().openapi({ description: "The number of contacts in the contact book" }),
|
.object({
|
||||||
}).optional(),
|
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(),
|
name: z.string().optional(),
|
||||||
properties: z.record(z.string()).optional(),
|
properties: z.record(z.string()).optional(),
|
||||||
emoji: 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 }) => {
|
.mutation(async ({ ctx: { contactBook }, input }) => {
|
||||||
@@ -190,6 +194,45 @@ export const contactsRouter = createTRPCRouter({
|
|||||||
return deletedContact;
|
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
|
exportContacts: contactBookProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
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 { createRoute, z } from "@hono/zod-openapi";
|
||||||
import { ContactBookSchema } from "~/lib/zod/contact-book-schema";
|
import { ContactBookSchema } from "~/lib/zod/contact-book-schema";
|
||||||
|
import { db } from "~/server/db";
|
||||||
import { PublicAPIApp } from "~/server/public-api/hono";
|
import { PublicAPIApp } from "~/server/public-api/hono";
|
||||||
import {
|
import {
|
||||||
createContactBook as createContactBookService,
|
createContactBook as createContactBookService,
|
||||||
updateContactBook,
|
updateContactBook,
|
||||||
} from "~/server/service/contact-book-service";
|
} from "~/server/service/contact-book-service";
|
||||||
|
|
||||||
const route = createRoute({
|
const route = createRoute({
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/v1/contactBooks",
|
path: "/v1/contactBooks",
|
||||||
request: {
|
request: {
|
||||||
body: {
|
body: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
emoji: z.string().optional(),
|
emoji: z.string().optional(),
|
||||||
properties: z.record(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,
|
responses: {
|
||||||
},
|
200: {
|
||||||
},
|
content: {
|
||||||
description: "Create a new contact book",
|
"application/json": {
|
||||||
},
|
schema: ContactBookSchema,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
description: "Create a new contact book",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function createContactBook(app: PublicAPIApp) {
|
function createContactBook(app: PublicAPIApp) {
|
||||||
app.openapi(route, async (c) => {
|
app.openapi(route, async (c) => {
|
||||||
const team = c.var.team;
|
const team = c.var.team;
|
||||||
const body = c.req.valid("json");
|
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
|
const contactBook = await db.$transaction(async (tx) => {
|
||||||
if (body.emoji || body.properties) {
|
const created = await createContactBookService(team.id, body.name, tx);
|
||||||
const updated = await updateContactBook(contactBook.id, {
|
|
||||||
emoji: body.emoji,
|
|
||||||
properties: body.properties,
|
|
||||||
});
|
|
||||||
|
|
||||||
return c.json({
|
if (!hasOptionalFields) {
|
||||||
...updated,
|
return created;
|
||||||
properties: updated.properties as Record<string, string>,
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({
|
return updateContactBook(
|
||||||
...contactBook,
|
created.id,
|
||||||
properties: contactBook.properties as Record<string, string>,
|
{
|
||||||
});
|
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;
|
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";
|
import { getContactBook } from "../../api-utils";
|
||||||
|
|
||||||
const route = createRoute({
|
const route = createRoute({
|
||||||
method: "patch",
|
method: "patch",
|
||||||
path: "/v1/contactBooks/{contactBookId}",
|
path: "/v1/contactBooks/{contactBookId}",
|
||||||
request: {
|
request: {
|
||||||
params: z.object({
|
params: z.object({
|
||||||
contactBookId: z.string().openapi({
|
contactBookId: z.string().openapi({
|
||||||
param: {
|
param: {
|
||||||
name: "contactBookId",
|
name: "contactBookId",
|
||||||
in: "path",
|
in: "path",
|
||||||
},
|
},
|
||||||
example: "clx1234567890",
|
example: "clx1234567890",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
body: {
|
body: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
name: z.string().min(1).optional(),
|
name: z.string().min(1).optional(),
|
||||||
emoji: z.string().optional(),
|
emoji: z.string().optional(),
|
||||||
properties: z.record(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,
|
responses: {
|
||||||
},
|
200: {
|
||||||
},
|
content: {
|
||||||
description: "Update the contact book",
|
"application/json": {
|
||||||
},
|
schema: ContactBookSchema,
|
||||||
403: {
|
},
|
||||||
content: {
|
},
|
||||||
"application/json": {
|
description: "Update the contact book",
|
||||||
schema: z.object({
|
},
|
||||||
error: z.string(),
|
403: {
|
||||||
}),
|
content: {
|
||||||
},
|
"application/json": {
|
||||||
},
|
schema: z.object({
|
||||||
description:
|
error: z.string(),
|
||||||
"Forbidden - API key doesn't have access to this contact book",
|
}),
|
||||||
},
|
},
|
||||||
404: {
|
},
|
||||||
content: {
|
description:
|
||||||
"application/json": {
|
"Forbidden - API key doesn't have access to this contact book",
|
||||||
schema: z.object({
|
},
|
||||||
error: z.string(),
|
404: {
|
||||||
}),
|
content: {
|
||||||
},
|
"application/json": {
|
||||||
},
|
schema: z.object({
|
||||||
description: "Contact book not found",
|
error: z.string(),
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
description: "Contact book not found",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateContactBook(app: PublicAPIApp) {
|
function updateContactBook(app: PublicAPIApp) {
|
||||||
app.openapi(route, async (c) => {
|
app.openapi(route, async (c) => {
|
||||||
const team = c.var.team;
|
const team = c.var.team;
|
||||||
const contactBookId = c.req.valid("param").contactBookId;
|
const contactBookId = c.req.valid("param").contactBookId;
|
||||||
const body = c.req.valid("json");
|
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({
|
return c.json({
|
||||||
...updated,
|
...updated,
|
||||||
properties: updated.properties as Record<string, string>,
|
properties: updated.properties as Record<string, string>,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default updateContactBook;
|
export default updateContactBook;
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { CampaignStatus, type ContactBook } from "@prisma/client";
|
import { CampaignStatus } from "@prisma/client";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { LimitService } from "./limit-service";
|
import { LimitService } from "./limit-service";
|
||||||
import { UnsendApiError } from "../public-api/api-error";
|
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) {
|
export async function getContactBooks(teamId: number, search?: string) {
|
||||||
return db.contactBook.findMany({
|
return db.contactBook.findMany({
|
||||||
@@ -9,7 +17,18 @@ export async function getContactBooks(teamId: number, search?: string) {
|
|||||||
teamId,
|
teamId,
|
||||||
...(search ? { name: { contains: search, mode: "insensitive" } } : {}),
|
...(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: {
|
_count: {
|
||||||
select: { contacts: true },
|
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 } =
|
const { isLimitReached, reason } =
|
||||||
await LimitService.checkContactBookLimit(teamId);
|
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: {
|
data: {
|
||||||
name,
|
name,
|
||||||
teamId,
|
teamId,
|
||||||
properties: {},
|
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;
|
name?: string;
|
||||||
properties?: Record<string, string>;
|
properties?: Record<string, string>;
|
||||||
emoji?: 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 },
|
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 {
|
import {
|
||||||
type ContactPayload,
|
type ContactPayload,
|
||||||
type ContactWebhookEventType,
|
type ContactWebhookEventType,
|
||||||
@@ -7,6 +7,7 @@ import { db } from "../db";
|
|||||||
import { ContactQueueService } from "./contact-queue-service";
|
import { ContactQueueService } from "./contact-queue-service";
|
||||||
import { WebhookService } from "./webhook-service";
|
import { WebhookService } from "./webhook-service";
|
||||||
import { logger } from "../logger/log";
|
import { logger } from "../logger/log";
|
||||||
|
import { sendDoubleOptInConfirmationEmail } from "./double-opt-in-service";
|
||||||
|
|
||||||
export type ContactInput = {
|
export type ContactInput = {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -21,6 +22,20 @@ export async function addOrUpdateContact(
|
|||||||
contact: ContactInput,
|
contact: ContactInput,
|
||||||
teamId?: number,
|
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
|
// Check if contact exists to handle subscribed logic
|
||||||
const existingContact = await db.contact.findUnique({
|
const existingContact = await db.contact.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -31,6 +46,7 @@ export async function addOrUpdateContact(
|
|||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
subscribed: true,
|
subscribed: true,
|
||||||
|
unsubscribeReason: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,6 +61,20 @@ export async function addOrUpdateContact(
|
|||||||
// All other cases (Yes→No, Yes→Yes, No→No) are allowed naturally
|
// 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({
|
const savedContact = await db.contact.upsert({
|
||||||
where: {
|
where: {
|
||||||
contactBookId_email: {
|
contactBookId_email: {
|
||||||
@@ -58,16 +88,50 @@ export async function addOrUpdateContact(
|
|||||||
firstName: contact.firstName,
|
firstName: contact.firstName,
|
||||||
lastName: contact.lastName,
|
lastName: contact.lastName,
|
||||||
properties: contact.properties ?? {},
|
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: {
|
update: {
|
||||||
firstName: contact.firstName,
|
firstName: contact.firstName,
|
||||||
lastName: contact.lastName,
|
lastName: contact.lastName,
|
||||||
properties: contact.properties ?? {},
|
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
|
const eventType: ContactWebhookEventType = existingContact
|
||||||
? "contact.updated"
|
? "contact.updated"
|
||||||
: "contact.created";
|
: "contact.created";
|
||||||
@@ -108,7 +172,16 @@ export async function updateContactInContactBook(
|
|||||||
where: {
|
where: {
|
||||||
id: contactId,
|
id: contactId,
|
||||||
},
|
},
|
||||||
data: contact,
|
data: {
|
||||||
|
...contact,
|
||||||
|
...(contact.subscribed !== undefined
|
||||||
|
? {
|
||||||
|
unsubscribeReason: contact.subscribed
|
||||||
|
? null
|
||||||
|
: UnsubscribeReason.UNSUBSCRIBED,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await emitContactEvent(updatedContact, "contact.updated", teamId);
|
await emitContactEvent(updatedContact, "contact.updated", teamId);
|
||||||
@@ -141,6 +214,51 @@ export async function deleteContactInContactBook(
|
|||||||
return deletedContact;
|
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(
|
export async function bulkAddContacts(
|
||||||
contactBookId: string,
|
contactBookId: string,
|
||||||
contacts: Array<ContactInput>,
|
contacts: Array<ContactInput>,
|
||||||
@@ -161,6 +279,7 @@ export async function unsubscribeContact(contactId: string) {
|
|||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
subscribed: false,
|
subscribed: false,
|
||||||
|
unsubscribeReason: UnsubscribeReason.UNSUBSCRIBED,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -172,6 +291,7 @@ export async function subscribeContact(contactId: string) {
|
|||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
subscribed: true,
|
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",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
@@ -56,7 +56,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
showSpinner = false,
|
showSpinner = false,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const Comp = asChild ? Slot : "button";
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
@@ -67,13 +67,19 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
disabled={isLoading || props.disabled}
|
disabled={isLoading || props.disabled}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{isLoading && showSpinner ? (
|
{asChild ? (
|
||||||
<Spinner className="h-4 w-4 mr-2 " innerSvgClass="stroke-white" />
|
children
|
||||||
) : null}
|
) : (
|
||||||
{children}
|
<>
|
||||||
|
{isLoading && showSpinner ? (
|
||||||
|
<Spinner className="h-4 w-4 mr-2 " innerSvgClass="stroke-white" />
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Comp>
|
</Comp>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
Button.displayName = "Button";
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user