From 249f9bc06ae24284ddecc85ce9613a61868e62f6 Mon Sep 17 00:00:00 2001 From: amanjuman <19264857+amanjuman@users.noreply.github.com> Date: Sat, 18 Apr 2026 11:35:17 +0200 Subject: [PATCH] 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 --- .../migration.sql | 2 + apps/web/prisma/schema.prisma | 2 + .../src/app/(dashboard)/admin/teams/page.tsx | 14 +- .../(dashboard)/domains/[domainId]/page.tsx | 112 ++++++++++++++- .../app/(dashboard)/domains/domain-list.tsx | 12 +- apps/web/src/lib/domain-aggregate-status.ts | 48 +++++++ .../lib/domain-aggregate-status.unit.test.ts | 35 +++++ apps/web/src/lib/zod/domain-schema.ts | 19 ++- apps/web/src/server/api/routers/domain.ts | 12 ++ apps/web/src/server/aws/ses.ts | 19 ++- .../jobs/domain-verification-job.unit.test.ts | 1 + apps/web/src/server/service/domain-service.ts | 129 +++++++++++++++--- .../service/domain-service.unit.test.ts | 44 ++++++ .../service/webhook-service.unit.test.ts | 2 +- apps/web/src/types/domain.ts | 2 + packages/lib/src/webhook/webhook-events.ts | 2 + 16 files changed, 422 insertions(+), 33 deletions(-) create mode 100644 apps/web/prisma/migrations/20260419140000_domain_mail_from_label/migration.sql create mode 100644 apps/web/src/lib/domain-aggregate-status.ts create mode 100644 apps/web/src/lib/domain-aggregate-status.unit.test.ts diff --git a/apps/web/prisma/migrations/20260419140000_domain_mail_from_label/migration.sql b/apps/web/prisma/migrations/20260419140000_domain_mail_from_label/migration.sql new file mode 100644 index 00000000..18c0ed4e --- /dev/null +++ b/apps/web/prisma/migrations/20260419140000_domain_mail_from_label/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Domain" ADD COLUMN "mailFromLabel" TEXT; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 1492adf8..80743a83 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -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()) diff --git a/apps/web/src/app/(dashboard)/admin/teams/page.tsx b/apps/web/src/app/(dashboard)/admin/teams/page.tsx index b884f95a..755ea442 100644 --- a/apps/web/src/app/(dashboard)/admin/teams/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/teams/page.tsx @@ -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"; @@ -223,19 +224,22 @@ export default function AdminTeamsPage() {

Domains

{team.domains.length ? ( - team.domains.map((domain) => ( + team.domains.map((domain) => { + const agg = aggregateDomainStatus(domain); + return (
{domain.name} - - {domain.status === "SUCCESS" + + {agg === "SUCCESS" ? "Verified" - : domain.status.toLowerCase()} + : agg.toLowerCase()}
- )) + ); + }) ) : (

No domains connected.

)} diff --git a/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx b/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx index be4691b9..424f89ce 100644 --- a/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx +++ b/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx @@ -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"; @@ -94,7 +95,11 @@ export default function DomainItemPage({
@@ -103,7 +108,8 @@ export default function DomainItemPage({ @@ -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); @@ -210,6 +227,97 @@ const DomainSettings: React.FC<{ domain: DomainResponse }> = ({ domain }) => { return (

Settings

+ +
+
MAIL FROM label
+

+ The MX and SPF rows in the DNS table use this hostname label. By + default it matches your SES region ( + {domain.region}). You can + set a custom label (for example{" "} + bounce) — a single DNS + label, letters, digits, and hyphens only. Saving updates Amazon SES; + then update DNS and run Verify. +

+

+ Tip: add the new MX and SPF records at your DNS provider{" "} + before you save a new label here. That way SES can + verify as soon as you save, and you avoid a temporary “not verified” + state. +

+

+ Effective label:{" "} + {effectiveMailFromLabel} +

+
+
+ + Custom label (optional) + + setMailFromDraft(e.target.value)} + disabled={setMailFromLabelMutation.isPending} + autoComplete="off" + /> +
+ + {domain.mailFromLabel ? ( + + ) : null} +
+
+
Click tracking

diff --git a/apps/web/src/app/(dashboard)/domains/domain-list.tsx b/apps/web/src/app/(dashboard)/domains/domain-list.tsx index a2ef5f4e..3d96805a 100644 --- a/apps/web/src/app/(dashboard)/domains/domain-list.tsx +++ b/apps/web/src/app/(dashboard)/domains/domain-list.tsx @@ -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"; @@ -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(); @@ -71,7 +71,9 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => { return (

- +
= ({ domain }) => { > {domain.name} - +
diff --git a/apps/web/src/lib/domain-aggregate-status.ts b/apps/web/src/lib/domain-aggregate-status.ts new file mode 100644 index 00000000..6f2b8e0c --- /dev/null +++ b/apps/web/src/lib/domain-aggregate-status.ts @@ -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]!; +} diff --git a/apps/web/src/lib/domain-aggregate-status.unit.test.ts b/apps/web/src/lib/domain-aggregate-status.unit.test.ts new file mode 100644 index 00000000..43d0e100 --- /dev/null +++ b/apps/web/src/lib/domain-aggregate-status.unit.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/lib/zod/domain-schema.ts b/apps/web/src/lib/zod/domain-schema.ts index 4d81ac18..ff97f95d 100644 --- a/apps/web/src/lib/zod/domain-schema.ts +++ b/apps/web/src/lib/zod/domain-schema.ts @@ -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.", + }), value: z .string() .openapi({ @@ -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(), diff --git a/apps/web/src/server/api/routers/domain.ts b/apps/web/src/server/api/routers/domain.ts index 848d24aa..0370028a 100644 --- a/apps/web/src/server/api/routers/domain.ts +++ b/apps/web/src/server/api/routers/domain.ts @@ -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"; @@ -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 }; diff --git a/apps/web/src/server/aws/ses.ts b/apps/web/src/server/aws/ses.ts index 0fda94c8..957e4070 100644 --- a/apps/web/src/server/aws/ses.ts +++ b/apps/web/src/server/aws/ses.ts @@ -103,7 +103,7 @@ export async function addDomain( const emailIdentityCommand = new PutEmailIdentityMailFromAttributesCommand({ EmailIdentity: domain, - MailFromDomain: `mail.${domain}`, + MailFromDomain: `${region}.${domain}`, }); const emailIdentityResponse = await sesClient.send(emailIdentityCommand); @@ -142,6 +142,23 @@ export async function addDomain( return publicKey; } +export async function putEmailIdentityMailFromDomain( + emailIdentity: string, + region: string, + mailFromDomain: string, +) { + const sesClient = getSesClient(region); + const command = new PutEmailIdentityMailFromAttributesCommand({ + EmailIdentity: emailIdentity, + MailFromDomain: mailFromDomain, + }); + const response = await sesClient.send(command); + if (response.$metadata.httpStatusCode !== 200) { + logger.error({ response, emailIdentity, mailFromDomain }, "Failed to set MAIL FROM domain"); + throw new Error("Failed to set MAIL FROM domain"); + } +} + export async function deleteDomain( domain: string, region: string, 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..8b40bc9a 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 @@ -74,6 +74,7 @@ function createDomain(id: number, status: DomainStatus): Domain { dmarcAdded: false, errorMessage: null, subdomain: null, + mailFromLabel: null, sesTenantId: null, isVerifying: status !== DomainStatus.SUCCESS, createdAt: 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..58e55003 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -16,7 +16,11 @@ import { type DomainWebhookEventType, } from "@usesend/lib/src/webhook/webhook-events"; import { LimitService } from "./limit-service"; -import type { DomainDnsRecord } from "~/types/domain"; +import type { + DomainDnsRecord, + DomainWithDnsRecords as DomainWithDnsRecordsEnriched, +} from "~/types/domain"; +import { aggregateDomainStatus } from "~/lib/domain-aggregate-status"; import { WebhookService } from "./webhook-service"; const DOMAIN_STATUS_VALUES = new Set(Object.values(DomainStatus)); @@ -30,9 +34,7 @@ type DomainVerificationState = { lastNotifiedStatus: DomainStatus | null; }; -type DomainWithDnsRecords = Domain & { dnsRecords: DomainDnsRecord[] }; - -type DomainVerificationRefreshResult = DomainWithDnsRecords & { +type DomainVerificationRefreshResult = DomainWithDnsRecordsEnriched & { verificationError: string | null; lastCheckedTime: string | null; previousStatus: DomainStatus; @@ -54,9 +56,29 @@ function parseDomainStatus(status?: string | null): DomainStatus { return DomainStatus.NOT_STARTED; } +const MAIL_FROM_LABEL_MAX_LEN = 63; + +function isValidMailFromLabel(label: string): boolean { + if (label.length < 1 || label.length > MAIL_FROM_LABEL_MAX_LEN) { + return false; + } + if (label.startsWith("-") || label.endsWith("-")) { + return false; + } + return /^[a-zA-Z0-9-]+$/.test(label); +} + +function effectiveMailFromFirstLabel(domain: Domain): string { + const custom = domain.mailFromLabel?.trim().toLowerCase(); + if (custom) { + return custom; + } + return domain.region; +} + function buildDnsRecords(domain: Domain): DomainDnsRecord[] { const subdomainSuffix = domain.subdomain ? `.${domain.subdomain}` : ""; - const mailDomain = `mail${subdomainSuffix}`; + const mailFromSubdomain = `${effectiveMailFromFirstLabel(domain)}${subdomainSuffix}`; const dkimSelector = domain.dkimSelector ?? "usesend"; const spfStatus = parseDomainStatus(domain.spfDetails); @@ -68,7 +90,7 @@ function buildDnsRecords(domain: Domain): DomainDnsRecord[] { return [ { type: "MX", - name: mailDomain, + name: mailFromSubdomain, value: `feedback-smtp.${domain.region}.amazonses.com`, ttl: "Auto", priority: "10", @@ -83,7 +105,7 @@ function buildDnsRecords(domain: Domain): DomainDnsRecord[] { }, { type: "TXT", - name: mailDomain, + name: mailFromSubdomain, value: "v=spf1 include:amazonses.com ~all", ttl: "Auto", status: spfStatus, @@ -101,9 +123,10 @@ function buildDnsRecords(domain: Domain): DomainDnsRecord[] { function withDnsRecords( domain: T, -): T & { dnsRecords: DomainDnsRecord[] } { +): T & { dnsRecords: DomainDnsRecord[]; aggregateStatus: DomainStatus } { return { ...domain, + aggregateStatus: aggregateDomainStatus(domain), dnsRecords: buildDnsRecords(domain), }; } @@ -268,8 +291,9 @@ async function sendDomainStatusNotification({ return; } + const aggregate = aggregateDomainStatus(domain); const subject = - domain.status === DomainStatus.SUCCESS + aggregate === DomainStatus.SUCCESS ? `useSend: ${domain.name} is verified` : previousStatus === DomainStatus.SUCCESS ? `useSend: ${domain.name} verification status changed` @@ -278,12 +302,12 @@ async function sendDomainStatusNotification({ const domainUrl = `${env.NEXTAUTH_URL}/domains/${domain.id}`; const html = await renderDomainVerificationStatusEmail({ domainName: domain.name, - currentStatus: domain.status, + currentStatus: aggregate, previousStatus, domainUrl, }); const statusMessage = - domain.status === DomainStatus.SUCCESS + aggregate === DomainStatus.SUCCESS ? `Your domain ${domain.name} is now verified, and you can start sending emails.` : `Your domain ${domain.name} could not be verified because the DNS records are not set up correctly yet. Please review your DNS settings and try again.`; const textLines = [ @@ -308,7 +332,7 @@ function buildDomainPayload(domain: Domain): DomainPayload { return { id: domain.id, name: domain.name, - status: domain.status, + status: aggregateDomainStatus(domain), region: domain.region, createdAt: domain.createdAt.toISOString(), updatedAt: domain.updatedAt.toISOString(), @@ -319,6 +343,7 @@ function buildDomainPayload(domain: Domain): DomainPayload { dkimStatus: domain.dkimStatus, spfDetails: domain.spfDetails, dmarcAdded: domain.dmarcAdded, + mailFromLabel: domain.mailFromLabel, }; } @@ -357,7 +382,7 @@ export async function validateDomainFromEmail(email: string, teamId: number) { }); } - if (domain.status !== "SUCCESS") { + if (aggregateDomainStatus(domain) !== DomainStatus.SUCCESS) { throw new UnsendApiError({ code: "BAD_REQUEST", message: `Domain: ${fromDomain} is not verified`, @@ -576,9 +601,16 @@ export async function refreshDomainVerification( ? String(lastCheckedTime) : null; - if (previousStatus !== domainWithDns.status) { + const previousAggregate = aggregateDomainStatus(domain); + const nextAggregate = aggregateDomainStatus(normalizedDomain); + + if ( + previousStatus !== domainWithDns.status || + previousAggregate !== nextAggregate + ) { const eventType: DomainWebhookEventType = - domainWithDns.status === DomainStatus.SUCCESS + nextAggregate === DomainStatus.SUCCESS && + previousAggregate !== DomainStatus.SUCCESS ? "domain.verified" : "domain.updated"; await emitDomainEvent(domainWithDns, eventType); @@ -592,13 +624,76 @@ export async function refreshDomainVerification( lastCheckedTime: normalizedLastCheckedTime, dmarcAdded: normalizedDomain.dmarcAdded, previousStatus, - statusChanged: previousStatus !== domainWithDns.status, + statusChanged: + previousStatus !== domainWithDns.status || + previousAggregate !== nextAggregate, hasEverVerified: verificationState.hasEverVerified || - domainWithDns.status === DomainStatus.SUCCESS, + nextAggregate === DomainStatus.SUCCESS, }; } +export async function setMailFromLabel( + domainId: number, + teamId: number, + label: string | null, +) { + const domain = await db.domain.findFirst({ + where: { id: domainId, teamId }, + }); + + if (!domain) { + throw new UnsendApiError({ + code: "NOT_FOUND", + message: "Domain not found", + }); + } + + const trimmed = + label === null || label === undefined ? "" : label.trim().toLowerCase(); + + let stored: string | null = trimmed === "" ? null : trimmed; + + if (stored && !isValidMailFromLabel(stored)) { + throw new UnsendApiError({ + code: "BAD_REQUEST", + message: + "Invalid MAIL FROM label. Use 1–63 letters, digits, or hyphens; do not start or end with a hyphen.", + }); + } + + if (stored === domain.region.toLowerCase()) { + stored = null; + } + + const previousStored = domain.mailFromLabel?.trim().toLowerCase() ?? null; + if (previousStored === stored) { + return withDnsRecords(domain); + } + + const firstLabel = stored ?? domain.region; + const mailFromFqdn = `${firstLabel}.${domain.name}`; + + await ses.putEmailIdentityMailFromDomain( + domain.name, + domain.region, + mailFromFqdn, + ); + + const updated = await db.domain.update({ + where: { id: domainId }, + data: { + mailFromLabel: stored, + spfDetails: DomainStatus.PENDING, + isVerifying: true, + errorMessage: null, + }, + }); + + await emitDomainEvent(updated, "domain.updated"); + return withDnsRecords(updated); +} + export async function updateDomain( id: number, data: { clickTracking?: boolean; openTracking?: boolean }, 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..8f8814cb 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,7 @@ import { DomainStatus, type Domain } from "@prisma/client"; const { mockDb, mockGetDomainIdentity, + mockPutEmailIdentityMailFromDomain, mockWebhookEmit, mockRedis, mockSendMail, @@ -14,12 +15,14 @@ const { domain: { update: vi.fn(), findUnique: vi.fn(), + findFirst: vi.fn(), }, teamUser: { findMany: vi.fn(), }, }, mockGetDomainIdentity: vi.fn(), + mockPutEmailIdentityMailFromDomain: vi.fn(), mockWebhookEmit: vi.fn(), mockRedis: { mget: vi.fn(), @@ -49,6 +52,7 @@ vi.mock("~/server/db", () => ({ vi.mock("~/server/aws/ses", () => ({ getDomainIdentity: mockGetDomainIdentity, + putEmailIdentityMailFromDomain: mockPutEmailIdentityMailFromDomain, })); vi.mock("~/server/service/webhook-service", () => ({ @@ -75,6 +79,7 @@ import { DOMAIN_VERIFIED_RECHECK_MS, isDomainVerificationDue, refreshDomainVerification, + setMailFromLabel, } from "~/server/service/domain-service"; function createDomain(overrides: Partial = {}): Domain { @@ -93,6 +98,7 @@ function createDomain(overrides: Partial = {}): Domain { dmarcAdded: false, errorMessage: null, subdomain: null, + mailFromLabel: null, sesTenantId: null, isVerifying: true, createdAt: new Date("2026-03-01T00:00:00.000Z"), @@ -108,6 +114,8 @@ describe("domain-service", () => { mockDb.domain.update.mockReset(); mockDb.domain.findUnique.mockReset(); + mockDb.domain.findFirst.mockReset(); + mockPutEmailIdentityMailFromDomain.mockReset(); mockDb.teamUser.findMany.mockReset(); mockGetDomainIdentity.mockReset(); mockWebhookEmit.mockReset(); @@ -411,4 +419,40 @@ describe("domain-service", () => { await expect(isDomainVerificationDue(domain)).resolves.toBe(false); }); + + it("marks MAIL FROM verification pending when the label changes", async () => { + const existing = createDomain({ + status: DomainStatus.SUCCESS, + spfDetails: DomainStatus.SUCCESS, + isVerifying: false, + mailFromLabel: null, + }); + mockDb.domain.findFirst.mockResolvedValue(existing); + mockPutEmailIdentityMailFromDomain.mockResolvedValue(undefined); + mockDb.domain.update.mockImplementation(async ({ data }) => + createDomain({ ...existing, ...data }), + ); + + const result = await setMailFromLabel(42, 7, "bounce"); + + expect(mockPutEmailIdentityMailFromDomain).toHaveBeenCalledWith( + "example.com", + "us-east-1", + "bounce.example.com", + ); + expect(mockDb.domain.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 42 }, + data: expect.objectContaining({ + mailFromLabel: "bounce", + spfDetails: DomainStatus.PENDING, + isVerifying: true, + errorMessage: null, + }), + }), + ); + expect(result.spfDetails).toBe(DomainStatus.PENDING); + expect(result.aggregateStatus).toBe(DomainStatus.PENDING); + expect(result.dnsRecords[0]?.status).toBe(DomainStatus.PENDING); + }); }); 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/types/domain.ts b/apps/web/src/types/domain.ts index 10c791da..d1edc95c 100644 --- a/apps/web/src/types/domain.ts +++ b/apps/web/src/types/domain.ts @@ -11,6 +11,8 @@ export type DomainDnsRecord = { }; export type DomainWithDnsRecords = Domain & { + /** Worst of identity verification, DKIM, and MAIL FROM (SPF); use for UI badges. */ + aggregateStatus: DomainStatus; dnsRecords: DomainDnsRecord[]; verificationError?: string | null; lastCheckedTime?: Date | string | null; diff --git a/packages/lib/src/webhook/webhook-events.ts b/packages/lib/src/webhook/webhook-events.ts index b201165c..834023ee 100644 --- a/packages/lib/src/webhook/webhook-events.ts +++ b/packages/lib/src/webhook/webhook-events.ts @@ -102,6 +102,8 @@ export type DomainPayload = { dkimStatus?: string | null; spfDetails?: string | null; dmarcAdded?: boolean | null; + /** Custom MAIL FROM label (first DNS label); null means the SES region is used. */ + mailFromLabel?: string | null; }; export type EmailBouncedPayload = EmailBasePayload & {