diff --git a/apps/web/prisma/migrations/20250930120000_add_double_opt_in/migration.sql b/apps/web/prisma/migrations/20250930120000_add_double_opt_in/migration.sql
new file mode 100644
index 00000000..21171b30
--- /dev/null
+++ b/apps/web/prisma/migrations/20250930120000_add_double_opt_in/migration.sql
@@ -0,0 +1,17 @@
+-- Add double opt-in supporting columns
+ALTER TABLE "Domain" ADD COLUMN "defaultFrom" TEXT;
+
+ALTER TABLE "ContactBook"
+ ADD COLUMN "defaultDomainId" INTEGER,
+ ADD COLUMN "doubleOptInEnabled" BOOLEAN NOT NULL DEFAULT false,
+ ADD COLUMN "doubleOptInTemplateId" TEXT;
+
+-- Indexes for new foreign keys
+CREATE INDEX "ContactBook_defaultDomainId_idx" ON "ContactBook"("defaultDomainId");
+CREATE INDEX "ContactBook_doubleOptInTemplateId_idx" ON "ContactBook"("doubleOptInTemplateId");
+
+-- Foreign key constraints
+ALTER TABLE "ContactBook"
+ ADD CONSTRAINT "ContactBook_defaultDomainId_fkey" FOREIGN KEY ("defaultDomainId") REFERENCES "Domain"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "ContactBook"
+ ADD CONSTRAINT "ContactBook_doubleOptInTemplateId_fkey" FOREIGN KEY ("doubleOptInTemplateId") REFERENCES "Template"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma
index 8e9e546a..2ae4d324 100644
--- a/apps/web/prisma/schema.prisma
+++ b/apps/web/prisma/schema.prisma
@@ -177,26 +177,28 @@ enum DomainStatus {
}
model Domain {
- id Int @id @default(autoincrement())
- name String @unique
+ id Int @id @default(autoincrement())
+ name String @unique
teamId Int
- status DomainStatus @default(PENDING)
- region String @default("us-east-1")
- clickTracking Boolean @default(false)
- openTracking Boolean @default(false)
+ status DomainStatus @default(PENDING)
+ region String @default("us-east-1")
+ clickTracking Boolean @default(false)
+ openTracking Boolean @default(false)
publicKey String
- dkimSelector String? @default("usesend")
+ dkimSelector String? @default("usesend")
dkimStatus String?
spfDetails String?
- dmarcAdded Boolean @default(false)
+ dmarcAdded Boolean @default(false)
errorMessage String?
subdomain String?
sesTenantId String?
- isVerifying Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
+ isVerifying Boolean @default(false)
+ defaultFrom String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
apiKeys ApiKey[]
+ ContactBook ContactBook[]
}
enum ApiPermission {
@@ -279,17 +281,24 @@ model EmailEvent {
}
model ContactBook {
- id String @id @default(cuid())
- name String
- teamId Int
- properties Json
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- emoji String @default("📙")
- team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
- contacts Contact[]
+ id String @id @default(cuid())
+ name String
+ teamId Int
+ properties Json
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ emoji String @default("📙")
+ defaultDomainId Int?
+ doubleOptInEnabled Boolean @default(false)
+ doubleOptInTemplateId String?
+ team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
+ contacts Contact[]
+ defaultDomain Domain? @relation(fields: [defaultDomainId], references: [id], onDelete: SetNull, onUpdate: Cascade)
+ doubleOptInTemplate Template? @relation(fields: [doubleOptInTemplateId], references: [id], onDelete: SetNull, onUpdate: Cascade)
@@index([teamId])
+ @@index([defaultDomainId])
+ @@index([doubleOptInTemplateId])
}
enum UnsubscribeReason {
@@ -369,7 +378,8 @@ model Template {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
+ team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
+ ContactBook ContactBook[]
@@index([createdAt(sort: Desc)])
}
diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx
index 3d50c486..563711f0 100644
--- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx
+++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx
@@ -120,6 +120,11 @@ export default function ContactsPage({
>({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ doubleOptInEnabled: false,
+ defaultDomainId: null,
+ doubleOptInTemplateId: null,
+ },
+ });
+
+ useEffect(() => {
+ if (!settingsQuery.data) return;
+ const { contactBook } = settingsQuery.data;
+ form.reset({
+ doubleOptInEnabled: contactBook.doubleOptInEnabled,
+ defaultDomainId: contactBook.defaultDomainId
+ ? String(contactBook.defaultDomainId)
+ : null,
+ doubleOptInTemplateId: contactBook.doubleOptInTemplateId,
+ });
+ }, [settingsQuery.data, form]);
+
+ const onSubmit = form.handleSubmit((values) => {
+ updateMutation.mutate({
+ contactBookId,
+ doubleOptInEnabled: values.doubleOptInEnabled,
+ defaultDomainId: values.defaultDomainId
+ ? Number(values.defaultDomainId)
+ : null,
+ doubleOptInTemplateId: values.doubleOptInTemplateId,
+ });
+ });
+
+ if (settingsQuery.isLoading || !settingsQuery.data) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ const { domains, templates } = settingsQuery.data;
+
+ const disableSelectorsBase =
+ !form.watch("doubleOptInEnabled") || domains.length === 0;
+ const disableDomainSelect = disableSelectorsBase;
+ const disableTemplateSelect = disableSelectorsBase || templates.length === 0;
+
+ return (
+
+
+
Double opt-in
+
+ Require new contacts to confirm their email address before they are
+ subscribed.
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/confirm/page.tsx b/apps/web/src/app/confirm/page.tsx
new file mode 100644
index 00000000..f7111af5
--- /dev/null
+++ b/apps/web/src/app/confirm/page.tsx
@@ -0,0 +1,80 @@
+import { confirmContactFromLink } from "~/server/service/double-opt-in-service";
+
+export const dynamic = "force-dynamic";
+
+async function ConfirmSubscriptionPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
+}) {
+ const params = await searchParams;
+
+ const id = params.id as string;
+ const hash = params.hash as string;
+
+ if (!id || !hash) {
+ return (
+
+
+
+ Confirm Subscription
+
+
+ Invalid confirmation link. Please check your URL and try again.
+
+
+
+ );
+ }
+
+ try {
+ const { confirmed } = await confirmContactFromLink(id, hash);
+
+ return (
+
+
+
+ Confirm Subscription
+
+
+ {confirmed
+ ? "Your email has been confirmed. Thanks for subscribing!"
+ : "We could not confirm your email yet. Please try again later."}
+
+
+
+
+ );
+ } catch (error) {
+ return (
+
+
+
+ Confirm Subscription
+
+
+ Invalid or expired confirmation link. Please contact the sender for
+ a new email.
+
+
+
+
+ );
+ }
+}
+
+export default ConfirmSubscriptionPage;
diff --git a/apps/web/src/server/api/routers/contacts.ts b/apps/web/src/server/api/routers/contacts.ts
index bfe1dc5b..b563cd17 100644
--- a/apps/web/src/server/api/routers/contacts.ts
+++ b/apps/web/src/server/api/routers/contacts.ts
@@ -41,6 +41,12 @@ export const contactsRouter = createTRPCRouter({
}
),
+ getContactBookSettings: contactBookProcedure.query(
+ async ({ ctx: { contactBook } }) => {
+ return contactBookService.getContactBookSettings(contactBook.id);
+ }
+ ),
+
updateContactBook: contactBookProcedure
.input(
z.object({
@@ -48,10 +54,14 @@ export const contactsRouter = createTRPCRouter({
name: z.string().optional(),
properties: z.record(z.string()).optional(),
emoji: z.string().optional(),
+ defaultDomainId: z.number().nullable().optional(),
+ doubleOptInEnabled: z.boolean().optional(),
+ doubleOptInTemplateId: z.string().nullable().optional(),
})
)
.mutation(async ({ ctx: { contactBook }, input }) => {
- return contactBookService.updateContactBook(contactBook.id, input);
+ const { contactBookId, ...payload } = input;
+ return contactBookService.updateContactBook(contactBook.id, payload);
}),
deleteContactBook: contactBookProcedure
diff --git a/apps/web/src/server/api/routers/domain.ts b/apps/web/src/server/api/routers/domain.ts
index 11c0570c..a3235040 100644
--- a/apps/web/src/server/api/routers/domain.ts
+++ b/apps/web/src/server/api/routers/domain.ts
@@ -11,6 +11,7 @@ import {
createDomain,
deleteDomain,
getDomain,
+ resolveFromAddress,
updateDomain,
} from "~/server/service/domain-service";
import { sendEmail } from "~/server/service/email-service";
@@ -96,10 +97,15 @@ export const domainRouter = createTRPCRouter({
throw new Error("User email not found");
}
+ const fromAddress = resolveFromAddress({
+ name: domain.name,
+ defaultFrom: (domain as any).defaultFrom ?? null,
+ });
+
return sendEmail({
teamId: team.id,
to: user.email,
- from: `hello@${domain.name}`,
+ from: fromAddress,
subject: "useSend test email",
text: "hello,\n\nuseSend is the best open source sending platform\n\ncheck out https://usesend.com",
html: "hello,
useSend is the best open source sending platform
check out usesend.com",
diff --git a/apps/web/src/server/mailer.ts b/apps/web/src/server/mailer.ts
index 4a4cbc22..e081fa23 100644
--- a/apps/web/src/server/mailer.ts
+++ b/apps/web/src/server/mailer.ts
@@ -2,7 +2,7 @@ import { env } from "~/env";
import { UseSend } from "usesend-js";
import { isSelfHosted } from "~/utils/common";
import { db } from "./db";
-import { getDomains } from "./service/domain-service";
+import { getDomains, resolveFromAddress } from "./service/domain-service";
import { sendEmail } from "./service/email-service";
import { logger } from "./logger/log";
import { renderOtpEmail, renderTeamInviteEmail } from "./email-templates";
@@ -101,10 +101,15 @@ export async function sendMail(
const domain =
domains.find((d) => d.name === fromEmailDomain) ?? domains[0];
+ const fromAddress = resolveFromAddress({
+ name: domain.name,
+ defaultFrom: (domain as any).defaultFrom ?? null,
+ });
+
await sendEmail({
teamId: team.id,
to: email,
- from: `hello@${domain.name}`,
+ from: fromAddress,
subject,
text,
html,
diff --git a/apps/web/src/server/service/contact-book-service.ts b/apps/web/src/server/service/contact-book-service.ts
index 2e7043ed..18a24db7 100644
--- a/apps/web/src/server/service/contact-book-service.ts
+++ b/apps/web/src/server/service/contact-book-service.ts
@@ -1,7 +1,13 @@
-import { CampaignStatus, type ContactBook } from "@prisma/client";
+import { CampaignStatus } from "@prisma/client";
import { db } from "../db";
import { LimitService } from "./limit-service";
import { UnsendApiError } from "../public-api/api-error";
+import {
+ assertTemplateSupportsDoubleOptIn,
+ ensureDefaultDoubleOptInTemplate,
+ templateSupportsDoubleOptIn,
+} from "./double-opt-in-service";
+import { getVerifiedDomains } from "./domain-service";
export async function getContactBooks(teamId: number, search?: string) {
return db.contactBook.findMany({
@@ -72,8 +78,75 @@ export async function updateContactBook(
name?: string;
properties?: Record;
emoji?: string;
+ defaultDomainId?: number | null;
+ doubleOptInEnabled?: boolean;
+ doubleOptInTemplateId?: string | null;
}
) {
+ const contactBook = await db.contactBook.findUnique({
+ where: { id: contactBookId },
+ });
+
+ if (!contactBook) {
+ throw new UnsendApiError({
+ code: "NOT_FOUND",
+ message: "Contact book not found",
+ });
+ }
+
+ const nextDoubleOptInEnabled =
+ data.doubleOptInEnabled ?? contactBook.doubleOptInEnabled;
+ const nextTemplateId =
+ data.doubleOptInTemplateId ?? contactBook.doubleOptInTemplateId;
+ const nextDomainId = data.defaultDomainId ?? contactBook.defaultDomainId;
+
+ if (nextDoubleOptInEnabled) {
+ if (!nextDomainId) {
+ throw new UnsendApiError({
+ code: "BAD_REQUEST",
+ message: "Select a verified domain before enabling double opt-in",
+ });
+ }
+
+ const domain = await db.domain.findFirst({
+ where: {
+ id: nextDomainId,
+ teamId: contactBook.teamId,
+ status: "SUCCESS",
+ },
+ });
+
+ if (!domain) {
+ throw new UnsendApiError({
+ code: "BAD_REQUEST",
+ message: "Domain must be verified before enabling double opt-in",
+ });
+ }
+
+ if (!nextTemplateId) {
+ throw new UnsendApiError({
+ code: "BAD_REQUEST",
+ message: "Select a template before enabling double opt-in",
+ });
+ }
+
+ const template = await db.template.findFirst({
+ where: {
+ id: nextTemplateId,
+ teamId: contactBook.teamId,
+ },
+ });
+
+ if (!template) {
+ throw new UnsendApiError({
+ code: "BAD_REQUEST",
+ message: "Template not found",
+ });
+ }
+
+ assertTemplateSupportsDoubleOptIn(template);
+ }
+
return db.contactBook.update({
where: { id: contactBookId },
data,
@@ -85,3 +158,48 @@ export async function deleteContactBook(contactBookId: string) {
return deleted;
}
+
+export async function getContactBookSettings(contactBookId: string) {
+ const contactBook = await db.contactBook.findUnique({
+ where: { id: contactBookId },
+ include: {
+ defaultDomain: true,
+ doubleOptInTemplate: true,
+ },
+ });
+
+ if (!contactBook) {
+ throw new UnsendApiError({
+ code: "NOT_FOUND",
+ message: "Contact book not found",
+ });
+ }
+
+ await ensureDefaultDoubleOptInTemplate(contactBook.teamId);
+
+ const [domains, templates] = await Promise.all([
+ getVerifiedDomains(contactBook.teamId),
+ db.template.findMany({
+ where: { teamId: contactBook.teamId },
+ orderBy: { createdAt: "desc" },
+ select: {
+ id: true,
+ name: true,
+ subject: true,
+ html: true,
+ content: true,
+ createdAt: true,
+ },
+ }),
+ ]);
+
+ const eligibleTemplates = templates.filter((template) =>
+ templateSupportsDoubleOptIn(template)
+ );
+
+ return {
+ contactBook,
+ domains,
+ templates: eligibleTemplates,
+ };
+}
diff --git a/apps/web/src/server/service/contact-service.ts b/apps/web/src/server/service/contact-service.ts
index d42ce73d..4d050204 100644
--- a/apps/web/src/server/service/contact-service.ts
+++ b/apps/web/src/server/service/contact-service.ts
@@ -1,4 +1,9 @@
import { db } from "../db";
+import {
+ sendDoubleOptInEmail,
+ templateSupportsDoubleOptIn,
+} from "./double-opt-in-service";
+import { UnsendApiError } from "../public-api/api-error";
export type ContactInput = {
email: string;
@@ -12,6 +17,58 @@ export async function addOrUpdateContact(
contactBookId: string,
contact: ContactInput
) {
+ const contactBook = await db.contactBook.findUnique({
+ where: { id: contactBookId },
+ include: {
+ defaultDomain: true,
+ doubleOptInTemplate: true,
+ },
+ });
+
+ if (!contactBook) {
+ throw new UnsendApiError({
+ code: "NOT_FOUND",
+ message: "Contact book not found",
+ });
+ }
+
+ const existingContact = await db.contact.findUnique({
+ where: {
+ contactBookId_email: {
+ contactBookId,
+ email: contact.email,
+ },
+ },
+ });
+
+ const doubleOptInActive =
+ contactBook.doubleOptInEnabled &&
+ contactBook.doubleOptInTemplateId &&
+ contactBook.defaultDomainId;
+
+ if (doubleOptInActive) {
+ if (!contactBook.doubleOptInTemplate || !contactBook.defaultDomain) {
+ throw new UnsendApiError({
+ code: "BAD_REQUEST",
+ message: "Double opt-in configuration is incomplete",
+ });
+ }
+
+ if (!templateSupportsDoubleOptIn(contactBook.doubleOptInTemplate)) {
+ throw new UnsendApiError({
+ code: "BAD_REQUEST",
+ message: "Double opt-in template must include {{verificationUrl}}",
+ });
+ }
+ }
+
+ const requestedSubscribed =
+ contact.subscribed === undefined ? true : contact.subscribed;
+
+ const subscribedValue = doubleOptInActive
+ ? existingContact?.subscribed ?? false
+ : requestedSubscribed;
+
const createdContact = await db.contact.upsert({
where: {
contactBookId_email: {
@@ -25,16 +82,37 @@ export async function addOrUpdateContact(
firstName: contact.firstName,
lastName: contact.lastName,
properties: contact.properties ?? {},
- subscribed: contact.subscribed,
+ subscribed: subscribedValue,
+ ...(doubleOptInActive ? { unsubscribeReason: null } : {}),
},
update: {
firstName: contact.firstName,
lastName: contact.lastName,
properties: contact.properties ?? {},
- subscribed: contact.subscribed,
+ subscribed: subscribedValue,
+ ...(doubleOptInActive && requestedSubscribed
+ ? { unsubscribeReason: null }
+ : {}),
},
});
+ const shouldSendDoubleOptInEmail =
+ doubleOptInActive &&
+ (!existingContact || (requestedSubscribed && existingContact.subscribed === false));
+
+ if (shouldSendDoubleOptInEmail) {
+ await sendDoubleOptInEmail({
+ contact: createdContact,
+ contactBook: contactBook as typeof contactBook & {
+ doubleOptInEnabled: boolean;
+ },
+ template: contactBook.doubleOptInTemplate!,
+ domain: contactBook.defaultDomain as typeof contactBook.defaultDomain & {
+ defaultFrom: string | null;
+ },
+ });
+ }
+
return createdContact;
}
@@ -42,11 +120,45 @@ export async function updateContact(
contactId: string,
contact: Partial
) {
+ const existing = await db.contact.findUnique({
+ where: { id: contactId },
+ include: {
+ contactBook: true,
+ },
+ });
+
+ if (!existing) {
+ throw new UnsendApiError({
+ code: "NOT_FOUND",
+ message: "Contact not found",
+ });
+ }
+
+ const doubleOptInActive =
+ existing.contactBook.doubleOptInEnabled &&
+ existing.contactBook.doubleOptInTemplateId &&
+ existing.contactBook.defaultDomainId;
+
+ const data: Partial & { subscribed?: boolean } = {
+ ...contact,
+ };
+
+ if (doubleOptInActive && contact.subscribed) {
+ throw new UnsendApiError({
+ code: "BAD_REQUEST",
+ message: "Contact can only subscribe via confirmation link",
+ });
+ }
+
+ if (doubleOptInActive && contact.subscribed === undefined) {
+ delete data.subscribed;
+ }
+
return db.contact.update({
where: {
id: contactId,
},
- data: contact,
+ data,
});
}
diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts
index 76c9760c..c7f0d9cc 100644
--- a/apps/web/src/server/service/domain-service.ts
+++ b/apps/web/src/server/service/domain-service.ts
@@ -6,7 +6,7 @@ import { db } from "~/server/db";
import { SesSettingsService } from "./ses-settings-service";
import { UnsendApiError } from "../public-api/api-error";
import { logger } from "../logger/log";
-import { ApiKey } from "@prisma/client";
+import { ApiKey, Domain } from "@prisma/client";
import { LimitService } from "./limit-service";
const dnsResolveTxt = util.promisify(dns.resolveTxt);
@@ -56,6 +56,18 @@ export async function validateDomainFromEmail(email: string, teamId: number) {
return domain;
}
+type DomainWithDefaultFrom = Domain & { defaultFrom: string | null };
+
+export function resolveFromAddress(
+ domain: Pick
+) {
+ if (domain.defaultFrom && domain.defaultFrom.trim().length > 0) {
+ return domain.defaultFrom.trim();
+ }
+
+ return `hello@${domain.name}`;
+}
+
export async function validateApiKeyDomainAccess(
email: string,
teamId: number,
@@ -236,6 +248,18 @@ export async function getDomains(teamId: number) {
});
}
+export async function getVerifiedDomains(teamId: number) {
+ return db.domain.findMany({
+ where: {
+ teamId,
+ status: "SUCCESS",
+ },
+ orderBy: {
+ createdAt: "desc",
+ },
+ });
+}
+
async function getDmarcRecord(domain: string) {
try {
const dmarcRecord = await dnsResolveTxt(`_dmarc.${domain}`);
diff --git a/apps/web/src/server/service/double-opt-in-service.ts b/apps/web/src/server/service/double-opt-in-service.ts
new file mode 100644
index 00000000..2613136a
--- /dev/null
+++ b/apps/web/src/server/service/double-opt-in-service.ts
@@ -0,0 +1,194 @@
+import { createHash } from "crypto";
+import type { Contact, ContactBook, Domain, Template } from "@prisma/client";
+
+import { env } from "~/env";
+import { db } from "../db";
+import { logger } from "../logger/log";
+import { resolveFromAddress } from "./domain-service";
+import { UnsendApiError } from "../public-api/api-error";
+import { sendEmail } from "./email-service";
+
+export const DOUBLE_OPT_IN_PLACEHOLDER = "{{verificationUrl}}";
+export const DOUBLE_OPT_IN_ROUTE = "/confirm";
+export const DOUBLE_OPT_IN_TEMPLATE_NAME = "Double Opt In";
+const DOUBLE_OPT_IN_TEMPLATE_SUBJECT = "Confirm your email";
+const DOUBLE_OPT_IN_TEMPLATE_HTML =
+ "Hey there,
Welcome to [Product name]. Please click the link below to verify your email address to get started.
Confirm
Best
";
+const DOUBLE_OPT_IN_TEMPLATE_CONTENT =
+ '{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null},"content":[{"type":"text","text":"Hey there,"}]},{"type":"paragraph","attrs":{"textAlign":null},"content":[{"type":"text","text":"Welcome to [Product name]. Please click the link below to verify your email address to get started."}]},{"type":"paragraph","attrs":{"textAlign":null}},{"type":"button","attrs":{"component":"button","text":"Confirm","url":"{{verificationUrl}}","alignment":"left","borderRadius":"4","borderWidth":"1","buttonColor":"rgb(0, 0, 0)","borderColor":"rgb(0, 0, 0)","textColor":"rgb(255, 255, 255)"}},{"type":"paragraph","attrs":{"textAlign":null}},{"type":"paragraph","attrs":{"textAlign":null},"content":[{"type":"text","text":"Best"}]}]}';
+
+type ContactBookWithSettings = ContactBook & {
+ defaultDomainId: number | null;
+ doubleOptInEnabled: boolean;
+ doubleOptInTemplateId: string | null;
+};
+
+type DomainWithDefaultFrom = Domain & { defaultFrom: string | null };
+
+type TemplateWithContent = Template & { content: string | null; html: string | null };
+
+export function createDoubleOptInIdentifier(
+ contactId: string,
+ contactBookId: string
+) {
+ return `${contactId}-${contactBookId}`;
+}
+
+function createDoubleOptInHash(identifier: string) {
+ return createHash("sha256")
+ .update(`${identifier}-${env.NEXTAUTH_SECRET}`)
+ .digest("hex");
+}
+
+export function createDoubleOptInUrl(
+ contactId: string,
+ contactBookId: string
+) {
+ const identifier = createDoubleOptInIdentifier(contactId, contactBookId);
+ const hash = createDoubleOptInHash(identifier);
+
+ return `${env.NEXTAUTH_URL}${DOUBLE_OPT_IN_ROUTE}?id=${identifier}&hash=${hash}`;
+}
+
+export function templateSupportsDoubleOptIn(template: {
+ html: string | null;
+ content: string | null;
+}) {
+ if (template.html && template.html.includes(DOUBLE_OPT_IN_PLACEHOLDER)) {
+ return true;
+ }
+
+ if (!template.content) {
+ return false;
+ }
+
+ if (template.content.includes(DOUBLE_OPT_IN_PLACEHOLDER)) {
+ return true;
+ }
+
+ try {
+ const parsed = JSON.stringify(JSON.parse(template.content));
+ return parsed.includes(DOUBLE_OPT_IN_PLACEHOLDER);
+ } catch (error) {
+ logger.warn(
+ { err: error },
+ "Failed to parse template content while checking double opt-in support"
+ );
+ return false;
+ }
+}
+
+export function assertTemplateSupportsDoubleOptIn(template: TemplateWithContent) {
+ if (!templateSupportsDoubleOptIn(template)) {
+ throw new UnsendApiError({
+ code: "BAD_REQUEST",
+ message:
+ "Selected template must include the {{verificationUrl}} placeholder",
+ });
+ }
+}
+
+export async function ensureDefaultDoubleOptInTemplate(teamId: number) {
+ const existing = await db.template.findFirst({
+ where: {
+ teamId,
+ name: DOUBLE_OPT_IN_TEMPLATE_NAME,
+ },
+ });
+
+ if (existing) {
+ return existing;
+ }
+
+ return db.template.create({
+ data: {
+ teamId,
+ name: DOUBLE_OPT_IN_TEMPLATE_NAME,
+ subject: DOUBLE_OPT_IN_TEMPLATE_SUBJECT,
+ html: DOUBLE_OPT_IN_TEMPLATE_HTML,
+ content: DOUBLE_OPT_IN_TEMPLATE_CONTENT,
+ },
+ });
+}
+
+export async function sendDoubleOptInEmail(options: {
+ contact: Contact;
+ contactBook: ContactBookWithSettings;
+ template: TemplateWithContent;
+ domain: DomainWithDefaultFrom;
+}) {
+ const { contact, contactBook, template, domain } = options;
+
+ if (!contactBook.doubleOptInEnabled) {
+ return;
+ }
+
+ if (!contactBook.doubleOptInTemplateId || !contactBook.defaultDomainId) {
+ logger.warn(
+ {
+ contactBookId: contactBook.id,
+ contactId: contact.id,
+ },
+ "Skipped sending double opt-in email because configuration is incomplete"
+ );
+ return;
+ }
+
+ assertTemplateSupportsDoubleOptIn(template);
+
+ const verificationUrl = createDoubleOptInUrl(contact.id, contactBook.id);
+ const fromAddress = resolveFromAddress(domain);
+
+ await sendEmail({
+ teamId: contactBook.teamId,
+ to: contact.email,
+ from: fromAddress,
+ templateId: template.id,
+ variables: {
+ verificationUrl,
+ },
+ });
+}
+
+export async function confirmContactFromLink(id: string, hash: string) {
+ const expectedHash = createDoubleOptInHash(id);
+
+ if (hash !== expectedHash) {
+ throw new Error("Invalid confirmation link");
+ }
+
+ const [contactId, contactBookId] = id.split("-");
+
+ if (!contactId || !contactBookId) {
+ throw new Error("Invalid confirmation link");
+ }
+
+ const contact = await db.contact.findUnique({
+ where: { id: contactId },
+ include: {
+ contactBook: true,
+ },
+ });
+
+ if (!contact || contact.contactBookId !== contactBookId) {
+ throw new Error("Invalid confirmation link");
+ }
+
+ if (!contact.contactBook.doubleOptInEnabled) {
+ return { contact, confirmed: contact.subscribed };
+ }
+
+ if (contact.subscribed) {
+ return { contact, confirmed: true };
+ }
+
+ const updated = await db.contact.update({
+ where: { id: contact.id },
+ data: {
+ subscribed: true,
+ unsubscribeReason: null,
+ },
+ });
+
+ return { contact: updated, confirmed: true };
+}
diff --git a/apps/web/src/server/service/team-service.ts b/apps/web/src/server/service/team-service.ts
index b268effb..dde0bc86 100644
--- a/apps/web/src/server/service/team-service.ts
+++ b/apps/web/src/server/service/team-service.ts
@@ -10,6 +10,7 @@ import { LimitReason } from "~/lib/constants/plans";
import { LimitService } from "./limit-service";
import { renderUsageLimitReachedEmail } from "../email-templates/UsageLimitReachedEmail";
import { renderUsageWarningEmail } from "../email-templates/UsageWarningEmail";
+import { ensureDefaultDoubleOptInTemplate } from "./double-opt-in-service";
// Cache stores exactly Prisma Team shape (no counts)
@@ -92,6 +93,8 @@ export class TeamService {
},
},
});
+
+ await ensureDefaultDoubleOptInTemplate(created.id);
// Warm cache for the new team
await TeamService.refreshTeamCache(created.id);
return created;
diff --git a/double-opt-in.md b/double-opt-in.md
new file mode 100644
index 00000000..4ddbf7d2
--- /dev/null
+++ b/double-opt-in.md
@@ -0,0 +1,105 @@
+# Double Opt-In Implementation Plan
+
+## Goals
+- Allow teams to require email-based confirmation before a contact becomes subscribed.
+- Ensure every double opt-in email uses a verified domain and a template containing a verification URL.
+- Provide dashboard controls so each contact book can manage double opt-in configuration.
+
+## Functional Requirements (from brief)
+- Seed a template named "Double Opt In" via migration.
+- Each contact book should be mapped to a verified domain and reference an optional double opt-in template.
+- Dashboard settings must validate that the selected template exposes a `verificationUrl` placeholder.
+- When double opt-in is enabled and a contact is added through the public API, automatically send a confirmation email.
+- Confirmation link should mirror the existing unsubscribe hashing flow (contact + book identifiers, shared secret).
+- Contacts stay unsubscribed until the verification link is consumed; confirmation toggles them back to subscribed.
+
+## Workstreams
+
+### 1. Schema & Data Changes
+- Extend `Domain` with a nullable `defaultFrom` column.
+ - When populated, double opt-in emails use `domain.defaultFrom` as the `from` address.
+ - When absent, construct `from` as `hello@` (e.g., `hello@subdomain.example.com`).
+- Extend `ContactBook` with:
+ - `defaultDomainId` (FK → `Domain`, required once domains exist).
+ - `doubleOptInEnabled` boolean (default `false`).
+ - `doubleOptInTemplateId` (FK → `Template`, nullable while feature disabled).
+- Backfill existing contact books:
+ - Infer `defaultDomainId` when a team has exactly one verified domain.
+ - Set `doubleOptInEnabled = false` and leave template null.
+
+### 2. Template Seeding
+- Implement an application hook that:
+ - Ensures each team gets a "Double Opt In" template created during team creation (and lazily when settings load for existing teams).
+ - Stores the provided editor JSON in `Template.content` and ensures `Template.html` includes a `{{verificationUrl}}` button/link.
+ - Default template content:
+ ```json
+ {"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null},"content":[{"type":"text","text":"Hey there,"}]},{"type":"paragraph","attrs":{"textAlign":null},"content":[{"type":"text","text":"Welcome to [Product name]. Please click the link below to verify your email address to get started."}]},{"type":"paragraph","attrs":{"textAlign":null}},{"type":"button","attrs":{"component":"button","text":"Confirm","url":"{{verificationUrl}}","alignment":"left","borderRadius":"4","borderWidth":"1","buttonColor":"rgb(0, 0, 0)","borderColor":"rgb(0, 0, 0)","textColor":"rgb(255, 255, 255)"}},{"type":"paragraph","attrs":{"textAlign":null}},{"type":"paragraph","attrs":{"textAlign":null},"content":[{"type":"text","text":"Best"}]}]}
+ ```
+ - Document that templates must expose `{{verificationUrl}}`; no personalization fields (e.g., name) are required or supported.
+
+### 3. Backend Configuration API
+- Update `contactBookService.getContactBookDetails` to return `defaultDomainId`, `doubleOptInEnabled`, and `doubleOptInTemplateId`.
+- Extend TRPC router mutations/queries:
+ - `contacts.updateContactBook` accepts the new fields and enforces:
+ - When enabling double opt-in, both `defaultDomainId` and `doubleOptInTemplateId` must be present.
+ - The chosen domain must be verified (status success) and expose a usable `from` (either `defaultFrom` or synthesize fallback).
+ - The chosen template must contain `{{verificationUrl}}`; reject otherwise.
+ - Add helper queries to surface available verified domains + templates for UI selectors.
+
+### 4. Double Opt-In Email Generation
+- Build `createDoubleOptInUrl(contactId, contactBookId)` mirroring `createUnsubUrl`:
+ - Use `${contactId}-${contactBookId}` as the identifier.
+ - Hash with `sha256` + `env.NEXTAUTH_SECRET` (same as unsubscribe) to produce `hash`.
+ - URL shape: `${env.NEXTAUTH_URL}/confirm?id=${identifier}&hash=${hash}` (final route TBD).
+- Add `sendDoubleOptInEmail({ contact, contactBook, teamId })`:
+ - Resolve domain via `contactBook.defaultDomainId` and compute `from` with `domain.defaultFrom ?? hello@...`.
+ - Render template content via `EmailRenderer` with replacements mapping `{{verificationUrl}}` to generated link.
+ - Queue email through `EmailQueueService` and record standard `Email`/`EmailEvent` entries (no extra token storage).
+ - Ensure repeated calls reuse the same link (deterministic hash), so resend logic stays idempotent.
+
+### 5. API Flow Adjustments
+- Update `contactService.addOrUpdateContact` and public API handlers:
+ - Force `subscribed = false` for new or updated contacts while double opt-in is enabled.
+ - After create/update, call `sendDoubleOptInEmail` if the contact is new or previously unsubscribed.
+ - When double opt-in disabled, retain existing behavior.
+ - Disallow `subscribed: true` payloads while double opt-in is active (reject or ignore with warning).
+
+### 6. Confirmation Endpoint
+- Add route (e.g., `/api/confirm-subscription`) accepting `id` + `hash`.
+ - Split `id` into `contactId` and `contactBookId`.
+ - Recompute expected hash using the same secret; reject if mismatch.
+ - Verify contact still belongs to the contact book and is unsubscribed.
+ - Set `subscribed = true`, clear any `unsubscribeReason`, and emit success response.
+ - Subsequent requests should be idempotent (no token revocation needed); respond with already confirmed message.
+
+### 7. Dashboard UI
+- Introduce `contacts/[contactBookId]/settings` page or tab:
+ - Allow selecting verified domain (show defaultFrom / fallback preview) and template.
+ - Toggle for "Require double opt-in" gating template/domain selectors.
+ - Surface validation messaging when template lacks `{{verificationUrl}}` or domain missing `defaultFrom`.
+ - Link from contact book details to the new settings page.
+
+### 8. Background & Notifications
+- Optional follow-up: add tooling to resend confirmations manually or report pending confirmations (contacts still unsubscribed with double opt-in enabled).
+
+### 9. Testing & Rollout
+- Unit/Integration coverage targets:
+ - Hash generation & validation (`createDoubleOptInUrl`, confirmation endpoint).
+ - Configuration validation (domain + template requirements).
+ - API flow ensuring contacts remain unsubscribed until confirmation.
+- Manual QA checklist:
+ 1. Enable double opt-in, add contact via API → confirmation email sent using domain.defaultFrom (or fallback) and contact remains unsubscribed.
+ 2. Visit confirmation link → contact becomes subscribed.
+ 3. Revisit link → receive idempotent "already confirmed" response without altering state.
+ 4. Disable double opt-in → contacts can be created as subscribed immediately.
+- Ensure migrations run safely in production (Domain.defaultFrom nullable with sensible fallback; template seeding idempotent).
+
+## Open Questions
+- What is the expected fallback when a domain lacks `subdomain` (use root `example.com`)?
+- Do we allow dashboard CSV imports to follow the same double opt-in flow, or should they bypass it?
+- Should we emit webhook/event when confirmation completes?
+
+## Dependencies
+- Teams must own at least one verified domain to enable double opt-in.
+- Email template rendering relies on `@usesend/email-editor`; ensure placeholder replacement matches editor schema.
+- Requires access to existing unsubscribe hashing logic and shared secret (`NEXTAUTH_SECRET`).