From f239be8e20919c6ba19528595e5a3663b353d00a Mon Sep 17 00:00:00 2001 From: amanjuman <19264857+amanjuman@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:24:33 +0200 Subject: [PATCH 1/4] feat: self-hosted custom tracking domain, per-domain HTTPS, and unsubscribe fixes Add SES custom tracking hostname for self-hosted installs (DNS, verification, per-domain configuration sets). Store tracking HTTPS preference on the domain (OPTIONAL vs REQUIRE) instead of a global env var; document Cloudflare proxy as a simple TLS option for tracking links. Apply ses:no-track to unsubscribe links so opt-outs are not click-tracked, and broaden campaign analytics to ignore unsubscribe destination URLs. Include Prisma migrations, root docker-compose for app/postgres/redis, ignore .pnpm-store, and update unit tests. Made-with: Cursor --- .env.selfhost.example | 1 + .gitignore | 1 + .../migration.sql | 10 + .../migration.sql | 2 + apps/web/prisma/schema.prisma | 12 + .../(dashboard)/domains/[domainId]/page.tsx | 145 +++++- apps/web/src/lib/zod/domain-schema.ts | 2 +- apps/web/src/server/api/routers/domain.ts | 29 ++ apps/web/src/server/aws/ses.ts | 103 +++- .../jobs/domain-verification-job.unit.test.ts | 10 + apps/web/src/server/service/domain-service.ts | 442 +++++++++++++++++- .../service/domain-service.unit.test.ts | 10 + .../src/server/service/email-queue-service.ts | 15 +- .../web/src/server/service/ses-hook-parser.ts | 23 +- .../service/webhook-service.unit.test.ts | 2 +- .../web/src/server/utils/ses-tracking-html.ts | 16 + .../utils/ses-tracking-html.unit.test.ts | 25 + apps/web/src/types/domain.ts | 2 +- apps/web/src/utils/ses-utils.ts | 42 +- docker-compose.yml | 28 ++ 20 files changed, 900 insertions(+), 20 deletions(-) create mode 100644 apps/web/prisma/migrations/20260418120000_custom_tracking_domain/migration.sql create mode 100644 apps/web/prisma/migrations/20260418153000_domain_tracking_https_required/migration.sql create mode 100644 apps/web/src/server/utils/ses-tracking-html.ts create mode 100644 apps/web/src/server/utils/ses-tracking-html.unit.test.ts create mode 100644 docker-compose.yml diff --git a/.env.selfhost.example b/.env.selfhost.example index d294bd34..fbcc6eab 100644 --- a/.env.selfhost.example +++ b/.env.selfhost.example @@ -27,6 +27,7 @@ GOOGLE_CLIENT_SECRET="" # AWS details - required AWS_DEFAULT_REGION="us-east-1" +# Custom tracking HTTPS: configure per domain in the dashboard (toggle when adding the tracking hostname). AWS_SECRET_KEY="" AWS_ACCESS_KEY="" 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/schema.prisma b/apps/web/prisma/schema.prisma index 1492adf8..1ac5bdfe 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? + 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..e1d6cd02 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,102 @@ 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); + return response.$metadata.httpStatusCode === 200; +} + +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 +317,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, 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..24fb7c0f 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,340 @@ function withDnsRecords( ): T & { dnsRecords: DomainDnsRecord[] } { return { ...domain, - dnsRecords: buildDnsRecords(domain), + dnsRecords: [...buildDnsRecords(domain), ...buildTrackingDnsRecords(domain)], }; } +function shouldPollCustomTrackingVerification(domain: Domain): boolean { + if (env.NEXT_PUBLIC_IS_CLOUD) { + return false; + } + if (!domain.customTrackingHostname || !domain.customTrackingPublicKey) { + return false; + } + if (domain.customTrackingStatus === DomainStatus.SUCCESS) { + return false; + } + if (domain.customTrackingStatus === DomainStatus.FAILED) { + return false; + } + 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); + + if ( + domain.customTrackingHostname === normalized && + domain.customTrackingPublicKey + ) { + if ( + trackingHttpsRequired !== undefined && + trackingHttpsRequired !== domain.trackingHttpsRequired + ) { + const updated = await db.domain.update({ + where: { id: domainId }, + data: { trackingHttpsRequired }, + }); + try { + await reapplyCustomTrackingSesPolicy(updated); + } catch (error) { + logger.error( + { err: error, domainId }, + "[DomainService]: Failed to reapply custom tracking HTTPS policy", + ); + } + await emitDomainEvent(updated, "domain.updated"); + return updated; + } + return domain; + } + + if (domain.customTrackingHostname) { + await removeCustomTrackingResources(domain); + } + + 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 ?? false, + }, + }); + + await emitDomainEvent(updated, "domain.updated"); + return updated; +} + const dnsResolveTxt = util.promisify(dns.resolveTxt); function getDomainVerificationKey(kind: string, domainId: number) { @@ -464,7 +849,7 @@ export async function getDomain(id: number, teamId: number) { }); } - if (domain.isVerifying) { + if (domain.isVerifying || shouldPollCustomTrackingVerification(domain)) { return refreshDomainVerification(domain); } @@ -506,6 +891,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 +928,13 @@ export async function refreshDomainVerification( dkimStatus, spfDetails, ), + ...(domain.customTrackingHostname && + trackingVerificationStatus !== undefined + ? { + customTrackingDkimStatus: trackingDkimStatus, + customTrackingStatus: trackingVerificationStatus, + } + : {}), }, }); @@ -561,8 +975,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 +1056,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, 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..be9dafd2 100644 --- a/apps/web/src/server/service/domain-service.unit.test.ts +++ b/apps/web/src/server/service/domain-service.unit.test.ts @@ -95,6 +95,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, 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..7528c8fb 100644 --- a/apps/web/src/server/service/ses-hook-parser.ts +++ b/apps/web/src/server/service/ses-hook-parser.ts @@ -32,6 +32,27 @@ import { randomUUID } from "crypto"; import { SuppressionService } from "./suppression-service"; import { WebhookService } from "./webhook-service"; +/** Destination URLs for opt-out should not increment campaign click/open-style engagement. */ +function isUnsubscribeEngagementExemptLink(link: string | undefined): boolean { + if (!link) { + return false; + } + try { + const base = new URL(env.NEXTAUTH_URL); + const u = new URL(link); + if (u.origin !== base.origin) { + return false; + } + 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) + ); + } +} + export async function parseSesHook(data: SesEvent) { const mailStatus = getEmailStatus(data); @@ -264,7 +285,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/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..036f33ae --- /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 (!html.includes("unsubscribe")) { + 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..59caf5a3 --- /dev/null +++ b/apps/web/src/server/utils/ses-tracking-html.unit.test.ts @@ -0,0 +1,25 @@ +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); + }); +}); 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; } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..f03a625e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + app: + image: usesend/usesend:latest + restart: always + env_file: + - .env + ports: + - "3000:3000" + depends_on: + - postgres + - redis + + postgres: + image: postgres:15 + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: usesend + volumes: + - pgdata:/var/lib/postgresql/data + + redis: + image: redis:7 + restart: always + +volumes: + pgdata: From 11170821c11c491ca8f2f398ad2cab9804e814e3 Mon Sep 17 00:00:00 2001 From: amanjuman <19264857+amanjuman@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:41:01 +0200 Subject: [PATCH 2/4] chore: remove root docker-compose and redundant env example note Drop fork-local compose file not needed upstream; keep .env.selfhost.example aligned with existing AWS vars only. Made-with: Cursor --- .env.selfhost.example | 1 - docker-compose.yml | 28 ---------------------------- 2 files changed, 29 deletions(-) delete mode 100644 docker-compose.yml diff --git a/.env.selfhost.example b/.env.selfhost.example index fbcc6eab..d294bd34 100644 --- a/.env.selfhost.example +++ b/.env.selfhost.example @@ -27,7 +27,6 @@ GOOGLE_CLIENT_SECRET="" # AWS details - required AWS_DEFAULT_REGION="us-east-1" -# Custom tracking HTTPS: configure per domain in the dashboard (toggle when adding the tracking hostname). AWS_SECRET_KEY="" AWS_ACCESS_KEY="" diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index f03a625e..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,28 +0,0 @@ -services: - app: - image: usesend/usesend:latest - restart: always - env_file: - - .env - ports: - - "3000:3000" - depends_on: - - postgres - - redis - - postgres: - image: postgres:15 - restart: always - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: usesend - volumes: - - pgdata:/var/lib/postgresql/data - - redis: - image: redis:7 - restart: always - -volumes: - pgdata: From 8a6ea9916331d665f88bf29dc1f894b5847725be Mon Sep 17 00:00:00 2001 From: amanjuman <19264857+amanjuman@users.noreply.github.com> Date: Sat, 18 Apr 2026 03:14:33 +0200 Subject: [PATCH 3/4] fix: address PR #391 review (tracking UX, SES idempotency, schema uniqueness) - Case-insensitive unsubscribe pre-check; reject apex tracking hostnames - PutConfigurationSetTrackingOptions throws on non-200; addWebhookConfiguration treats AlreadyExists as success and still returns boolean - Poll custom tracking verification on 6h cadence when tracking DNS is pending - Apply HTTPS policy to SES before persisting DB; create new tracking identity before removing old SES resources - Unique customTrackingHostname to avoid cross-domain SES cleanup clashes - Tests: mixed-case unsubscribe, verification due with pending tracking Made-with: Cursor --- .../migration.sql | 2 + apps/web/prisma/schema.prisma | 2 +- apps/web/src/server/aws/ses.ts | 46 ++++++++++++++++--- apps/web/src/server/service/domain-service.ts | 43 ++++++++++++----- .../service/domain-service.unit.test.ts | 35 ++++++++++++++ .../web/src/server/utils/ses-tracking-html.ts | 2 +- .../utils/ses-tracking-html.unit.test.ts | 7 +++ 7 files changed, 117 insertions(+), 20 deletions(-) create mode 100644 apps/web/prisma/migrations/20260419120000_domain_custom_tracking_hostname_unique/migration.sql 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 1ac5bdfe..ce83c2ff 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -197,7 +197,7 @@ model Domain { 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? + customTrackingHostname String? @unique customTrackingPublicKey String? customTrackingDkimSelector String? @default("utrack") customTrackingDkimStatus String? diff --git a/apps/web/src/server/aws/ses.ts b/apps/web/src/server/aws/ses.ts index e1d6cd02..76035488 100644 --- a/apps/web/src/server/aws/ses.ts +++ b/apps/web/src/server/aws/ses.ts @@ -217,7 +217,12 @@ export async function putConfigurationSetHttpsTracking( HttpsPolicy: httpsPolicy, }); const response = await sesClient.send(cmd); - return response.$metadata.httpStatusCode === 200; + const code = response.$metadata.httpStatusCode; + if (code !== 200) { + throw new Error( + `PutConfigurationSetTrackingOptions failed for ${configurationSetName}: HTTP ${code ?? "unknown"}`, + ); + } } export async function deleteConfigurationSet( @@ -379,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, @@ -391,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({ @@ -409,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/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index 24fb7c0f..e37db62b 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -435,6 +435,15 @@ export async function setCustomTrackingHostname( 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 @@ -443,27 +452,26 @@ export async function setCustomTrackingHostname( trackingHttpsRequired !== undefined && trackingHttpsRequired !== domain.trackingHttpsRequired ) { + const domainForSes: Domain = { + ...domain, + trackingHttpsRequired, + }; + await reapplyCustomTrackingSesPolicy(domainForSes); const updated = await db.domain.update({ where: { id: domainId }, data: { trackingHttpsRequired }, }); - try { - await reapplyCustomTrackingSesPolicy(updated); - } catch (error) { - logger.error( - { err: error, domainId }, - "[DomainService]: Failed to reapply custom tracking HTTPS policy", - ); - } await emitDomainEvent(updated, "domain.updated"); return updated; } return domain; } - if (domain.customTrackingHostname) { - await removeCustomTrackingResources(domain); - } + const previousForCleanup = + domain.customTrackingHostname && + domain.customTrackingHostname !== normalized + ? domain + : null; const selector = domain.customTrackingDkimSelector ?? "utrack"; const publicKey = await ses.addTrackingEmailIdentity( @@ -489,6 +497,10 @@ export async function setCustomTrackingHostname( }, }); + if (previousForCleanup) { + await removeCustomTrackingResources(previousForCleanup); + } + await emitDomainEvent(updated, "domain.updated"); return updated; } @@ -1134,6 +1146,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 be9dafd2..e0427abb 100644 --- a/apps/web/src/server/service/domain-service.unit.test.ts +++ b/apps/web/src/server/service/domain-service.unit.test.ts @@ -70,6 +70,13 @@ 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, @@ -421,4 +428,32 @@ 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); + }); }); diff --git a/apps/web/src/server/utils/ses-tracking-html.ts b/apps/web/src/server/utils/ses-tracking-html.ts index 036f33ae..984f2dda 100644 --- a/apps/web/src/server/utils/ses-tracking-html.ts +++ b/apps/web/src/server/utils/ses-tracking-html.ts @@ -5,7 +5,7 @@ * @see https://docs.aws.amazon.com/ses/latest/dg/faqs-metrics.html */ export function addSesNoTrackToUnsubscribeLinks(html: string): string { - if (!html.includes("unsubscribe")) { + if (!/unsubscribe/i.test(html)) { return html; } 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 index 59caf5a3..d243cfb5 100644 --- a/apps/web/src/server/utils/ses-tracking-html.unit.test.ts +++ b/apps/web/src/server/utils/ses-tracking-html.unit.test.ts @@ -22,4 +22,11 @@ describe("addSesNoTrackToUnsubscribeLinks", () => { 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"); + }); }); From fb600adbeb9d18dcec3c82030e23c8278eab4ccd Mon Sep 17 00:00:00 2001 From: amanjuman <19264857+amanjuman@users.noreply.github.com> Date: Sat, 18 Apr 2026 03:35:12 +0200 Subject: [PATCH 4/4] fix: custom tracking polling, HTTPS policy, and unsubscribe click exemption Keep polling when SES tracking is SUCCESS but config sets are missing; preserve trackingHttpsRequired when rotating hostnames; treat branded unsubscribe URLs as non-engagement without same-origin check. Extract unsubscribe helper for tests. Made-with: Cursor --- apps/web/src/server/service/domain-service.ts | 18 +++- .../service/domain-service.unit.test.ts | 82 +++++++++++++++++-- .../web/src/server/service/ses-hook-parser.ts | 23 +----- .../service/unsubscribe-engagement-exempt.ts | 20 +++++ ...unsubscribe-engagement-exempt.unit.test.ts | 39 +++++++++ 5 files changed, 151 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/server/service/unsubscribe-engagement-exempt.ts create mode 100644 apps/web/src/server/service/unsubscribe-engagement-exempt.unit.test.ts diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index e37db62b..f0fbd200 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -163,6 +163,15 @@ function withDnsRecords( }; } +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; @@ -170,12 +179,12 @@ function shouldPollCustomTrackingVerification(domain: Domain): boolean { if (!domain.customTrackingHostname || !domain.customTrackingPublicKey) { return false; } - if (domain.customTrackingStatus === DomainStatus.SUCCESS) { - return false; - } if (domain.customTrackingStatus === DomainStatus.FAILED) { return false; } + if (domain.customTrackingStatus === DomainStatus.SUCCESS) { + return !isCustomTrackingProvisioningComplete(domain); + } return true; } @@ -493,7 +502,8 @@ export async function setCustomTrackingHostname( trackingConfigClick: null, trackingConfigOpen: null, trackingConfigFull: null, - trackingHttpsRequired: trackingHttpsRequired ?? false, + trackingHttpsRequired: + trackingHttpsRequired ?? domain.trackingHttpsRequired ?? false, }, }); 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 e0427abb..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", () => ({ @@ -82,6 +92,7 @@ import { DOMAIN_VERIFIED_RECHECK_MS, isDomainVerificationDue, refreshDomainVerification, + setCustomTrackingHostname, } from "~/server/service/domain-service"; function createDomain(overrides: Partial = {}): Domain { @@ -125,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(); @@ -143,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 () => { @@ -456,4 +469,63 @@ describe("domain-service", () => { 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/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts index 7528c8fb..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 { @@ -32,27 +32,6 @@ import { randomUUID } from "crypto"; import { SuppressionService } from "./suppression-service"; import { WebhookService } from "./webhook-service"; -/** Destination URLs for opt-out should not increment campaign click/open-style engagement. */ -function isUnsubscribeEngagementExemptLink(link: string | undefined): boolean { - if (!link) { - return false; - } - try { - const base = new URL(env.NEXTAUTH_URL); - const u = new URL(link); - if (u.origin !== base.origin) { - return false; - } - 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) - ); - } -} - export async function parseSesHook(data: SesEvent) { const mailStatus = getEmailStatus(data); 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, + ); + }); +});