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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Domain" ADD COLUMN "mailFromLabel" TEXT;
2 changes: 2 additions & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ model Domain {
dmarcAdded Boolean @default(false)
errorMessage String?
subdomain String?
/// Optional first label for custom MAIL FROM (e.g. "bounce"); full host is `{label}.{name}`. Null means use `region` as the label (e.g. us-east-1.example.com).
mailFromLabel String?
sesTenantId String?
isVerifying Boolean @default(false)
createdAt DateTime @default(now())
Expand Down
14 changes: 9 additions & 5 deletions apps/web/src/app/(dashboard)/admin/teams/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
SelectValue,
} from "@usesend/ui/src/select";
import { formatDistanceToNow } from "date-fns";
import { aggregateDomainStatus } from "~/lib/domain-aggregate-status";

import { api } from "~/trpc/react";
import type { AppRouter } from "~/server/api/root";
Expand Down Expand Up @@ -223,19 +224,22 @@ export default function AdminTeamsPage() {
<h3 className="text-sm font-medium text-muted-foreground">Domains</h3>
<div className="space-y-2 rounded-lg border bg-muted/20 p-3">
{team.domains.length ? (
team.domains.map((domain) => (
team.domains.map((domain) => {
const agg = aggregateDomainStatus(domain);
return (
<div
key={domain.id}
className="flex items-center justify-between rounded-md bg-background px-3 py-2 text-sm"
>
<span>{domain.name}</span>
<Badge variant={domain.status === "SUCCESS" ? "outline" : "secondary"}>
{domain.status === "SUCCESS"
<Badge variant={agg === "SUCCESS" ? "outline" : "secondary"}>
{agg === "SUCCESS"
? "Verified"
: domain.status.toLowerCase()}
: agg.toLowerCase()}
</Badge>
Comment on lines +227 to 239
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

aggregateDomainStatus(domain) here is computed from team.domains, but the admin API selection for domains (see teamAdminSelection in apps/web/src/server/api/routers/admin.ts) only includes status/isVerifying and does not include dkimStatus/spfDetails. With missing fields, aggregateDomainStatus treats them as NOT_STARTED, so even a SUCCESS domain will display as not_started. Fix by including dkimStatus and spfDetails (and any other required fields) in the admin query selection, or by using domain.status directly when those fields aren’t present.

Copilot uses AI. Check for mistakes.
</div>
))
);
})
) : (
<p className="text-xs text-muted-foreground">No domains connected.</p>
)}
Expand Down
112 changes: 110 additions & 2 deletions apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ 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";
Expand Down Expand Up @@ -94,7 +95,11 @@ export default function DomainItemPage({

<div className="">
<DomainStatusBadge
status={domainQuery.data?.status || DomainStatus.NOT_STARTED}
status={
domainQuery.data?.aggregateStatus ??
domainQuery.data?.status ??
DomainStatus.NOT_STARTED
}
/>
</div>
</div>
Expand All @@ -103,7 +108,8 @@ export default function DomainItemPage({
<Button variant="outline" onClick={handleVerify}>
{domainQuery.data?.isVerifying
? "Verifying..."
: domainQuery.data?.status === DomainStatus.SUCCESS
: (domainQuery.data?.aggregateStatus ??
domainQuery.data?.status) === DomainStatus.SUCCESS
? "Verify again"
: "Verify domain"}
</Button>
Expand Down Expand Up @@ -175,12 +181,23 @@ export default function DomainItemPage({

const DomainSettings: React.FC<{ domain: DomainResponse }> = ({ domain }) => {
const updateDomain = api.domain.updateDomain.useMutation();
const setMailFromLabelMutation = api.domain.setMailFromLabel.useMutation();
const utils = api.useUtils();

const [clickTracking, setClickTracking] = React.useState(
domain.clickTracking,
);
const [openTracking, setOpenTracking] = React.useState(domain.openTracking);
const [mailFromDraft, setMailFromDraft] = React.useState(
domain.mailFromLabel ?? "",
);

const effectiveMailFromLabel =
domain.mailFromLabel?.trim() || domain.region;

React.useEffect(() => {
setMailFromDraft(domain.mailFromLabel ?? "");
}, [domain.mailFromLabel]);

function handleClickTrackingChange() {
setClickTracking(!clickTracking);
Expand Down Expand Up @@ -210,6 +227,97 @@ const DomainSettings: React.FC<{ domain: DomainResponse }> = ({ domain }) => {
return (
<div className="rounded-lg shadow p-4 border flex flex-col gap-6">
<p className="font-semibold text-xl">Settings</p>

<div className="flex flex-col gap-3 border-b border-border pb-6">
<div className="font-semibold">MAIL FROM label</div>
<p className="text-muted-foreground text-sm">
The MX and SPF rows in the DNS table use this hostname label. By
default it matches your SES region (
<span className="font-mono text-xs">{domain.region}</span>). You can
set a custom label (for example{" "}
<span className="font-mono text-xs">bounce</span>) — a single DNS
label, letters, digits, and hyphens only. Saving updates Amazon SES;
then update DNS and run Verify.
</p>
<p className="text-muted-foreground text-sm">
Tip: add the new MX and SPF records at your DNS provider{" "}
<strong>before</strong> you save a new label here. That way SES can
verify as soon as you save, and you avoid a temporary “not verified”
state.
</p>
<p className="text-sm">
<span className="text-muted-foreground">Effective label:</span>{" "}
<span className="font-mono text-xs">{effectiveMailFromLabel}</span>
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end sm:gap-3">
<div className="flex flex-col gap-1 flex-1 min-w-[200px]">
<span className="text-xs text-muted-foreground">
Custom label (optional)
</span>
<Input
placeholder={domain.region}
value={mailFromDraft}
onChange={(e) => setMailFromDraft(e.target.value)}
disabled={setMailFromLabelMutation.isPending}
autoComplete="off"
/>
</div>
<Button
type="button"
variant="secondary"
disabled={setMailFromLabelMutation.isPending}
onClick={() => {
const trimmed = mailFromDraft.trim();
setMailFromLabelMutation.mutate(
{
id: domain.id,
mailFromLabel:
trimmed === "" ? null : trimmed.toLowerCase(),
},
{
onSuccess: () => {
utils.domain.invalidate();
toast.success(
trimmed === ""
? "MAIL FROM reset to region default"
: "MAIL FROM label updated — update DNS and verify",
);
},
onError: (err) => {
toast.error(err.message);
},
},
);
}}
>
Save
</Button>
{domain.mailFromLabel ? (
<Button
type="button"
variant="outline"
disabled={setMailFromLabelMutation.isPending}
onClick={() => {
setMailFromLabelMutation.mutate(
{ id: domain.id, mailFromLabel: null },
{
onSuccess: () => {
utils.domain.invalidate();
toast.success("MAIL FROM reset to region default");
},
onError: (err) => {
toast.error(err.message);
},
},
);
}}
>
Reset to default
</Button>
) : null}
</div>
</div>

<div className="flex flex-col gap-1">
<div className="font-semibold">Click tracking</div>
<p className=" text-muted-foreground text-sm">
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/app/(dashboard)/domains/domain-list.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { Domain } from "@prisma/client";
import type { DomainWithDnsRecords } from "~/types/domain";
import { formatDistanceToNow } from "date-fns";
import Link from "next/link";
import { Switch } from "@usesend/ui/src/switch";
Expand Down Expand Up @@ -35,7 +35,7 @@ export default function DomainsList() {
);
}

const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
const DomainItem: React.FC<{ domain: DomainWithDnsRecords }> = ({ domain }) => {
const updateDomain = api.domain.updateDomain.useMutation();
const utils = api.useUtils();

Expand Down Expand Up @@ -71,7 +71,9 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
return (
<div key={domain.id}>
<div className=" pr-8 border rounded-lg flex items-stretch shadow">
<StatusIndicator status={domain.status} />
<StatusIndicator
status={domain.aggregateStatus ?? domain.status}
/>
<div className="flex justify-between w-full pl-8 py-4">
<div className="flex flex-col gap-4 w-1/5">
<Link
Expand All @@ -80,7 +82,9 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
>
{domain.name}
</Link>
<DomainStatusBadge status={domain.status} />
<DomainStatusBadge
status={domain.aggregateStatus ?? domain.status}
/>
</div>

<div className="flex flex-col gap-4">
Expand Down
48 changes: 48 additions & 0 deletions apps/web/src/lib/domain-aggregate-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { DomainStatus } from "@prisma/client";

/**
* Severity order: worst first. Used to combine identity, DKIM, and MAIL FROM (SPF) checks.
*/
const STATUS_WORST_FIRST: DomainStatus[] = [
DomainStatus.FAILED,
DomainStatus.TEMPORARY_FAILURE,
DomainStatus.PENDING,
DomainStatus.NOT_STARTED,
DomainStatus.SUCCESS,
];

function parseLooseStatus(value?: string | null): DomainStatus {
if (!value) {
return DomainStatus.NOT_STARTED;
}
const normalized = value.toUpperCase();
if ((Object.values(DomainStatus) as string[]).includes(normalized)) {
return normalized as DomainStatus;
}
return DomainStatus.NOT_STARTED;
}

/**
* Single status for UX: all of SES identity verification, DKIM, and MAIL FROM (SPF) must be SUCCESS
* for the aggregate to be SUCCESS.
*/
export function aggregateDomainStatus(domain: {
status: DomainStatus;
dkimStatus?: string | null;
spfDetails?: string | null;
}): DomainStatus {
const parts: DomainStatus[] = [
domain.status,
parseLooseStatus(domain.dkimStatus),
parseLooseStatus(domain.spfDetails),
];

let minIdx = STATUS_WORST_FIRST.length - 1;
for (const p of parts) {
const idx = STATUS_WORST_FIRST.indexOf(p);
if (idx !== -1 && idx < minIdx) {
minIdx = idx;
}
}
return STATUS_WORST_FIRST[minIdx]!;
}
35 changes: 35 additions & 0 deletions apps/web/src/lib/domain-aggregate-status.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { DomainStatus } from "@prisma/client";
import { aggregateDomainStatus } from "~/lib/domain-aggregate-status";

describe("aggregateDomainStatus", () => {
it("returns SUCCESS only when identity, DKIM, and SPF are all SUCCESS", () => {
expect(
aggregateDomainStatus({
status: DomainStatus.SUCCESS,
dkimStatus: DomainStatus.SUCCESS,
spfDetails: DomainStatus.SUCCESS,
}),
).toBe(DomainStatus.SUCCESS);
});

it("returns the worst status across the three checks", () => {
expect(
aggregateDomainStatus({
status: DomainStatus.SUCCESS,
dkimStatus: DomainStatus.SUCCESS,
spfDetails: DomainStatus.PENDING,
}),
).toBe(DomainStatus.PENDING);
});

it("treats FAILED as worse than PENDING", () => {
expect(
aggregateDomainStatus({
status: DomainStatus.SUCCESS,
dkimStatus: DomainStatus.FAILED,
spfDetails: DomainStatus.PENDING,
}),
).toBe(DomainStatus.FAILED);
});
});
19 changes: 16 additions & 3 deletions apps/web/src/lib/zod/domain-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ export const DomainDnsRecordSchema = z.object({
description: "DNS record type",
example: "TXT",
}),
name: z
.string()
.openapi({ description: "DNS record name", example: "mail" }),
name: z.string().openapi({
description:
"DNS record name (hostname label). For custom MAIL FROM MX and SPF TXT records, this is the first label of the MAIL FROM host: the domain `mailFromLabel` if set, otherwise the SES `region` value.",
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

The OpenAPI description for DomainDnsRecordSchema.name says this is a single “hostname label”, but the code can return values containing dots/underscores (e.g. ${selector}._domainkey${subdomainSuffix} and ${label}${subdomainSuffix}). Update the description (and ideally the example) to reflect that this is the record name relative to the domain/zone and may include multiple labels.

Suggested change
"DNS record name (hostname label). For custom MAIL FROM MX and SPF TXT records, this is the first label of the MAIL FROM host: the domain `mailFromLabel` if set, otherwise the SES `region` value.",
"DNS record name relative to the domain/zone. This may be a single label or multiple labels (for example, `selector1._domainkey` or a MAIL FROM subdomain such as `bounce`). For custom MAIL FROM MX and SPF TXT records, this is the MAIL FROM host name relative to the domain: `mailFromLabel` if set, otherwise the SES `region` value.",
example: "selector1._domainkey",

Copilot uses AI. Check for mistakes.
}),
Comment on lines +11 to +14
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 | 🟡 Minor

Clarify MAIL FROM DNS record names for subdomains.

The schema says this is “the first label,” but buildDnsRecords returns the full relative record name; for sub.example.com, a custom label becomes bounce.sub, not just bounce.

📝 Proposed wording update
   name: z.string().openapi({
     description:
-      "DNS record name (hostname label). For custom MAIL FROM MX and SPF TXT records, this is the first label of the MAIL FROM host: the domain `mailFromLabel` if set, otherwise the SES `region` value.",
+      "DNS record name relative to the domain's DNS zone. For MAIL FROM MX and SPF TXT records, this starts with `mailFromLabel` when set, otherwise the SES `region`, and may include the domain subdomain prefix.",
   }),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/lib/zod/domain-schema.ts` around lines 11 - 14, The openapi
description for the zod schema field name (z.string().openapi(...) in
domain-schema.ts) misleadingly states "the first label" but buildDnsRecords
actually returns the full relative record name including subdomain (e.g., for
sub.example.com a custom label becomes "bounce.sub" not just "bounce"); update
the description text in the name field to clarify it is the full relative DNS
record name (first label plus any subdomain segment) and include a brief example
such as "for sub.example.com and custom label 'bounce' the record name is
'bounce.sub'".

value: z
.string()
.openapi({
Expand Down Expand Up @@ -39,6 +40,18 @@ export const DomainSchema = z.object({
teamId: z.number().openapi({ description: "The ID of the team", example: 1 }),
status: DomainStatusSchema,
region: z.string().default("us-east-1"),
aggregateStatus: DomainStatusSchema.openapi({
description:
"Combined verification: SES identity, DKIM, and MAIL FROM (SPF) must all succeed for SUCCESS.",
}),
mailFromLabel: z
.string()
.optional()
.nullish()
.openapi({
description:
"Optional MAIL FROM subdomain label (e.g. bounce). Null means use `region` as the label.",
}),
clickTracking: z.boolean().default(false),
openTracking: z.boolean().default(false),
publicKey: z.string(),
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/server/api/routers/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getDomain,
getDomains,
updateDomain,
setMailFromLabel,
} 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 +64,17 @@ export const domainRouter = createTRPCRouter({
});
}),

setMailFromLabel: domainProcedure
.input(
z.object({
id: z.number(),
mailFromLabel: z.string().max(63).nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
return setMailFromLabel(input.id, ctx.team.id, input.mailFromLabel);
}),

deleteDomain: domainProcedure.mutation(async ({ input }) => {
await deleteDomain(input.id);
return { success: true };
Expand Down
Loading
Loading