From 6674169785c5b6e4257b66e483f74662bbd6d673 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Thu, 18 Sep 2025 03:52:59 +1000 Subject: [PATCH 1/2] feat: add double opt-in --- .../migration.sql | 45 ++++ apps/web/prisma/schema.prisma | 54 ++-- .../contacts/[contactBookId]/page.tsx | 5 + .../[contactBookId]/settings/page.tsx | 243 ++++++++++++++++++ apps/web/src/app/confirm/page.tsx | 80 ++++++ apps/web/src/server/api/routers/contacts.ts | 12 +- apps/web/src/server/api/routers/domain.ts | 8 +- apps/web/src/server/mailer.ts | 9 +- .../server/service/contact-book-service.ts | 117 ++++++++- .../web/src/server/service/contact-service.ts | 118 ++++++++- apps/web/src/server/service/domain-service.ts | 26 +- .../server/service/double-opt-in-service.ts | 165 ++++++++++++ double-opt-in.md | 105 ++++++++ 13 files changed, 956 insertions(+), 31 deletions(-) create mode 100644 apps/web/prisma/migrations/20250930120000_add_double_opt_in/migration.sql create mode 100644 apps/web/src/app/(dashboard)/contacts/[contactBookId]/settings/page.tsx create mode 100644 apps/web/src/app/confirm/page.tsx create mode 100644 apps/web/src/server/service/double-opt-in-service.ts create mode 100644 double-opt-in.md 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..8ed7ddd0 --- /dev/null +++ b/apps/web/prisma/migrations/20250930120000_add_double_opt_in/migration.sql @@ -0,0 +1,45 @@ +-- 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; + +-- Seed default double opt-in template per team when missing +INSERT INTO "Template" ( + "id", + "name", + "teamId", + "subject", + "html", + "content", + "createdAt", + "updatedAt" +) +SELECT + 'doi_' || substr(md5(random()::text || clock_timestamp()::text), 1, 24), + 'Double Opt In', + t.id, + 'Confirm your email', + '

Hey there,

Welcome to [Product name]. Please click the link below to verify your email address to get started.

Confirm

Best

', + '{"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"}]}]}', + NOW(), + NOW() +FROM "Team" t +WHERE NOT EXISTS ( + SELECT 1 + FROM "Template" + WHERE "Template"."teamId" = t.id + AND "Template"."name" = 'Double Opt In' +); 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({
+ + +
diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/settings/page.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/settings/page.tsx new file mode 100644 index 00000000..c9d15e43 --- /dev/null +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/settings/page.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { use, useEffect } from "react"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@usesend/ui/src/form"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Switch } from "@usesend/ui/src/switch"; +import { Button } from "@usesend/ui/src/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@usesend/ui/src/select"; +import { api } from "~/trpc/react"; +import { toast } from "@usesend/ui/src/toaster"; +import { Skeleton } from "@usesend/ui/src/skeleton"; + +const schema = z + .object({ + doubleOptInEnabled: z.boolean(), + defaultDomainId: z.string().nullable(), + doubleOptInTemplateId: z.string().nullable(), + }) + .superRefine((value, ctx) => { + if (!value.doubleOptInEnabled) { + return; + } + + if (!value.defaultDomainId) { + ctx.addIssue({ + path: ["defaultDomainId"], + code: z.ZodIssueCode.custom, + message: "Choose a verified domain", + }); + } + + if (!value.doubleOptInTemplateId) { + ctx.addIssue({ + path: ["doubleOptInTemplateId"], + code: z.ZodIssueCode.custom, + message: "Select a confirmation template", + }); + } + }); + +export default function ContactBookSettingsPage({ + params, +}: { + params: Promise<{ contactBookId: string }>; +}) { + const { contactBookId } = use(params); + + const utils = api.useUtils(); + + const settingsQuery = api.contacts.getContactBookSettings.useQuery({ + contactBookId, + }); + + const updateMutation = api.contacts.updateContactBook.useMutation({ + onSuccess: async () => { + await Promise.all([ + utils.contacts.getContactBookSettings.invalidate({ contactBookId }), + utils.contacts.getContactBookDetails.invalidate({ contactBookId }), + ]); + toast.success("Settings updated"); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + const form = useForm>({ + 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. +

+
+
+ + ( + +
+ Require confirmation +

+ Send a confirmation email when contacts are added via the + API. +

+
+ + + +
+ )} + /> + + ( + + From domain + + + + {domains.length === 0 ? ( +

+ Add a verified domain before enabling double opt-in. +

+ ) : ( + + )} +
+ )} + /> + + ( + + Confirmation email + + + +

+ Templates must include the {"{{verificationUrl}}"} placeholder. + {templates.length === 0 + ? " Create or publish a template before enabling double opt-in." + : ""} +

+ +
+ )} + /> + +
+ +
+ + +
+ ); +} 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."} +

+
+
+

+ Powered by{" "} + + useSend + +

+
+
+ ); + } catch (error) { + return ( +
+
+

+ Confirm Subscription +

+

+ Invalid or expired confirmation link. Please contact the sender for + a new email. +

+
+
+

+ Powered by{" "} + + useSend + +

+
+
+ ); + } +} + +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..b6b4255a 100644 --- a/apps/web/src/server/service/contact-book-service.ts +++ b/apps/web/src/server/service/contact-book-service.ts @@ -1,7 +1,12 @@ -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, + 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 +77,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 +157,46 @@ 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", + }); + } + + 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..5f6df15b --- /dev/null +++ b/apps/web/src/server/service/double-opt-in-service.ts @@ -0,0 +1,165 @@ +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"; + +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 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/double-opt-in.md b/double-opt-in.md new file mode 100644 index 00000000..3709a275 --- /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 Migration +- Add migration that: + - Inserts a "Double Opt In" template per team (or a global seed copied to each team) with subject "Confirm your email". + - 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`). From 9e513ea1afcabfddf58f9c2335fed27061df4de2 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Thu, 18 Sep 2025 18:00:58 +1000 Subject: [PATCH 2/2] update --- .../migration.sql | 28 ------------------ .../server/service/contact-book-service.ts | 3 ++ .../server/service/double-opt-in-service.ts | 29 +++++++++++++++++++ apps/web/src/server/service/team-service.ts | 3 ++ double-opt-in.md | 6 ++-- 5 files changed, 38 insertions(+), 31 deletions(-) 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 index 8ed7ddd0..21171b30 100644 --- a/apps/web/prisma/migrations/20250930120000_add_double_opt_in/migration.sql +++ b/apps/web/prisma/migrations/20250930120000_add_double_opt_in/migration.sql @@ -15,31 +15,3 @@ 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; - --- Seed default double opt-in template per team when missing -INSERT INTO "Template" ( - "id", - "name", - "teamId", - "subject", - "html", - "content", - "createdAt", - "updatedAt" -) -SELECT - 'doi_' || substr(md5(random()::text || clock_timestamp()::text), 1, 24), - 'Double Opt In', - t.id, - 'Confirm your email', - '

Hey there,

Welcome to [Product name]. Please click the link below to verify your email address to get started.

Confirm

Best

', - '{"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"}]}]}', - NOW(), - NOW() -FROM "Team" t -WHERE NOT EXISTS ( - SELECT 1 - FROM "Template" - WHERE "Template"."teamId" = t.id - AND "Template"."name" = 'Double Opt In' -); diff --git a/apps/web/src/server/service/contact-book-service.ts b/apps/web/src/server/service/contact-book-service.ts index b6b4255a..18a24db7 100644 --- a/apps/web/src/server/service/contact-book-service.ts +++ b/apps/web/src/server/service/contact-book-service.ts @@ -4,6 +4,7 @@ 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"; @@ -174,6 +175,8 @@ export async function getContactBookSettings(contactBookId: string) { }); } + await ensureDefaultDoubleOptInTemplate(contactBook.teamId); + const [domains, templates] = await Promise.all([ getVerifiedDomains(contactBook.teamId), db.template.findMany({ diff --git a/apps/web/src/server/service/double-opt-in-service.ts b/apps/web/src/server/service/double-opt-in-service.ts index 5f6df15b..2613136a 100644 --- a/apps/web/src/server/service/double-opt-in-service.ts +++ b/apps/web/src/server/service/double-opt-in-service.ts @@ -10,6 +10,12 @@ 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; @@ -82,6 +88,29 @@ export function assertTemplateSupportsDoubleOptIn(template: TemplateWithContent) } } +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; 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 index 3709a275..4ddbf7d2 100644 --- a/double-opt-in.md +++ b/double-opt-in.md @@ -27,9 +27,9 @@ - Infer `defaultDomainId` when a team has exactly one verified domain. - Set `doubleOptInEnabled = false` and leave template null. -### 2. Template Seeding Migration -- Add migration that: - - Inserts a "Double Opt In" template per team (or a global seed copied to each team) with subject "Confirm your email". +### 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