diff --git a/.gitignore b/.gitignore index c2b5d84b..41251de8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # Dependencies node_modules +.pnpm-store .pnp .pnp.js diff --git a/apps/web/prisma/migrations/20260418120000_custom_tracking_domain/migration.sql b/apps/web/prisma/migrations/20260418120000_custom_tracking_domain/migration.sql new file mode 100644 index 00000000..43d4b3c1 --- /dev/null +++ b/apps/web/prisma/migrations/20260418120000_custom_tracking_domain/migration.sql @@ -0,0 +1,10 @@ +-- AlterTable +ALTER TABLE "Domain" ADD COLUMN "customTrackingHostname" TEXT, +ADD COLUMN "customTrackingPublicKey" TEXT, +ADD COLUMN "customTrackingDkimSelector" TEXT DEFAULT 'utrack', +ADD COLUMN "customTrackingDkimStatus" TEXT, +ADD COLUMN "customTrackingStatus" "DomainStatus" NOT NULL DEFAULT 'NOT_STARTED', +ADD COLUMN "trackingConfigGeneral" TEXT, +ADD COLUMN "trackingConfigClick" TEXT, +ADD COLUMN "trackingConfigOpen" TEXT, +ADD COLUMN "trackingConfigFull" TEXT; diff --git a/apps/web/prisma/migrations/20260418153000_domain_tracking_https_required/migration.sql b/apps/web/prisma/migrations/20260418153000_domain_tracking_https_required/migration.sql new file mode 100644 index 00000000..11fa3016 --- /dev/null +++ b/apps/web/prisma/migrations/20260418153000_domain_tracking_https_required/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Domain" ADD COLUMN "trackingHttpsRequired" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/web/prisma/migrations/20260419120000_domain_custom_tracking_hostname_unique/migration.sql b/apps/web/prisma/migrations/20260419120000_domain_custom_tracking_hostname_unique/migration.sql new file mode 100644 index 00000000..b9b58ae3 --- /dev/null +++ b/apps/web/prisma/migrations/20260419120000_domain_custom_tracking_hostname_unique/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE UNIQUE INDEX "Domain_customTrackingHostname_key" ON "Domain"("customTrackingHostname"); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 1492adf8..ce83c2ff 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -196,6 +196,18 @@ model Domain { subdomain String? sesTenantId String? isVerifying Boolean @default(false) + /// Self-hosted: custom hostname for SES click/open tracking (e.g. track.example.com). Requires DNS + verification in SES. + customTrackingHostname String? @unique + customTrackingPublicKey String? + customTrackingDkimSelector String? @default("utrack") + customTrackingDkimStatus String? + customTrackingStatus DomainStatus @default(NOT_STARTED) + trackingConfigGeneral String? + trackingConfigClick String? + trackingConfigOpen String? + trackingConfigFull String? + /// Self-hosted: when true, SES uses HTTPS for tracking links (REQUIRE). Needs valid TLS on the tracking hostname (e.g. Cloudflare proxy). When false, OPTIONAL (HTTP allowed; CNAME-only to awstrack). + trackingHttpsRequired Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) diff --git a/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx b/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx index be4691b9..0efe36ea 100644 --- a/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx +++ b/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx @@ -25,10 +25,12 @@ import { Switch } from "@usesend/ui/src/switch"; import DeleteDomain from "./delete-domain"; import SendTestMail from "./send-test-mail"; import { Button } from "@usesend/ui/src/button"; +import { Input } from "@usesend/ui/src/input"; import Link from "next/link"; import { toast } from "@usesend/ui/src/toaster"; import type { inferRouterOutputs } from "@trpc/server"; import type { AppRouter } from "~/server/api/root"; +import { env } from "~/env"; type RouterOutputs = inferRouterOutputs; type DomainResponse = NonNullable; @@ -45,7 +47,21 @@ export default function DomainItemPage({ id: Number(domainId), }, { - refetchInterval: (q) => (q?.state.data?.isVerifying ? 10000 : false), + refetchInterval: (q) => { + const d = q?.state.data; + if (!d) return false; + if (d.isVerifying) return 10000; + if ( + !env.NEXT_PUBLIC_IS_CLOUD && + d.customTrackingHostname && + d.customTrackingPublicKey && + d.customTrackingStatus !== DomainStatus.SUCCESS && + d.customTrackingStatus !== DomainStatus.FAILED + ) { + return 10000; + } + return false; + }, refetchIntervalInBackground: true, }, ); @@ -128,8 +144,8 @@ export default function DomainItemPage({ - {(domainQuery.data?.dnsRecords ?? []).map((record) => { - const key = `${record.type}-${record.name}`; + {(domainQuery.data?.dnsRecords ?? []).map((record, idx) => { + const key = `${record.type}-${record.name}-${idx}`; const valueClassName = record.name.includes("_domainkey") ? "w-[200px] overflow-hidden text-ellipsis" : "w-[200px] overflow-hidden text-ellipsis text-nowrap"; @@ -175,12 +191,27 @@ export default function DomainItemPage({ const DomainSettings: React.FC<{ domain: DomainResponse }> = ({ domain }) => { const updateDomain = api.domain.updateDomain.useMutation(); + const setTrackingHost = api.domain.setCustomTrackingHostname.useMutation(); const utils = api.useUtils(); const [clickTracking, setClickTracking] = React.useState( domain.clickTracking, ); const [openTracking, setOpenTracking] = React.useState(domain.openTracking); + const [trackingHostDraft, setTrackingHostDraft] = React.useState( + domain.customTrackingHostname ?? "", + ); + const [trackingHttpsDraft, setTrackingHttpsDraft] = React.useState( + domain.trackingHttpsRequired, + ); + + React.useEffect(() => { + setTrackingHostDraft(domain.customTrackingHostname ?? ""); + }, [domain.customTrackingHostname]); + + React.useEffect(() => { + setTrackingHttpsDraft(domain.trackingHttpsRequired); + }, [domain.trackingHttpsRequired]); function handleClickTrackingChange() { setClickTracking(!clickTracking); @@ -236,6 +267,114 @@ const DomainSettings: React.FC<{ domain: DomainResponse }> = ({ domain }) => { /> + {!env.NEXT_PUBLIC_IS_CLOUD ? ( +
+
Custom tracking domain
+

+ Use your own hostname for click and open tracking instead of the + default SES tracking URLs. It must be on the same registrable domain + as this sending domain (for example{" "} + track.example.com for{" "} + example.com). You need{" "} + both records in the DNS table: the DKIM TXT proves + ownership to SES; the CNAME points your hostname at Amazon's + regional tracking servers so links and pixels resolve. +

+

+ HTTPS for tracking links is off by default (HTTP is + allowed; fine with a CNAME-only setup). Turn it on only if valid TLS + exists for this hostname — the easiest option is often{" "} + Cloudflare proxy (orange cloud) on the tracking + name so visitors get HTTPS without running CloudFront. Advanced + setups can use CloudFront + ACM or another TLS terminator instead. +

+
+
+ Hostname + setTrackingHostDraft(e.target.value)} + disabled={setTrackingHost.isPending} + /> +
+ +
+
+
+ Require HTTPS for tracking links +
+

+ Tells SES to use HTTPS in tracking URLs. Only enable if this + hostname already serves a valid certificate (e.g. Cloudflare + proxy). +

+ { + setTrackingHttpsDraft(checked); + if (domain.customTrackingHostname) { + setTrackingHost.mutate( + { + id: domain.id, + hostname: domain.customTrackingHostname, + trackingHttpsRequired: checked, + }, + { + onSuccess: () => { + utils.domain.invalidate(); + toast.success("Tracking HTTPS preference updated"); + }, + onError: (err) => { + toast.error(err.message); + setTrackingHttpsDraft(domain.trackingHttpsRequired); + }, + }, + ); + } + }} + disabled={setTrackingHost.isPending} + className="data-[state=checked]:bg-success" + /> +
+ {domain.customTrackingHostname ? ( +
+ Tracking identity: + +
+ ) : null} +
+ ) : null} +

Danger

diff --git a/apps/web/src/lib/zod/domain-schema.ts b/apps/web/src/lib/zod/domain-schema.ts index 4d81ac18..3b45ce48 100644 --- a/apps/web/src/lib/zod/domain-schema.ts +++ b/apps/web/src/lib/zod/domain-schema.ts @@ -4,7 +4,7 @@ import { z } from "zod"; export const DomainStatusSchema = z.nativeEnum(DomainStatus); export const DomainDnsRecordSchema = z.object({ - type: z.enum(["MX", "TXT"]).openapi({ + type: z.enum(["MX", "TXT", "CNAME"]).openapi({ description: "DNS record type", example: "TXT", }), diff --git a/apps/web/src/server/api/routers/domain.ts b/apps/web/src/server/api/routers/domain.ts index 848d24aa..39856ea3 100644 --- a/apps/web/src/server/api/routers/domain.ts +++ b/apps/web/src/server/api/routers/domain.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { TRPCError } from "@trpc/server"; import { createTRPCRouter, @@ -13,6 +14,7 @@ import { getDomain, getDomains, updateDomain, + setCustomTrackingHostname, } from "~/server/service/domain-service"; import { sendEmail } from "~/server/service/email-service"; import { SesSettingsService } from "~/server/service/ses-settings-service"; @@ -63,6 +65,33 @@ export const domainRouter = createTRPCRouter({ }); }), + setCustomTrackingHostname: teamProcedure + .input( + z.object({ + id: z.number(), + hostname: z.string().max(253).nullable(), + /** When set, persisted and applied to SES tracking config sets for this domain. */ + trackingHttpsRequired: z.boolean().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const domain = await db.domain.findFirst({ + where: { id: input.id, teamId: ctx.team.id }, + }); + if (!domain) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Domain not found", + }); + } + return setCustomTrackingHostname( + input.id, + ctx.team.id, + input.hostname, + input.trackingHttpsRequired, + ); + }), + deleteDomain: domainProcedure.mutation(async ({ input }) => { await deleteDomain(input.id); return { success: true }; diff --git a/apps/web/src/server/aws/ses.ts b/apps/web/src/server/aws/ses.ts index 0fda94c8..76035488 100644 --- a/apps/web/src/server/aws/ses.ts +++ b/apps/web/src/server/aws/ses.ts @@ -7,6 +7,8 @@ import { SendEmailCommand, CreateConfigurationSetEventDestinationCommand, CreateConfigurationSetCommand, + DeleteConfigurationSetCommand, + PutConfigurationSetTrackingOptionsCommand, EventType, GetAccountCommand, CreateTenantResourceAssociationCommand, @@ -20,6 +22,7 @@ import { env } from "~/env"; import { EmailContent } from "~/types"; import { logger } from "../logger/log"; import { buildHeaders } from "~/server/utils/email-headers"; +import { addSesNoTrackToUnsubscribeLinks } from "~/server/utils/ses-tracking-html"; let accountId: string | undefined = undefined; @@ -142,6 +145,107 @@ export async function addDomain( return publicKey; } +/** + * DKIM-only identity for a custom click/open tracking hostname (no custom MAIL FROM). + * Used for self-hosted per-domain SES tracking domains. + */ +export async function addTrackingEmailIdentity( + hostname: string, + region: string, + sesTenantId?: string, + dkimSelector: string = "utrack", +) { + const sesClient = getSesClient(region); + + const { privateKey, publicKey } = generateKeyPair(); + const command = new CreateEmailIdentityCommand({ + EmailIdentity: hostname, + DkimSigningAttributes: { + DomainSigningSelector: dkimSelector, + DomainSigningPrivateKey: privateKey, + }, + }); + const response = await sesClient.send(command); + + if (sesTenantId) { + const tenantResourceAssociationCommand = + new CreateTenantResourceAssociationCommand({ + TenantName: sesTenantId, + ResourceArn: await getIdentityArn(hostname, region), + }); + + const tenantResourceAssociationResponse = await sesClient.send( + tenantResourceAssociationCommand, + ); + + if (tenantResourceAssociationResponse.$metadata.httpStatusCode !== 200) { + logger.error( + { tenantResourceAssociationResponse }, + "Failed to associate tracking identity with tenant", + ); + throw new Error("Failed to associate tracking identity with tenant"); + } + } + + if (response.$metadata.httpStatusCode !== 200) { + logger.error({ response }, "Failed to create tracking email identity"); + throw new Error("Failed to create tracking email identity"); + } + + return publicKey; +} + +/** Values supported for PutConfigurationSetTrackingOptions / HttpsPolicy in our app. */ +export type SesTrackingHttpsPolicy = "OPTIONAL" | "REQUIRE"; + +export function trackingHttpsRequiredToSesPolicy( + trackingHttpsRequired: boolean, +): SesTrackingHttpsPolicy { + return trackingHttpsRequired ? "REQUIRE" : "OPTIONAL"; +} + +export async function putConfigurationSetHttpsTracking( + configurationSetName: string, + customRedirectDomain: string, + region: string, + httpsPolicy: SesTrackingHttpsPolicy, +) { + const sesClient = getSesClient(region); + const cmd = new PutConfigurationSetTrackingOptionsCommand({ + ConfigurationSetName: configurationSetName, + CustomRedirectDomain: customRedirectDomain, + HttpsPolicy: httpsPolicy, + }); + const response = await sesClient.send(cmd); + const code = response.$metadata.httpStatusCode; + if (code !== 200) { + throw new Error( + `PutConfigurationSetTrackingOptions failed for ${configurationSetName}: HTTP ${code ?? "unknown"}`, + ); + } +} + +export async function deleteConfigurationSet( + configurationSetName: string, + region: string, +) { + const sesClient = getSesClient(region); + try { + const response = await sesClient.send( + new DeleteConfigurationSetCommand({ + ConfigurationSetName: configurationSetName, + }), + ); + return response.$metadata.httpStatusCode === 200; + } catch (error: unknown) { + const err = error as { name?: string }; + if (err.name === "NotFoundException") { + return true; + } + throw error; + } +} + export async function deleteDomain( domain: string, region: string, @@ -218,13 +322,15 @@ export async function sendRawEmail({ }) { const sesClient = getSesClient(region); + const htmlForSes = html ? addSesNoTrackToUnsubscribeLinks(html) : html; + const { message: messageStream } = await nodemailer .createTransport({ streamTransport: true }) .sendMail({ from, to, subject, - html, + html: htmlForSes, attachments: attachments?.map((attachment) => ({ filename: attachment.filename, content: attachment.content, @@ -278,6 +384,10 @@ export async function getAccount(region: string) { return response; } +function isAlreadyExistsError(error: unknown): boolean { + return (error as { name?: string })?.name === "AlreadyExistsException"; +} + export async function addWebhookConfiguration( configName: string, topicArn: string, @@ -290,10 +400,19 @@ export async function addWebhookConfiguration( ConfigurationSetName: configName, }); - const configSetResponse = await sesClient.send(configSetCommand); - - if (configSetResponse.$metadata.httpStatusCode !== 200) { - throw new Error("Failed to create configuration set"); + try { + const configSetResponse = await sesClient.send(configSetCommand); + if (configSetResponse.$metadata.httpStatusCode !== 200) { + throw new Error("Failed to create configuration set"); + } + } catch (error: unknown) { + if (!isAlreadyExistsError(error)) { + throw error; + } + logger.debug( + { configName, region }, + "SES configuration set already exists; continuing", + ); } const command = new CreateConfigurationSetEventDestinationCommand({ @@ -308,8 +427,22 @@ export async function addWebhookConfiguration( }, }); - const response = await sesClient.send(command); - return response.$metadata.httpStatusCode === 200; + try { + const response = await sesClient.send(command); + if (response.$metadata.httpStatusCode !== 200) { + throw new Error("Failed to create configuration set event destination"); + } + } catch (error: unknown) { + if (!isAlreadyExistsError(error)) { + throw error; + } + logger.debug( + { configName, region }, + "SES event destination already exists; continuing", + ); + } + + return true; } /** diff --git a/apps/web/src/server/jobs/domain-verification-job.unit.test.ts b/apps/web/src/server/jobs/domain-verification-job.unit.test.ts index 55c260ef..5f2aecbe 100644 --- a/apps/web/src/server/jobs/domain-verification-job.unit.test.ts +++ b/apps/web/src/server/jobs/domain-verification-job.unit.test.ts @@ -76,6 +76,16 @@ function createDomain(id: number, status: DomainStatus): Domain { subdomain: null, sesTenantId: null, isVerifying: status !== DomainStatus.SUCCESS, + customTrackingHostname: null, + customTrackingPublicKey: null, + customTrackingDkimSelector: "utrack", + customTrackingDkimStatus: null, + customTrackingStatus: DomainStatus.NOT_STARTED, + trackingConfigGeneral: null, + trackingConfigClick: null, + trackingConfigOpen: null, + trackingConfigFull: null, + trackingHttpsRequired: false, createdAt: new Date("2026-03-01T00:00:00.000Z"), updatedAt: new Date("2026-03-01T00:00:00.000Z"), }; diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index da9c325e..f0fbd200 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -1,5 +1,6 @@ import dns from "dns"; import util from "util"; +import { EventType } from "@aws-sdk/client-sesv2"; import * as tldts from "tldts"; import * as ses from "~/server/aws/ses"; import { db } from "~/server/db"; @@ -20,6 +21,17 @@ import type { DomainDnsRecord } from "~/types/domain"; import { WebhookService } from "./webhook-service"; const DOMAIN_STATUS_VALUES = new Set(Object.values(DomainStatus)); + +const SES_GENERAL_EVENTS: EventType[] = [ + "BOUNCE", + "COMPLAINT", + "DELIVERY", + "DELIVERY_DELAY", + "REJECT", + "RENDERING_FAILURE", + "SEND", + "SUBSCRIPTION", +]; export const DOMAIN_UNVERIFIED_RECHECK_MS = 6 * 60 * 60 * 1000; export const DOMAIN_VERIFIED_RECHECK_MS = 30 * 24 * 60 * 60 * 1000; const VERIFIED_DOMAIN_STATUSES = new Set([DomainStatus.SUCCESS]); @@ -54,6 +66,49 @@ function parseDomainStatus(status?: string | null): DomainStatus { return DomainStatus.NOT_STARTED; } +/** + * Regional SES open/click tracking origin (HTTP). Required CNAME target for custom tracking + * hostnames. See "Tracking domains for open/click links" in AWS General Reference (SES). + */ +function sesRegionalTrackingRedirectHost(region: string): string { + return `r.${region}.awstrack.me`; +} + +function buildTrackingDnsRecords(domain: Domain): DomainDnsRecord[] { + if (!domain.customTrackingHostname || !domain.customTrackingPublicKey) { + return []; + } + const selector = domain.customTrackingDkimSelector ?? "utrack"; + const parsed = tldts.parse(domain.customTrackingHostname); + const sub = parsed.subdomain; + const suffix = sub ? `.${sub}` : ""; + const dkimStatus = parseDomainStatus(domain.customTrackingDkimStatus); + const routingStatus = parseDomainStatus(domain.customTrackingStatus); + + const rows: DomainDnsRecord[] = [ + { + type: "TXT", + name: `${selector}._domainkey${suffix}`, + value: `p=${domain.customTrackingPublicKey}`, + ttl: "Auto", + status: dkimStatus, + }, + ]; + + if (sub) { + rows.push({ + type: "CNAME", + name: sub, + value: sesRegionalTrackingRedirectHost(domain.region), + ttl: "Auto", + status: routingStatus, + recommended: true, + }); + } + + return rows; +} + function buildDnsRecords(domain: Domain): DomainDnsRecord[] { const subdomainSuffix = domain.subdomain ? `.${domain.subdomain}` : ""; const mailDomain = `mail${subdomainSuffix}`; @@ -104,10 +159,362 @@ function withDnsRecords( ): T & { dnsRecords: DomainDnsRecord[] } { return { ...domain, - dnsRecords: buildDnsRecords(domain), + dnsRecords: [...buildDnsRecords(domain), ...buildTrackingDnsRecords(domain)], }; } +function isCustomTrackingProvisioningComplete(domain: Domain): boolean { + return !!( + domain.trackingConfigGeneral && + domain.trackingConfigClick && + domain.trackingConfigOpen && + domain.trackingConfigFull + ); +} + +function shouldPollCustomTrackingVerification(domain: Domain): boolean { + if (env.NEXT_PUBLIC_IS_CLOUD) { + return false; + } + if (!domain.customTrackingHostname || !domain.customTrackingPublicKey) { + return false; + } + if (domain.customTrackingStatus === DomainStatus.FAILED) { + return false; + } + if (domain.customTrackingStatus === DomainStatus.SUCCESS) { + return !isCustomTrackingProvisioningComplete(domain); + } + return true; +} + +function assertTrackingHostnameAllowed( + sendingDomainName: string, + trackingHost: string, +) { + const sendReg = tldts.getDomain(sendingDomainName); + const trackReg = tldts.getDomain(trackingHost); + if (!sendReg || !trackReg || sendReg !== trackReg) { + throw new UnsendApiError({ + code: "BAD_REQUEST", + message: + "Custom tracking hostname must use the same registrable domain as this sending domain.", + }); + } +} + +async function removeCustomTrackingResources(domain: Domain) { + const region = domain.region; + for (const name of [ + domain.trackingConfigGeneral, + domain.trackingConfigClick, + domain.trackingConfigOpen, + domain.trackingConfigFull, + ]) { + if (name) { + try { + await ses.deleteConfigurationSet(name, region); + } catch (error) { + logger.error( + { err: error, configurationSetName: name }, + "[DomainService]: Failed to delete tracking configuration set", + ); + } + } + } + if (domain.customTrackingHostname) { + try { + await ses.deleteDomain( + domain.customTrackingHostname, + region, + domain.sesTenantId ?? undefined, + ); + } catch (error) { + logger.error( + { err: error, hostname: domain.customTrackingHostname }, + "[DomainService]: Failed to delete tracking email identity", + ); + } + } +} + +async function reapplyCustomTrackingSesPolicy(domain: Domain) { + if ( + !domain.customTrackingHostname || + !domain.trackingConfigClick || + !domain.trackingConfigOpen || + !domain.trackingConfigFull + ) { + return; + } + const host = domain.customTrackingHostname; + const region = domain.region; + const httpsPolicy = ses.trackingHttpsRequiredToSesPolicy( + domain.trackingHttpsRequired, + ); + await ses.putConfigurationSetHttpsTracking( + domain.trackingConfigClick, + host, + region, + httpsPolicy, + ); + await ses.putConfigurationSetHttpsTracking( + domain.trackingConfigOpen, + host, + region, + httpsPolicy, + ); + await ses.putConfigurationSetHttpsTracking( + domain.trackingConfigFull, + host, + region, + httpsPolicy, + ); +} + +async function ensureCustomTrackingProvisioned(domainId: number) { + const domain = await db.domain.findUnique({ where: { id: domainId } }); + if (!domain?.customTrackingHostname) { + return; + } + if ( + domain.trackingConfigGeneral && + domain.trackingConfigClick && + domain.trackingConfigOpen && + domain.trackingConfigFull + ) { + try { + await reapplyCustomTrackingSesPolicy(domain); + } catch (error) { + logger.error( + { err: error, domainId }, + "[DomainService]: Failed to reapply custom tracking HTTPS policy", + ); + } + return; + } + if (domain.customTrackingStatus !== DomainStatus.SUCCESS) { + return; + } + + const setting = await SesSettingsService.getSetting(domain.region); + if (!setting?.topicArn) { + logger.error( + { region: domain.region }, + "[DomainService]: No SES setting for custom tracking provision", + ); + return; + } + + const base = `${setting.idPrefix}-dom${domain.id}-${domain.region}-unsend`; + const configGeneral = `${base}-general`; + const configClick = `${base}-click`; + const configOpen = `${base}-open`; + const configFull = `${base}-full`; + const region = domain.region; + const topicArn = setting.topicArn; + const host = domain.customTrackingHostname; + + try { + await ses.addWebhookConfiguration( + configGeneral, + topicArn, + SES_GENERAL_EVENTS, + region, + ); + await ses.addWebhookConfiguration( + configClick, + topicArn, + [...SES_GENERAL_EVENTS, "CLICK"], + region, + ); + await ses.addWebhookConfiguration( + configOpen, + topicArn, + [...SES_GENERAL_EVENTS, "OPEN"], + region, + ); + await ses.addWebhookConfiguration( + configFull, + topicArn, + [...SES_GENERAL_EVENTS, "CLICK", "OPEN"], + region, + ); + + const httpsPolicy = ses.trackingHttpsRequiredToSesPolicy( + domain.trackingHttpsRequired, + ); + await ses.putConfigurationSetHttpsTracking( + configClick, + host, + region, + httpsPolicy, + ); + await ses.putConfigurationSetHttpsTracking( + configOpen, + host, + region, + httpsPolicy, + ); + await ses.putConfigurationSetHttpsTracking( + configFull, + host, + region, + httpsPolicy, + ); + + await db.domain.update({ + where: { id: domainId }, + data: { + trackingConfigGeneral: configGeneral, + trackingConfigClick: configClick, + trackingConfigOpen: configOpen, + trackingConfigFull: configFull, + }, + }); + } catch (error) { + logger.error( + { err: error, domainId }, + "[DomainService]: Failed to provision custom tracking configuration sets", + ); + throw error; + } +} + +export async function setCustomTrackingHostname( + domainId: number, + teamId: number, + hostname: string | null, + trackingHttpsRequired?: boolean, +) { + if (env.NEXT_PUBLIC_IS_CLOUD) { + throw new UnsendApiError({ + code: "FORBIDDEN", + message: + "Custom tracking domains are only available for self-hosted useSend.", + }); + } + + const domain = await db.domain.findFirst({ + where: { id: domainId, teamId }, + }); + + if (!domain) { + throw new UnsendApiError({ + code: "NOT_FOUND", + message: "Domain not found", + }); + } + + const trimmed = + hostname === null || hostname === undefined ? "" : hostname.trim(); + + if (!trimmed) { + await removeCustomTrackingResources(domain); + const cleared = await db.domain.update({ + where: { id: domainId }, + data: { + customTrackingHostname: null, + customTrackingPublicKey: null, + customTrackingDkimSelector: "utrack", + customTrackingDkimStatus: null, + customTrackingStatus: DomainStatus.NOT_STARTED, + trackingConfigGeneral: null, + trackingConfigClick: null, + trackingConfigOpen: null, + trackingConfigFull: null, + trackingHttpsRequired: false, + }, + }); + await emitDomainEvent(cleared, "domain.updated"); + return cleared; + } + + const normalized = trimmed.toLowerCase(); + if ( + !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test( + normalized, + ) + ) { + throw new UnsendApiError({ + code: "BAD_REQUEST", + message: "Invalid tracking hostname", + }); + } + + assertTrackingHostnameAllowed(domain.name, normalized); + + const parsedHost = tldts.parse(normalized); + if (!parsedHost.subdomain) { + throw new UnsendApiError({ + code: "BAD_REQUEST", + message: + "Tracking hostname must be a subdomain (for example track.example.com), not the zone apex.", + }); + } + + if ( + domain.customTrackingHostname === normalized && + domain.customTrackingPublicKey + ) { + if ( + trackingHttpsRequired !== undefined && + trackingHttpsRequired !== domain.trackingHttpsRequired + ) { + const domainForSes: Domain = { + ...domain, + trackingHttpsRequired, + }; + await reapplyCustomTrackingSesPolicy(domainForSes); + const updated = await db.domain.update({ + where: { id: domainId }, + data: { trackingHttpsRequired }, + }); + await emitDomainEvent(updated, "domain.updated"); + return updated; + } + return domain; + } + + const previousForCleanup = + domain.customTrackingHostname && + domain.customTrackingHostname !== normalized + ? domain + : null; + + const selector = domain.customTrackingDkimSelector ?? "utrack"; + const publicKey = await ses.addTrackingEmailIdentity( + normalized, + domain.region, + domain.sesTenantId ?? undefined, + selector, + ); + + const updated = await db.domain.update({ + where: { id: domainId }, + data: { + customTrackingHostname: normalized, + customTrackingPublicKey: publicKey, + customTrackingDkimSelector: selector, + customTrackingDkimStatus: null, + customTrackingStatus: DomainStatus.PENDING, + trackingConfigGeneral: null, + trackingConfigClick: null, + trackingConfigOpen: null, + trackingConfigFull: null, + trackingHttpsRequired: + trackingHttpsRequired ?? domain.trackingHttpsRequired ?? false, + }, + }); + + if (previousForCleanup) { + await removeCustomTrackingResources(previousForCleanup); + } + + await emitDomainEvent(updated, "domain.updated"); + return updated; +} + const dnsResolveTxt = util.promisify(dns.resolveTxt); function getDomainVerificationKey(kind: string, domainId: number) { @@ -464,7 +871,7 @@ export async function getDomain(id: number, teamId: number) { }); } - if (domain.isVerifying) { + if (domain.isVerifying || shouldPollCustomTrackingVerification(domain)) { return refreshDomainVerification(domain); } @@ -506,6 +913,28 @@ export async function refreshDomainVerification( const dmarcRecord = _dmarcRecord?.[0]?.[0]; const checkedAt = new Date(); + let trackingDkimStatus: string | null = null; + let trackingVerificationStatus: DomainStatus | undefined; + + if (domain.customTrackingHostname) { + try { + const trackingIdentity = await ses.getDomainIdentity( + domain.customTrackingHostname, + domain.region, + ); + trackingDkimStatus = + trackingIdentity.DkimAttributes?.Status?.toString() ?? null; + trackingVerificationStatus = parseDomainStatus( + trackingIdentity.VerificationStatus?.toString(), + ); + } catch (error) { + logger.error( + { err: error, domainId: domain.id }, + "[DomainService]: Failed to refresh custom tracking identity status", + ); + } + } + const updatedDomain = await db.domain.update({ where: { id: domain.id, @@ -521,6 +950,13 @@ export async function refreshDomainVerification( dkimStatus, spfDetails, ), + ...(domain.customTrackingHostname && + trackingVerificationStatus !== undefined + ? { + customTrackingDkimStatus: trackingDkimStatus, + customTrackingStatus: trackingVerificationStatus, + } + : {}), }, }); @@ -561,8 +997,28 @@ export async function refreshDomainVerification( } } + let provisionedDomain = updatedDomain; + + if ( + domain.customTrackingHostname && + trackingVerificationStatus === DomainStatus.SUCCESS + ) { + try { + await ensureCustomTrackingProvisioned(domain.id); + const reloaded = await db.domain.findUnique({ where: { id: domain.id } }); + if (reloaded) { + provisionedDomain = reloaded; + } + } catch (error) { + logger.error( + { err: error, domainId: domain.id }, + "[DomainService]: ensureCustomTrackingProvisioned failed after refresh", + ); + } + } + const normalizedDomain = { - ...updatedDomain, + ...provisionedDomain, dkimStatus: dkimStatus ?? null, spfDetails: spfDetails ?? null, dmarcAdded: Boolean(dmarcRecord), @@ -622,6 +1078,8 @@ export async function deleteDomain(id: number) { throw new Error("Domain not found"); } + await removeCustomTrackingResources(domain); + const deleted = await ses.deleteDomain( domain.name, domain.region, @@ -698,6 +1156,15 @@ export async function isDomainVerificationDue(domain: Domain) { return false; } + if (shouldPollCustomTrackingVerification(domain)) { + const now = Date.now(); + const lastCheckedAt = verificationState.lastCheckedAt?.getTime() ?? 0; + if (!verificationState.lastCheckedAt) { + return true; + } + return now - lastCheckedAt >= DOMAIN_UNVERIFIED_RECHECK_MS; + } + const now = Date.now(); const lastCheckedAt = verificationState.lastCheckedAt?.getTime() ?? 0; const intervalMs = diff --git a/apps/web/src/server/service/domain-service.unit.test.ts b/apps/web/src/server/service/domain-service.unit.test.ts index a2fbf78d..e0d8b46b 100644 --- a/apps/web/src/server/service/domain-service.unit.test.ts +++ b/apps/web/src/server/service/domain-service.unit.test.ts @@ -4,6 +4,9 @@ import { DomainStatus, type Domain } from "@prisma/client"; const { mockDb, mockGetDomainIdentity, + mockAddTrackingEmailIdentity, + mockDeleteConfigurationSet, + mockDeleteDomain, mockWebhookEmit, mockRedis, mockSendMail, @@ -14,12 +17,16 @@ const { domain: { update: vi.fn(), findUnique: vi.fn(), + findFirst: vi.fn(), }, teamUser: { findMany: vi.fn(), }, }, mockGetDomainIdentity: vi.fn(), + mockAddTrackingEmailIdentity: vi.fn(), + mockDeleteConfigurationSet: vi.fn(), + mockDeleteDomain: vi.fn(), mockWebhookEmit: vi.fn(), mockRedis: { mget: vi.fn(), @@ -49,6 +56,9 @@ vi.mock("~/server/db", () => ({ vi.mock("~/server/aws/ses", () => ({ getDomainIdentity: mockGetDomainIdentity, + addTrackingEmailIdentity: mockAddTrackingEmailIdentity, + deleteConfigurationSet: mockDeleteConfigurationSet, + deleteDomain: mockDeleteDomain, })); vi.mock("~/server/service/webhook-service", () => ({ @@ -70,11 +80,19 @@ vi.mock("~/server/email-templates", () => ({ renderDomainVerificationStatusEmail: mockRenderDomainVerificationStatusEmail, })); +vi.mock("~/env", () => ({ + env: { + NEXT_PUBLIC_IS_CLOUD: false, + NEXTAUTH_URL: "http://localhost:3000", + }, +})); + import { DOMAIN_UNVERIFIED_RECHECK_MS, DOMAIN_VERIFIED_RECHECK_MS, isDomainVerificationDue, refreshDomainVerification, + setCustomTrackingHostname, } from "~/server/service/domain-service"; function createDomain(overrides: Partial = {}): Domain { @@ -95,6 +113,16 @@ function createDomain(overrides: Partial = {}): Domain { subdomain: null, sesTenantId: null, isVerifying: true, + customTrackingHostname: null, + customTrackingPublicKey: null, + customTrackingDkimSelector: "utrack", + customTrackingDkimStatus: null, + customTrackingStatus: DomainStatus.NOT_STARTED, + trackingConfigGeneral: null, + trackingConfigClick: null, + trackingConfigOpen: null, + trackingConfigFull: null, + trackingHttpsRequired: false, createdAt: new Date("2026-03-01T00:00:00.000Z"), updatedAt: new Date("2026-03-01T00:00:00.000Z"), ...overrides, @@ -108,6 +136,10 @@ describe("domain-service", () => { mockDb.domain.update.mockReset(); mockDb.domain.findUnique.mockReset(); + mockDb.domain.findFirst.mockReset(); + mockAddTrackingEmailIdentity.mockReset(); + mockDeleteConfigurationSet.mockReset(); + mockDeleteDomain.mockReset(); mockDb.teamUser.findMany.mockReset(); mockGetDomainIdentity.mockReset(); mockWebhookEmit.mockReset(); @@ -126,11 +158,9 @@ describe("domain-service", () => { { user: { email: "alice@example.com" } }, { user: { email: "bob@example.com" } }, ]); - mockResolveTxt.mockImplementation( - (_name: string, cb: (err: Error | null, value?: string[][]) => void) => { - cb(null, [["v=DMARC1; p=none;"]]); - }, - ); + mockResolveTxt.mockImplementation((_name, cb) => { + cb(null, [["v=DMARC1; p=none;"]]); + }); }); it("sends success status emails to all team members when a new domain becomes verified", async () => { @@ -411,4 +441,91 @@ describe("domain-service", () => { await expect(isDomainVerificationDue(domain)).resolves.toBe(false); }); + + it("uses unverified cadence when custom tracking is still pending even if the sending domain is verified", async () => { + const domain = createDomain({ + status: DomainStatus.SUCCESS, + customTrackingHostname: "track.example.com", + customTrackingPublicKey: "pk", + customTrackingStatus: DomainStatus.PENDING, + }); + mockRedis.mget.mockResolvedValue([ + new Date( + Date.now() - DOMAIN_UNVERIFIED_RECHECK_MS + 5 * 60 * 1000, + ).toISOString(), + DomainStatus.SUCCESS, + "1", + ]); + + await expect(isDomainVerificationDue(domain)).resolves.toBe(false); + + mockRedis.mget.mockResolvedValue([ + new Date( + Date.now() - DOMAIN_UNVERIFIED_RECHECK_MS - 5 * 60 * 1000, + ).toISOString(), + DomainStatus.SUCCESS, + "1", + ]); + + await expect(isDomainVerificationDue(domain)).resolves.toBe(true); + }); + + it("uses unverified cadence when custom tracking identity is SUCCESS but configuration sets are not provisioned", async () => { + const domain = createDomain({ + status: DomainStatus.SUCCESS, + customTrackingHostname: "track.example.com", + customTrackingPublicKey: "pk", + customTrackingStatus: DomainStatus.SUCCESS, + trackingConfigGeneral: null, + trackingConfigClick: null, + trackingConfigOpen: null, + trackingConfigFull: null, + }); + mockRedis.mget.mockResolvedValue([ + new Date( + Date.now() - DOMAIN_UNVERIFIED_RECHECK_MS + 5 * 60 * 1000, + ).toISOString(), + DomainStatus.SUCCESS, + "1", + ]); + + await expect(isDomainVerificationDue(domain)).resolves.toBe(false); + + mockRedis.mget.mockResolvedValue([ + new Date( + Date.now() - DOMAIN_UNVERIFIED_RECHECK_MS - 5 * 60 * 1000, + ).toISOString(), + DomainStatus.SUCCESS, + "1", + ]); + + await expect(isDomainVerificationDue(domain)).resolves.toBe(true); + }); + + it("preserves trackingHttpsRequired when changing hostname if omitted", async () => { + const existing = createDomain({ + status: DomainStatus.SUCCESS, + customTrackingHostname: "track.old.example.com", + customTrackingPublicKey: "oldpk", + customTrackingStatus: DomainStatus.SUCCESS, + trackingHttpsRequired: true, + }); + mockDb.domain.findFirst.mockResolvedValue(existing); + mockAddTrackingEmailIdentity.mockResolvedValue("newpk"); + mockDb.domain.update.mockImplementation(async ({ data }) => + createDomain({ ...existing, ...data }), + ); + mockDeleteConfigurationSet.mockResolvedValue(undefined); + mockDeleteDomain.mockResolvedValue(undefined); + + await setCustomTrackingHostname(42, 7, "track.new.example.com"); + + expect(mockDb.domain.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + trackingHttpsRequired: true, + }), + }), + ); + }); }); diff --git a/apps/web/src/server/service/email-queue-service.ts b/apps/web/src/server/service/email-queue-service.ts index fef79422..0c2713a0 100644 --- a/apps/web/src/server/service/email-queue-service.ts +++ b/apps/web/src/server/service/email-queue-service.ts @@ -342,9 +342,18 @@ async function executeEmail(job: QueueEmailJob) { logger.info({ domain }, `Domain`); const configurationSetName = await getConfigurationSetName( - domain?.clickTracking ?? false, - domain?.openTracking ?? false, - domain?.region ?? env.AWS_DEFAULT_REGION + domain + ? { + clickTracking: domain.clickTracking, + openTracking: domain.openTracking, + region: domain.region, + trackingConfigGeneral: domain.trackingConfigGeneral, + trackingConfigClick: domain.trackingConfigClick, + trackingConfigOpen: domain.trackingConfigOpen, + trackingConfigFull: domain.trackingConfigFull, + } + : null, + env.AWS_DEFAULT_REGION, ); if (!configurationSetName) { diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts index a073d131..c3b34de5 100644 --- a/apps/web/src/server/service/ses-hook-parser.ts +++ b/apps/web/src/server/service/ses-hook-parser.ts @@ -20,7 +20,7 @@ import { unsubscribeContact, updateCampaignAnalytics, } from "./campaign-service"; -import { env } from "~/env"; +import { isUnsubscribeEngagementExemptLink } from "./unsubscribe-engagement-exempt"; import { getRedis, BULL_PREFIX } from "../redis"; import { Queue, Worker } from "bullmq"; import { @@ -264,7 +264,7 @@ export async function parseSesHook(data: SesEvent) { if (email.campaignId) { if ( mailStatus !== "CLICKED" || - !(mailData as SesClick).link.startsWith(`${env.NEXTAUTH_URL}/unsubscribe`) + !isUnsubscribeEngagementExemptLink((mailData as SesClick).link) ) { await checkUnsubscribe({ contactId: email.contactId!, diff --git a/apps/web/src/server/service/unsubscribe-engagement-exempt.ts b/apps/web/src/server/service/unsubscribe-engagement-exempt.ts new file mode 100644 index 00000000..8e9761be --- /dev/null +++ b/apps/web/src/server/service/unsubscribe-engagement-exempt.ts @@ -0,0 +1,20 @@ +import { env } from "~/env"; + +/** Destination URLs for opt-out should not increment campaign click/open-style engagement. */ +export function isUnsubscribeEngagementExemptLink( + link: string | undefined, +): boolean { + if (!link) { + return false; + } + try { + const u = new URL(link); + return /\bunsubscribe\b/i.test(`${u.pathname}${u.search}`); + } catch { + const prefix = env.NEXTAUTH_URL.replace(/\/$/, ""); + return ( + link.startsWith(`${prefix}/unsubscribe`) || + /\/api\/unsubscribe/i.test(link) + ); + } +} diff --git a/apps/web/src/server/service/unsubscribe-engagement-exempt.unit.test.ts b/apps/web/src/server/service/unsubscribe-engagement-exempt.unit.test.ts new file mode 100644 index 00000000..70dcc77f --- /dev/null +++ b/apps/web/src/server/service/unsubscribe-engagement-exempt.unit.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("~/env", () => ({ + env: { + NEXTAUTH_URL: "http://localhost:3000", + }, +})); + +import { isUnsubscribeEngagementExemptLink } from "./unsubscribe-engagement-exempt"; + +describe("isUnsubscribeEngagementExemptLink", () => { + it("exempts branded unsubscribe URLs on a different origin", () => { + expect( + isUnsubscribeEngagementExemptLink( + "https://branded.example.com/unsubscribe", + ), + ).toBe(true); + }); + + it("exempts same-origin unsubscribe links", () => { + expect( + isUnsubscribeEngagementExemptLink( + "http://localhost:3000/unsubscribe?token=1", + ), + ).toBe(true); + }); + + it("returns false when unsubscribe does not appear in path or query", () => { + expect( + isUnsubscribeEngagementExemptLink("https://other.example.com/pricing"), + ).toBe(false); + }); + + it("matches relative /api/unsubscribe paths in the fallback branch", () => { + expect(isUnsubscribeEngagementExemptLink("/api/unsubscribe?x=1")).toBe( + true, + ); + }); +}); diff --git a/apps/web/src/server/service/webhook-service.unit.test.ts b/apps/web/src/server/service/webhook-service.unit.test.ts index 73fd9feb..c529701a 100644 --- a/apps/web/src/server/service/webhook-service.unit.test.ts +++ b/apps/web/src/server/service/webhook-service.unit.test.ts @@ -572,7 +572,7 @@ describe("WebhookService.emit domain filters", () => { await WebhookService.emit(10, "contact.created", { id: "contact_1", email: "test@example.com", - contactBookId: 1, + contactBookId: "1", subscribed: true, properties: {}, firstName: null, diff --git a/apps/web/src/server/utils/ses-tracking-html.ts b/apps/web/src/server/utils/ses-tracking-html.ts new file mode 100644 index 00000000..984f2dda --- /dev/null +++ b/apps/web/src/server/utils/ses-tracking-html.ts @@ -0,0 +1,16 @@ +/** + * SES wraps tracked links unless the anchor has `ses:no-track` (see SES metrics FAQ). + * Apply to unsubscribe / preference URLs so opt-outs are not counted as engagement clicks. + * + * @see https://docs.aws.amazon.com/ses/latest/dg/faqs-metrics.html + */ +export function addSesNoTrackToUnsubscribeLinks(html: string): string { + if (!/unsubscribe/i.test(html)) { + return html; + } + + return html.replace( + /]*\sses:no-track)([^>]*\bhref\s*=\s*["'][^"']*unsubscribe[^"']*["'][^>]*)>/gi, + "", + ); +} diff --git a/apps/web/src/server/utils/ses-tracking-html.unit.test.ts b/apps/web/src/server/utils/ses-tracking-html.unit.test.ts new file mode 100644 index 00000000..d243cfb5 --- /dev/null +++ b/apps/web/src/server/utils/ses-tracking-html.unit.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { addSesNoTrackToUnsubscribeLinks } from "~/server/utils/ses-tracking-html"; + +describe("addSesNoTrackToUnsubscribeLinks", () => { + it("adds ses:no-track to anchors whose href contains unsubscribe", () => { + const html = + '

Unsub

'; + const out = addSesNoTrackToUnsubscribeLinks(html); + expect(out).toContain("ses:no-track"); + expect(out).toBe( + '

Unsub

', + ); + }); + + it("does not duplicate ses:no-track", () => { + const html = + 'x'; + expect(addSesNoTrackToUnsubscribeLinks(html)).toBe(html); + }); + + it("leaves non-unsubscribe links unchanged", () => { + const html = 'Home'; + expect(addSesNoTrackToUnsubscribeLinks(html)).toBe(html); + }); + + it("handles mixed-case unsubscribe in href", () => { + const html = + 'x'; + const out = addSesNoTrackToUnsubscribeLinks(html); + expect(out).toContain("ses:no-track"); + }); +}); diff --git a/apps/web/src/types/domain.ts b/apps/web/src/types/domain.ts index 10c791da..ba7aef47 100644 --- a/apps/web/src/types/domain.ts +++ b/apps/web/src/types/domain.ts @@ -1,7 +1,7 @@ import type { Domain, DomainStatus } from "@prisma/client"; export type DomainDnsRecord = { - type: "MX" | "TXT"; + type: "MX" | "TXT" | "CNAME"; name: string; value: string; ttl: string; diff --git a/apps/web/src/utils/ses-utils.ts b/apps/web/src/utils/ses-utils.ts index c589a5b1..a2e1313e 100644 --- a/apps/web/src/utils/ses-utils.ts +++ b/apps/web/src/utils/ses-utils.ts @@ -1,23 +1,53 @@ import { SesSettingsService } from "~/server/service/ses-settings-service"; +export type DomainConfigurationSetPick = { + clickTracking: boolean; + openTracking: boolean; + region: string; + trackingConfigGeneral: string | null; + trackingConfigClick: string | null; + trackingConfigOpen: string | null; + trackingConfigFull: string | null; +}; + export async function getConfigurationSetName( - clickTracking: boolean, - openTracking: boolean, - region: string + domain: DomainConfigurationSetPick | null, + regionFallback: string, ) { + const region = domain?.region ?? regionFallback; const setting = await SesSettingsService.getSetting(region); if (!setting) { throw new Error(`No SES setting found for region: ${region}`); } - if (clickTracking && openTracking) { + const useCustom = + domain && + domain.trackingConfigGeneral && + domain.trackingConfigClick && + domain.trackingConfigOpen && + domain.trackingConfigFull; + + if (useCustom) { + if (domain.clickTracking && domain.openTracking) { + return domain.trackingConfigFull; + } + if (domain.clickTracking) { + return domain.trackingConfigClick; + } + if (domain.openTracking) { + return domain.trackingConfigOpen; + } + return domain.trackingConfigGeneral; + } + + if (domain?.clickTracking && domain?.openTracking) { return setting.configFull; } - if (clickTracking) { + if (domain?.clickTracking) { return setting.configClick; } - if (openTracking) { + if (domain?.openTracking) { return setting.configOpen; }