Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

# Dependencies
node_modules
.pnpm-store
.pnp
.pnp.js

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Domain" ADD COLUMN "trackingHttpsRequired" BOOLEAN NOT NULL DEFAULT false;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE UNIQUE INDEX "Domain_customTrackingHostname_key" ON "Domain"("customTrackingHostname");
12 changes: 12 additions & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +199 to +210
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make customTrackingHostname unique to prevent cross-domain resource deletion.

Right now two Domain rows can point at the same SES tracking hostname. Clearing or deleting one domain then calls custom tracking cleanup and can remove SES resources still referenced by the other domain.

Suggested schema hardening
-  customTrackingHostname     String?
+  customTrackingHostname     String?      `@unique`

Add the matching Prisma migration as well.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// 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)
/// 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)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/prisma/schema.prisma` around lines 199 - 210, The schema allows
multiple Domain rows to share the same customTrackingHostname which can cause
deletion of SES resources still used by another Domain; update the Prisma schema
by adding a unique constraint/index on the Domain model for the field
customTrackingHostname (i.e., make customTrackingHostname unique) and create the
corresponding Prisma migration to apply this change so the database enforces
uniqueness and prevents cross-domain resource deletion.

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
Expand Down
145 changes: 142 additions & 3 deletions apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppRouter>;
type DomainResponse = NonNullable<RouterOutputs["domain"]["getDomain"]>;
Expand All @@ -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,
},
);
Expand Down Expand Up @@ -128,8 +144,8 @@ export default function DomainItemPage({
</TableRow>
</TableHeader>
<TableBody>
{(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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -236,6 +267,114 @@ const DomainSettings: React.FC<{ domain: DomainResponse }> = ({ domain }) => {
/>
</div>

{!env.NEXT_PUBLIC_IS_CLOUD ? (
<div className="flex flex-col gap-3 border-t border-border pt-6">
<div className="font-semibold">Custom tracking domain</div>
<p className="text-muted-foreground text-sm">
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{" "}
<span className="font-mono text-xs">track.example.com</span> for{" "}
<span className="font-mono text-xs">example.com</span>). You need{" "}
<strong>both</strong> records in the DNS table: the DKIM TXT proves
ownership to SES; the CNAME points your hostname at Amazon&apos;s
regional tracking servers so links and pixels resolve.
</p>
<p className="text-muted-foreground text-sm">
<strong>HTTPS for tracking links</strong> 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{" "}
<strong>Cloudflare proxy</strong> (orange cloud) on the tracking
name so visitors get HTTPS without running CloudFront. Advanced
setups can use CloudFront + ACM or another TLS terminator instead.
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:gap-3">
<div className="flex flex-col gap-1 flex-1">
<span className="text-xs text-muted-foreground">Hostname</span>
<Input
placeholder="track.yourdomain.com"
value={trackingHostDraft}
onChange={(e) => setTrackingHostDraft(e.target.value)}
disabled={setTrackingHost.isPending}
/>
</div>
<Button
type="button"
variant="secondary"
disabled={setTrackingHost.isPending}
onClick={() => {
const trimmed = trackingHostDraft.trim();
setTrackingHost.mutate(
{
id: domain.id,
hostname: trimmed === "" ? null : trimmed.toLowerCase(),
trackingHttpsRequired: trackingHttpsDraft,
},
{
onSuccess: () => {
utils.domain.invalidate();
toast.success(
trimmed === ""
? "Custom tracking domain removed"
: "Saved — add the DKIM TXT and CNAME (to AWS tracking host) from DNS records, then verify",
);
},
onError: (err) => {
toast.error(err.message);
},
},
);
}}
>
{domain.customTrackingHostname ? "Update" : "Save"}
</Button>
</div>
<div className="flex flex-col gap-1">
<div className="font-semibold text-sm">
Require HTTPS for tracking links
</div>
<p className="text-muted-foreground text-sm">
Tells SES to use HTTPS in tracking URLs. Only enable if this
hostname already serves a valid certificate (e.g. Cloudflare
proxy).
</p>
<Switch
checked={trackingHttpsDraft}
onCheckedChange={(checked) => {
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"
/>
</div>
{domain.customTrackingHostname ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">Tracking identity:</span>
<DnsVerificationStatus status={domain.customTrackingStatus} />
</div>
) : null}
</div>
) : null}

<div className="flex flex-col gap-2">
<p className="font-semibold text-lg text-destructive">Danger</p>

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/zod/domain-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/server/api/routers/domain.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";

import {
createTRPCRouter,
Expand All @@ -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";
Expand Down Expand Up @@ -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 };
Expand Down
Loading