= ({ 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 & {