Skip to content

Commit 249f9bc

Browse files
committed
feat: MAIL FROM label override and aggregate domain verification
Adds optional mailFromLabel on Domain, SES MAIL FROM sync, aggregate verification status (identity + DKIM + SPF), domain UI and API updates, and webhook payload field mailFromLabel. Also fixes contactBookId type in webhook-service unit test (string) so apps/web tsc passes. Made-with: Cursor
1 parent 5b9788e commit 249f9bc

16 files changed

Lines changed: 422 additions & 33 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Domain" ADD COLUMN "mailFromLabel" TEXT;

apps/web/prisma/schema.prisma

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ model Domain {
194194
dmarcAdded Boolean @default(false)
195195
errorMessage String?
196196
subdomain String?
197+
/// 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).
198+
mailFromLabel String?
197199
sesTenantId String?
198200
isVerifying Boolean @default(false)
199201
createdAt DateTime @default(now())

apps/web/src/app/(dashboard)/admin/teams/page.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
SelectValue,
2727
} from "@usesend/ui/src/select";
2828
import { formatDistanceToNow } from "date-fns";
29+
import { aggregateDomainStatus } from "~/lib/domain-aggregate-status";
2930

3031
import { api } from "~/trpc/react";
3132
import type { AppRouter } from "~/server/api/root";
@@ -223,19 +224,22 @@ export default function AdminTeamsPage() {
223224
<h3 className="text-sm font-medium text-muted-foreground">Domains</h3>
224225
<div className="space-y-2 rounded-lg border bg-muted/20 p-3">
225226
{team.domains.length ? (
226-
team.domains.map((domain) => (
227+
team.domains.map((domain) => {
228+
const agg = aggregateDomainStatus(domain);
229+
return (
227230
<div
228231
key={domain.id}
229232
className="flex items-center justify-between rounded-md bg-background px-3 py-2 text-sm"
230233
>
231234
<span>{domain.name}</span>
232-
<Badge variant={domain.status === "SUCCESS" ? "outline" : "secondary"}>
233-
{domain.status === "SUCCESS"
235+
<Badge variant={agg === "SUCCESS" ? "outline" : "secondary"}>
236+
{agg === "SUCCESS"
234237
? "Verified"
235-
: domain.status.toLowerCase()}
238+
: agg.toLowerCase()}
236239
</Badge>
237240
</div>
238-
))
241+
);
242+
})
239243
) : (
240244
<p className="text-xs text-muted-foreground">No domains connected.</p>
241245
)}

apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { Switch } from "@usesend/ui/src/switch";
2525
import DeleteDomain from "./delete-domain";
2626
import SendTestMail from "./send-test-mail";
2727
import { Button } from "@usesend/ui/src/button";
28+
import { Input } from "@usesend/ui/src/input";
2829
import Link from "next/link";
2930
import { toast } from "@usesend/ui/src/toaster";
3031
import type { inferRouterOutputs } from "@trpc/server";
@@ -94,7 +95,11 @@ export default function DomainItemPage({
9495

9596
<div className="">
9697
<DomainStatusBadge
97-
status={domainQuery.data?.status || DomainStatus.NOT_STARTED}
98+
status={
99+
domainQuery.data?.aggregateStatus ??
100+
domainQuery.data?.status ??
101+
DomainStatus.NOT_STARTED
102+
}
98103
/>
99104
</div>
100105
</div>
@@ -103,7 +108,8 @@ export default function DomainItemPage({
103108
<Button variant="outline" onClick={handleVerify}>
104109
{domainQuery.data?.isVerifying
105110
? "Verifying..."
106-
: domainQuery.data?.status === DomainStatus.SUCCESS
111+
: (domainQuery.data?.aggregateStatus ??
112+
domainQuery.data?.status) === DomainStatus.SUCCESS
107113
? "Verify again"
108114
: "Verify domain"}
109115
</Button>
@@ -175,12 +181,23 @@ export default function DomainItemPage({
175181

176182
const DomainSettings: React.FC<{ domain: DomainResponse }> = ({ domain }) => {
177183
const updateDomain = api.domain.updateDomain.useMutation();
184+
const setMailFromLabelMutation = api.domain.setMailFromLabel.useMutation();
178185
const utils = api.useUtils();
179186

180187
const [clickTracking, setClickTracking] = React.useState(
181188
domain.clickTracking,
182189
);
183190
const [openTracking, setOpenTracking] = React.useState(domain.openTracking);
191+
const [mailFromDraft, setMailFromDraft] = React.useState(
192+
domain.mailFromLabel ?? "",
193+
);
194+
195+
const effectiveMailFromLabel =
196+
domain.mailFromLabel?.trim() || domain.region;
197+
198+
React.useEffect(() => {
199+
setMailFromDraft(domain.mailFromLabel ?? "");
200+
}, [domain.mailFromLabel]);
184201

185202
function handleClickTrackingChange() {
186203
setClickTracking(!clickTracking);
@@ -210,6 +227,97 @@ const DomainSettings: React.FC<{ domain: DomainResponse }> = ({ domain }) => {
210227
return (
211228
<div className="rounded-lg shadow p-4 border flex flex-col gap-6">
212229
<p className="font-semibold text-xl">Settings</p>
230+
231+
<div className="flex flex-col gap-3 border-b border-border pb-6">
232+
<div className="font-semibold">MAIL FROM label</div>
233+
<p className="text-muted-foreground text-sm">
234+
The MX and SPF rows in the DNS table use this hostname label. By
235+
default it matches your SES region (
236+
<span className="font-mono text-xs">{domain.region}</span>). You can
237+
set a custom label (for example{" "}
238+
<span className="font-mono text-xs">bounce</span>) — a single DNS
239+
label, letters, digits, and hyphens only. Saving updates Amazon SES;
240+
then update DNS and run Verify.
241+
</p>
242+
<p className="text-muted-foreground text-sm">
243+
Tip: add the new MX and SPF records at your DNS provider{" "}
244+
<strong>before</strong> you save a new label here. That way SES can
245+
verify as soon as you save, and you avoid a temporary “not verified”
246+
state.
247+
</p>
248+
<p className="text-sm">
249+
<span className="text-muted-foreground">Effective label:</span>{" "}
250+
<span className="font-mono text-xs">{effectiveMailFromLabel}</span>
251+
</p>
252+
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end sm:gap-3">
253+
<div className="flex flex-col gap-1 flex-1 min-w-[200px]">
254+
<span className="text-xs text-muted-foreground">
255+
Custom label (optional)
256+
</span>
257+
<Input
258+
placeholder={domain.region}
259+
value={mailFromDraft}
260+
onChange={(e) => setMailFromDraft(e.target.value)}
261+
disabled={setMailFromLabelMutation.isPending}
262+
autoComplete="off"
263+
/>
264+
</div>
265+
<Button
266+
type="button"
267+
variant="secondary"
268+
disabled={setMailFromLabelMutation.isPending}
269+
onClick={() => {
270+
const trimmed = mailFromDraft.trim();
271+
setMailFromLabelMutation.mutate(
272+
{
273+
id: domain.id,
274+
mailFromLabel:
275+
trimmed === "" ? null : trimmed.toLowerCase(),
276+
},
277+
{
278+
onSuccess: () => {
279+
utils.domain.invalidate();
280+
toast.success(
281+
trimmed === ""
282+
? "MAIL FROM reset to region default"
283+
: "MAIL FROM label updated — update DNS and verify",
284+
);
285+
},
286+
onError: (err) => {
287+
toast.error(err.message);
288+
},
289+
},
290+
);
291+
}}
292+
>
293+
Save
294+
</Button>
295+
{domain.mailFromLabel ? (
296+
<Button
297+
type="button"
298+
variant="outline"
299+
disabled={setMailFromLabelMutation.isPending}
300+
onClick={() => {
301+
setMailFromLabelMutation.mutate(
302+
{ id: domain.id, mailFromLabel: null },
303+
{
304+
onSuccess: () => {
305+
utils.domain.invalidate();
306+
toast.success("MAIL FROM reset to region default");
307+
},
308+
onError: (err) => {
309+
toast.error(err.message);
310+
},
311+
},
312+
);
313+
}}
314+
>
315+
Reset to default
316+
</Button>
317+
) : null}
318+
</div>
319+
</div>
320+
213321
<div className="flex flex-col gap-1">
214322
<div className="font-semibold">Click tracking</div>
215323
<p className=" text-muted-foreground text-sm">

apps/web/src/app/(dashboard)/domains/domain-list.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { Domain } from "@prisma/client";
3+
import type { DomainWithDnsRecords } from "~/types/domain";
44
import { formatDistanceToNow } from "date-fns";
55
import Link from "next/link";
66
import { Switch } from "@usesend/ui/src/switch";
@@ -35,7 +35,7 @@ export default function DomainsList() {
3535
);
3636
}
3737

38-
const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
38+
const DomainItem: React.FC<{ domain: DomainWithDnsRecords }> = ({ domain }) => {
3939
const updateDomain = api.domain.updateDomain.useMutation();
4040
const utils = api.useUtils();
4141

@@ -71,7 +71,9 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
7171
return (
7272
<div key={domain.id}>
7373
<div className=" pr-8 border rounded-lg flex items-stretch shadow">
74-
<StatusIndicator status={domain.status} />
74+
<StatusIndicator
75+
status={domain.aggregateStatus ?? domain.status}
76+
/>
7577
<div className="flex justify-between w-full pl-8 py-4">
7678
<div className="flex flex-col gap-4 w-1/5">
7779
<Link
@@ -80,7 +82,9 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
8082
>
8183
{domain.name}
8284
</Link>
83-
<DomainStatusBadge status={domain.status} />
85+
<DomainStatusBadge
86+
status={domain.aggregateStatus ?? domain.status}
87+
/>
8488
</div>
8589

8690
<div className="flex flex-col gap-4">
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { DomainStatus } from "@prisma/client";
2+
3+
/**
4+
* Severity order: worst first. Used to combine identity, DKIM, and MAIL FROM (SPF) checks.
5+
*/
6+
const STATUS_WORST_FIRST: DomainStatus[] = [
7+
DomainStatus.FAILED,
8+
DomainStatus.TEMPORARY_FAILURE,
9+
DomainStatus.PENDING,
10+
DomainStatus.NOT_STARTED,
11+
DomainStatus.SUCCESS,
12+
];
13+
14+
function parseLooseStatus(value?: string | null): DomainStatus {
15+
if (!value) {
16+
return DomainStatus.NOT_STARTED;
17+
}
18+
const normalized = value.toUpperCase();
19+
if ((Object.values(DomainStatus) as string[]).includes(normalized)) {
20+
return normalized as DomainStatus;
21+
}
22+
return DomainStatus.NOT_STARTED;
23+
}
24+
25+
/**
26+
* Single status for UX: all of SES identity verification, DKIM, and MAIL FROM (SPF) must be SUCCESS
27+
* for the aggregate to be SUCCESS.
28+
*/
29+
export function aggregateDomainStatus(domain: {
30+
status: DomainStatus;
31+
dkimStatus?: string | null;
32+
spfDetails?: string | null;
33+
}): DomainStatus {
34+
const parts: DomainStatus[] = [
35+
domain.status,
36+
parseLooseStatus(domain.dkimStatus),
37+
parseLooseStatus(domain.spfDetails),
38+
];
39+
40+
let minIdx = STATUS_WORST_FIRST.length - 1;
41+
for (const p of parts) {
42+
const idx = STATUS_WORST_FIRST.indexOf(p);
43+
if (idx !== -1 && idx < minIdx) {
44+
minIdx = idx;
45+
}
46+
}
47+
return STATUS_WORST_FIRST[minIdx]!;
48+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it } from "vitest";
2+
import { DomainStatus } from "@prisma/client";
3+
import { aggregateDomainStatus } from "~/lib/domain-aggregate-status";
4+
5+
describe("aggregateDomainStatus", () => {
6+
it("returns SUCCESS only when identity, DKIM, and SPF are all SUCCESS", () => {
7+
expect(
8+
aggregateDomainStatus({
9+
status: DomainStatus.SUCCESS,
10+
dkimStatus: DomainStatus.SUCCESS,
11+
spfDetails: DomainStatus.SUCCESS,
12+
}),
13+
).toBe(DomainStatus.SUCCESS);
14+
});
15+
16+
it("returns the worst status across the three checks", () => {
17+
expect(
18+
aggregateDomainStatus({
19+
status: DomainStatus.SUCCESS,
20+
dkimStatus: DomainStatus.SUCCESS,
21+
spfDetails: DomainStatus.PENDING,
22+
}),
23+
).toBe(DomainStatus.PENDING);
24+
});
25+
26+
it("treats FAILED as worse than PENDING", () => {
27+
expect(
28+
aggregateDomainStatus({
29+
status: DomainStatus.SUCCESS,
30+
dkimStatus: DomainStatus.FAILED,
31+
spfDetails: DomainStatus.PENDING,
32+
}),
33+
).toBe(DomainStatus.FAILED);
34+
});
35+
});

apps/web/src/lib/zod/domain-schema.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ export const DomainDnsRecordSchema = z.object({
88
description: "DNS record type",
99
example: "TXT",
1010
}),
11-
name: z
12-
.string()
13-
.openapi({ description: "DNS record name", example: "mail" }),
11+
name: z.string().openapi({
12+
description:
13+
"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.",
14+
}),
1415
value: z
1516
.string()
1617
.openapi({
@@ -39,6 +40,18 @@ export const DomainSchema = z.object({
3940
teamId: z.number().openapi({ description: "The ID of the team", example: 1 }),
4041
status: DomainStatusSchema,
4142
region: z.string().default("us-east-1"),
43+
aggregateStatus: DomainStatusSchema.openapi({
44+
description:
45+
"Combined verification: SES identity, DKIM, and MAIL FROM (SPF) must all succeed for SUCCESS.",
46+
}),
47+
mailFromLabel: z
48+
.string()
49+
.optional()
50+
.nullish()
51+
.openapi({
52+
description:
53+
"Optional MAIL FROM subdomain label (e.g. bounce). Null means use `region` as the label.",
54+
}),
4255
clickTracking: z.boolean().default(false),
4356
openTracking: z.boolean().default(false),
4457
publicKey: z.string(),

apps/web/src/server/api/routers/domain.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
getDomain,
1414
getDomains,
1515
updateDomain,
16+
setMailFromLabel,
1617
} from "~/server/service/domain-service";
1718
import { sendEmail } from "~/server/service/email-service";
1819
import { SesSettingsService } from "~/server/service/ses-settings-service";
@@ -63,6 +64,17 @@ export const domainRouter = createTRPCRouter({
6364
});
6465
}),
6566

67+
setMailFromLabel: domainProcedure
68+
.input(
69+
z.object({
70+
id: z.number(),
71+
mailFromLabel: z.string().max(63).nullable(),
72+
}),
73+
)
74+
.mutation(async ({ ctx, input }) => {
75+
return setMailFromLabel(input.id, ctx.team.id, input.mailFromLabel);
76+
}),
77+
6678
deleteDomain: domainProcedure.mutation(async ({ input }) => {
6779
await deleteDomain(input.id);
6880
return { success: true };

0 commit comments

Comments
 (0)