diff --git a/apps/web/src/instrumentation.ts b/apps/web/src/instrumentation.ts
index 8cade36f..e989ad12 100644
--- a/apps/web/src/instrumentation.ts
+++ b/apps/web/src/instrumentation.ts
@@ -1,5 +1,5 @@
-import { env } from "./env";
-import { isCloud , isEmailCleanupEnabled } from "./utils/common";
+import { initDomainVerificationJob } from "~/server/jobs/domain-verification-job";
+import { isCloud, isEmailCleanupEnabled } from "~/utils/common";
let initialized = false;
@@ -25,6 +25,10 @@ export async function register() {
await import("~/server/jobs/usage-job");
}
+ if (process.env.REDIS_URL) {
+ await initDomainVerificationJob();
+ }
+
if (isEmailCleanupEnabled()) {
await import("~/server/jobs/cleanup-email-bodies");
}
diff --git a/apps/web/src/server/email-templates/DomainVerificationStatusEmail.tsx b/apps/web/src/server/email-templates/DomainVerificationStatusEmail.tsx
new file mode 100644
index 00000000..356838bd
--- /dev/null
+++ b/apps/web/src/server/email-templates/DomainVerificationStatusEmail.tsx
@@ -0,0 +1,155 @@
+import React from "react";
+import { Container, Text } from "jsx-email";
+import { render } from "jsx-email";
+import { DomainStatus } from "@prisma/client";
+import { EmailButton } from "~/server/email-templates/components/EmailButton";
+import { EmailFooter } from "~/server/email-templates/components/EmailFooter";
+import { EmailHeader } from "~/server/email-templates/components/EmailHeader";
+import { EmailLayout } from "~/server/email-templates/components/EmailLayout";
+
+interface DomainVerificationStatusEmailProps {
+ domainName: string;
+ currentStatus: DomainStatus;
+ previousStatus: DomainStatus;
+ verificationError?: string | null;
+ domainUrl: string;
+}
+
+function formatDomainStatus(status: DomainStatus) {
+ return status.toLowerCase().replaceAll("_", " ");
+}
+
+function getTitle(currentStatus: DomainStatus, previousStatus: DomainStatus) {
+ if (currentStatus === DomainStatus.SUCCESS) {
+ return previousStatus === DomainStatus.SUCCESS
+ ? "Domain verification checked"
+ : "Your domain is verified";
+ }
+
+ if (previousStatus === DomainStatus.SUCCESS) {
+ return "Your domain status changed";
+ }
+
+ return "Your domain verification needs attention";
+}
+
+export function DomainVerificationStatusEmail({
+ domainName,
+ currentStatus,
+ previousStatus,
+ verificationError,
+ domainUrl,
+}: DomainVerificationStatusEmailProps) {
+ const isSuccess = currentStatus === DomainStatus.SUCCESS;
+ const preview = `${domainName} is now ${formatDomainStatus(currentStatus)}`;
+
+ return (
+
+
+
+
+
+ Hey,
+
+
+ {isSuccess ? (
+
+ Your domain {domainName} is now verified, and you
+ can start sending emails.
+
+ ) : (
+
+ Your domain {domainName} could not be verified
+ because the DNS records are not set up correctly yet. Please review
+ your DNS settings and try again.
+
+ )}
+
+ {verificationError ? (
+
+
+ Verification error: {verificationError}
+
+
+ ) : null}
+
+
+ Open your domain settings to review records and verification details.
+
+
+
+ Open domain settings
+
+
+
+ Thanks,
+
+ useSend Team
+
+
+
+
+
+ );
+}
+
+export async function renderDomainVerificationStatusEmail(
+ props: DomainVerificationStatusEmailProps,
+): Promise {
+ return render();
+}
diff --git a/apps/web/src/server/email-templates/index.ts b/apps/web/src/server/email-templates/index.ts
index 02e963a7..8d66bbaa 100644
--- a/apps/web/src/server/email-templates/index.ts
+++ b/apps/web/src/server/email-templates/index.ts
@@ -8,6 +8,10 @@ export {
UsageLimitReachedEmail,
renderUsageLimitReachedEmail,
} from "./UsageLimitReachedEmail";
+export {
+ DomainVerificationStatusEmail,
+ renderDomainVerificationStatusEmail,
+} from "./DomainVerificationStatusEmail";
export * from "./components/EmailLayout";
export * from "./components/EmailHeader";
diff --git a/apps/web/src/server/jobs/domain-verification-job.ts b/apps/web/src/server/jobs/domain-verification-job.ts
new file mode 100644
index 00000000..a83a9951
--- /dev/null
+++ b/apps/web/src/server/jobs/domain-verification-job.ts
@@ -0,0 +1,90 @@
+import { Queue, Worker } from "bullmq";
+import { db } from "~/server/db";
+import { logger } from "~/server/logger/log";
+import { getRedis, BULL_PREFIX } from "~/server/redis";
+import {
+ DOMAIN_VERIFICATION_QUEUE,
+ DEFAULT_QUEUE_OPTIONS,
+} from "~/server/queue/queue-constants";
+import {
+ isDomainVerificationDue,
+ refreshDomainVerification,
+} from "~/server/service/domain-service";
+
+let initialized = false;
+
+export async function runDueDomainVerifications() {
+ const domains = await db.domain.findMany({
+ orderBy: {
+ createdAt: "asc",
+ },
+ });
+
+ for (const domain of domains) {
+ try {
+ const isDue = await isDomainVerificationDue(domain);
+ if (!isDue) {
+ continue;
+ }
+
+ await refreshDomainVerification(domain);
+ } catch (error) {
+ logger.error(
+ { err: error, domainId: domain.id },
+ "[DomainVerificationJob]: Failed to refresh domain verification",
+ );
+ }
+ }
+}
+
+export async function initDomainVerificationJob() {
+ if (initialized) {
+ return;
+ }
+
+ const connection = getRedis();
+ const domainVerificationQueue = new Queue(DOMAIN_VERIFICATION_QUEUE, {
+ connection,
+ prefix: BULL_PREFIX,
+ skipVersionCheck: true,
+ });
+
+ const worker = new Worker(
+ DOMAIN_VERIFICATION_QUEUE,
+ async () => {
+ await runDueDomainVerifications();
+ },
+ {
+ connection,
+ concurrency: 1,
+ prefix: BULL_PREFIX,
+ skipVersionCheck: true,
+ },
+ );
+
+ await domainVerificationQueue.upsertJobScheduler(
+ "domain-verification-hourly",
+ {
+ pattern: "0 * * * *",
+ tz: "UTC",
+ },
+ {
+ opts: {
+ ...DEFAULT_QUEUE_OPTIONS,
+ },
+ },
+ );
+
+ worker.on("completed", (job) => {
+ logger.info({ jobId: job.id }, "[DomainVerificationJob]: Job completed");
+ });
+
+ worker.on("failed", (job, err) => {
+ logger.error(
+ { err, jobId: job?.id },
+ "[DomainVerificationJob]: Job failed",
+ );
+ });
+
+ initialized = true;
+}
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
new file mode 100644
index 00000000..55c260ef
--- /dev/null
+++ b/apps/web/src/server/jobs/domain-verification-job.unit.test.ts
@@ -0,0 +1,123 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { DomainStatus, type Domain } from "@prisma/client";
+
+const {
+ mockFindMany,
+ mockIsDomainVerificationDue,
+ mockRefreshDomainVerification,
+ mockUpsertJobScheduler,
+ mockWorkerOn,
+ mockQueue,
+ mockWorker,
+} = vi.hoisted(() => ({
+ mockFindMany: vi.fn(),
+ mockIsDomainVerificationDue: vi.fn(),
+ mockRefreshDomainVerification: vi.fn(),
+ mockUpsertJobScheduler: vi.fn(),
+ mockWorkerOn: vi.fn(),
+ mockQueue: vi.fn().mockImplementation(() => ({
+ upsertJobScheduler: mockUpsertJobScheduler,
+ })),
+ mockWorker: vi.fn().mockImplementation(() => ({
+ on: mockWorkerOn,
+ })),
+}));
+
+vi.mock("bullmq", () => ({
+ Queue: mockQueue,
+ Worker: mockWorker,
+}));
+
+vi.mock("~/server/db", () => ({
+ db: {
+ domain: {
+ findMany: mockFindMany,
+ },
+ },
+}));
+
+vi.mock("~/server/redis", () => ({
+ BULL_PREFIX: "bull",
+ getRedis: vi.fn(() => ({})),
+}));
+
+vi.mock("~/server/logger/log", () => ({
+ logger: {
+ error: vi.fn(),
+ info: vi.fn(),
+ },
+}));
+
+vi.mock("~/server/service/domain-service", () => ({
+ isDomainVerificationDue: mockIsDomainVerificationDue,
+ refreshDomainVerification: mockRefreshDomainVerification,
+}));
+
+import {
+ initDomainVerificationJob,
+ runDueDomainVerifications,
+} from "~/server/jobs/domain-verification-job";
+
+function createDomain(id: number, status: DomainStatus): Domain {
+ return {
+ id,
+ name: `example-${id}.com`,
+ teamId: 7,
+ status,
+ region: "us-east-1",
+ clickTracking: false,
+ openTracking: false,
+ publicKey: "public-key",
+ dkimSelector: "usesend",
+ dkimStatus: DomainStatus.NOT_STARTED,
+ spfDetails: DomainStatus.NOT_STARTED,
+ dmarcAdded: false,
+ errorMessage: null,
+ subdomain: null,
+ sesTenantId: null,
+ isVerifying: status !== DomainStatus.SUCCESS,
+ createdAt: new Date("2026-03-01T00:00:00.000Z"),
+ updatedAt: new Date("2026-03-01T00:00:00.000Z"),
+ };
+}
+
+describe("domain-verification-job", () => {
+ beforeEach(() => {
+ mockFindMany.mockReset();
+ mockIsDomainVerificationDue.mockReset();
+ mockRefreshDomainVerification.mockReset();
+ mockUpsertJobScheduler.mockReset();
+ mockWorkerOn.mockReset();
+ mockQueue.mockReset();
+ mockWorker.mockReset();
+ mockQueue.mockImplementation(() => ({
+ upsertJobScheduler: mockUpsertJobScheduler,
+ }));
+ mockWorker.mockImplementation(() => ({
+ on: mockWorkerOn,
+ }));
+ });
+
+ it("refreshes only domains that are due", async () => {
+ const firstDomain = createDomain(1, DomainStatus.PENDING);
+ const secondDomain = createDomain(2, DomainStatus.SUCCESS);
+ mockFindMany.mockResolvedValue([firstDomain, secondDomain]);
+ mockIsDomainVerificationDue
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(false);
+
+ await runDueDomainVerifications();
+
+ expect(mockRefreshDomainVerification).toHaveBeenCalledTimes(1);
+ expect(mockRefreshDomainVerification).toHaveBeenCalledWith(firstDomain);
+ });
+
+ it("initializes the worker lazily", async () => {
+ await initDomainVerificationJob();
+
+ expect(mockQueue).toHaveBeenCalledTimes(1);
+ expect(mockWorker).toHaveBeenCalledTimes(1);
+ expect(mockUpsertJobScheduler).toHaveBeenCalledTimes(1);
+ expect(mockWorkerOn).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/apps/web/src/server/queue/queue-constants.ts b/apps/web/src/server/queue/queue-constants.ts
index 42944945..ad6b8d26 100644
--- a/apps/web/src/server/queue/queue-constants.ts
+++ b/apps/web/src/server/queue/queue-constants.ts
@@ -3,6 +3,7 @@ export const CAMPAIGN_MAIL_PROCESSING_QUEUE = "campaign-emails-processing";
export const CONTACT_BULK_ADD_QUEUE = "contact-bulk-add";
export const CAMPAIGN_BATCH_QUEUE = "campaign-batch";
export const CAMPAIGN_SCHEDULER_QUEUE = "campaign-scheduler";
+export const DOMAIN_VERIFICATION_QUEUE = "domain-verification";
export const WEBHOOK_DISPATCH_QUEUE = "webhook-dispatch";
export const WEBHOOK_CLEANUP_QUEUE = "webhook-cleanup";
diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts
index baed67bd..0226633d 100644
--- a/apps/web/src/server/service/domain-service.ts
+++ b/apps/web/src/server/service/domain-service.ts
@@ -3,9 +3,13 @@ import util from "util";
import * as tldts from "tldts";
import * as ses from "~/server/aws/ses";
import { db } from "~/server/db";
+import { env } from "~/env";
+import { renderDomainVerificationStatusEmail } from "~/server/email-templates";
+import { logger } from "~/server/logger/log";
+import { sendMail } from "~/server/mailer";
+import { getRedis, redisKey } from "~/server/redis";
import { SesSettingsService } from "./ses-settings-service";
import { UnsendApiError } from "../public-api/api-error";
-import { logger } from "../logger/log";
import { ApiKey, DomainStatus, type Domain } from "@prisma/client";
import {
type DomainPayload,
@@ -16,6 +20,25 @@ import type { DomainDnsRecord } from "~/types/domain";
import { WebhookService } from "./webhook-service";
const DOMAIN_STATUS_VALUES = new Set(Object.values(DomainStatus));
+export const DOMAIN_UNVERIFIED_RECHECK_MS = 6 * 60 * 60 * 1000;
+export const DOMAIN_VERIFIED_RECHECK_MS = 30 * 24 * 60 * 60 * 1000;
+const VERIFIED_DOMAIN_STATUSES = new Set([DomainStatus.SUCCESS]);
+
+type DomainVerificationState = {
+ hasEverVerified: boolean;
+ lastCheckedAt: Date | null;
+ lastNotifiedStatus: DomainStatus | null;
+};
+
+type DomainWithDnsRecords = Domain & { dnsRecords: DomainDnsRecord[] };
+
+type DomainVerificationRefreshResult = DomainWithDnsRecords & {
+ verificationError: string | null;
+ lastCheckedTime: string | null;
+ previousStatus: DomainStatus;
+ statusChanged: boolean;
+ hasEverVerified: boolean;
+};
function parseDomainStatus(status?: string | null): DomainStatus {
if (!status) {
@@ -87,6 +110,204 @@ function withDnsRecords(
const dnsResolveTxt = util.promisify(dns.resolveTxt);
+function getDomainVerificationKey(kind: string, domainId: number) {
+ return redisKey(`domain:verification:${kind}:${domainId}`);
+}
+
+function normalizeDate(value: string | null | undefined) {
+ if (!value) {
+ return null;
+ }
+
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date;
+}
+
+async function getDomainVerificationState(
+ domainId: number,
+): Promise {
+ const redis = getRedis();
+ const [lastCheckedValue, lastNotifiedStatusValue, hasEverVerifiedValue] =
+ await redis.mget([
+ getDomainVerificationKey("last-check", domainId),
+ getDomainVerificationKey("last-notified-status", domainId),
+ getDomainVerificationKey("has-ever-verified", domainId),
+ ]);
+
+ return {
+ hasEverVerified: hasEverVerifiedValue === "1",
+ lastCheckedAt: normalizeDate(lastCheckedValue),
+ lastNotifiedStatus: DOMAIN_STATUS_VALUES.has(
+ (lastNotifiedStatusValue ?? "") as DomainStatus,
+ )
+ ? (lastNotifiedStatusValue as DomainStatus)
+ : null,
+ };
+}
+
+async function setDomainVerificationCheckedAt(
+ domainId: number,
+ checkedAt: Date,
+) {
+ await getRedis().set(
+ getDomainVerificationKey("last-check", domainId),
+ checkedAt.toISOString(),
+ );
+}
+
+async function markDomainEverVerified(domainId: number) {
+ await getRedis().set(
+ getDomainVerificationKey("has-ever-verified", domainId),
+ "1",
+ );
+}
+
+async function setLastNotifiedDomainStatus(
+ domainId: number,
+ status: DomainStatus,
+) {
+ await getRedis().set(
+ getDomainVerificationKey("last-notified-status", domainId),
+ status,
+ );
+}
+
+async function reserveDomainStatusNotification(
+ domainId: number,
+ status: DomainStatus,
+) {
+ const result = await getRedis().set(
+ getDomainVerificationKey(`notification-lock:${status}`, domainId),
+ "1",
+ "EX",
+ 300,
+ "NX",
+ );
+
+ return result === "OK";
+}
+
+async function clearDomainVerificationState(domainId: number) {
+ await getRedis().del(
+ getDomainVerificationKey("last-check", domainId),
+ getDomainVerificationKey("last-notified-status", domainId),
+ getDomainVerificationKey("has-ever-verified", domainId),
+ );
+}
+
+function shouldContinueVerifying(
+ verificationStatus: DomainStatus,
+ dkimStatus: string | undefined,
+ spfDetails: string | undefined,
+) {
+ if (
+ verificationStatus === DomainStatus.SUCCESS &&
+ dkimStatus === DomainStatus.SUCCESS &&
+ spfDetails === DomainStatus.SUCCESS
+ ) {
+ return false;
+ }
+
+ return verificationStatus !== DomainStatus.FAILED;
+}
+
+function shouldSendDomainStatusNotification({
+ previousStatus,
+ currentStatus,
+ hasEverVerified,
+ lastNotifiedStatus,
+}: {
+ previousStatus: DomainStatus;
+ currentStatus: DomainStatus;
+ hasEverVerified: boolean;
+ lastNotifiedStatus: DomainStatus | null;
+}) {
+ if (lastNotifiedStatus === null && currentStatus === previousStatus) {
+ return false;
+ }
+
+ if (hasEverVerified) {
+ return currentStatus !== lastNotifiedStatus;
+ }
+
+ if (
+ currentStatus !== DomainStatus.SUCCESS &&
+ currentStatus !== DomainStatus.FAILED
+ ) {
+ return false;
+ }
+
+ return currentStatus !== lastNotifiedStatus;
+}
+
+async function sendDomainStatusNotification({
+ domain,
+ previousStatus,
+ verificationError,
+}: {
+ domain: Domain;
+ previousStatus: DomainStatus;
+ verificationError: string | null;
+}) {
+ const recipients = (
+ await db.teamUser.findMany({
+ where: {
+ teamId: domain.teamId,
+ },
+ include: {
+ user: true,
+ },
+ })
+ )
+ .map((teamUser) => teamUser.user?.email)
+ .filter((email): email is string => Boolean(email));
+
+ if (recipients.length === 0) {
+ logger.info(
+ { domainId: domain.id, teamId: domain.teamId },
+ "[DomainService]: Skipping domain status email because team has no recipients",
+ );
+ return;
+ }
+
+ const subject =
+ domain.status === DomainStatus.SUCCESS
+ ? `useSend: ${domain.name} is verified`
+ : previousStatus === DomainStatus.SUCCESS
+ ? `useSend: ${domain.name} verification status changed`
+ : `useSend: ${domain.name} verification failed`;
+
+ const domainUrl = `${env.NEXTAUTH_URL}/domains/${domain.id}`;
+ const html = await renderDomainVerificationStatusEmail({
+ domainName: domain.name,
+ currentStatus: domain.status,
+ previousStatus,
+ verificationError,
+ domainUrl,
+ });
+ const statusMessage =
+ domain.status === 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 = [
+ "Hey,",
+ null,
+ statusMessage,
+ verificationError ? `Verification error: ${verificationError}` : null,
+ null,
+ `Open domain settings: ${domainUrl}`,
+ null,
+ "Thanks,",
+ "useSend Team",
+ ].filter((value): value is string => Boolean(value));
+
+ await Promise.all(
+ recipients.map((email) =>
+ sendMail(email, subject, textLines.join("\n"), html, "hey@usesend.com"),
+ ),
+ );
+}
+
function buildDomainPayload(domain: Domain): DomainPayload {
return {
id: domain.id,
@@ -248,73 +469,139 @@ export async function getDomain(id: number, teamId: number) {
}
if (domain.isVerifying) {
- const previousStatus = domain.status;
- const domainIdentity = await ses.getDomainIdentity(
- domain.name,
- domain.region,
- );
+ return refreshDomainVerification(domain);
+ }
- const dkimStatus = domainIdentity.DkimAttributes?.Status;
- const spfDetails = domainIdentity.MailFromAttributes?.MailFromDomainStatus;
- const verificationError = domainIdentity.VerificationInfo?.ErrorType;
- const verificationStatus = domainIdentity.VerificationStatus;
- const lastCheckedTime =
- domainIdentity.VerificationInfo?.LastCheckedTimestamp;
- const _dmarcRecord = await getDmarcRecord(tldts.getDomain(domain.name)!);
- const dmarcRecord = _dmarcRecord?.[0]?.[0];
+ return withDnsRecords(domain);
+}
- domain = await db.domain.update({
- where: {
- id,
- },
- data: {
+export async function refreshDomainVerification(
+ domainOrId: number | Domain,
+): Promise {
+ const domain =
+ typeof domainOrId === "number"
+ ? await db.domain.findUnique({ where: { id: domainOrId } })
+ : domainOrId;
+
+ if (!domain) {
+ throw new UnsendApiError({
+ code: "NOT_FOUND",
+ message: "Domain not found",
+ });
+ }
+
+ const verificationState = await getDomainVerificationState(domain.id);
+ const previousStatus = domain.status;
+ const domainIdentity = await ses.getDomainIdentity(
+ domain.name,
+ domain.region,
+ );
+ const dkimStatus = domainIdentity.DkimAttributes?.Status?.toString();
+ const spfDetails =
+ domainIdentity.MailFromAttributes?.MailFromDomainStatus?.toString();
+ const verificationError =
+ domainIdentity.VerificationInfo?.ErrorType?.toString() ?? null;
+ const verificationStatus = parseDomainStatus(
+ domainIdentity.VerificationStatus?.toString(),
+ );
+ const lastCheckedTime = domainIdentity.VerificationInfo?.LastCheckedTimestamp;
+ const baseDomain = tldts.getDomain(domain.name);
+ const _dmarcRecord = baseDomain ? await getDmarcRecord(baseDomain) : null;
+ const dmarcRecord = _dmarcRecord?.[0]?.[0];
+ const checkedAt = new Date();
+
+ const updatedDomain = await db.domain.update({
+ where: {
+ id: domain.id,
+ },
+ data: {
+ dkimStatus: dkimStatus ?? null,
+ spfDetails: spfDetails ?? null,
+ status: verificationStatus,
+ errorMessage: verificationError,
+ dmarcAdded: Boolean(dmarcRecord),
+ isVerifying: shouldContinueVerifying(
+ verificationStatus,
dkimStatus,
spfDetails,
- status: verificationStatus ?? "NOT_STARTED",
- dmarcAdded: dmarcRecord ? true : false,
- isVerifying:
- verificationStatus === "SUCCESS" &&
- dkimStatus === "SUCCESS" &&
- spfDetails === "SUCCESS"
- ? false
- : true,
- },
- });
+ ),
+ },
+ });
+
+ await setDomainVerificationCheckedAt(domain.id, checkedAt);
- const normalizedDomain = {
- ...domain,
- dkimStatus: dkimStatus?.toString() ?? null,
- spfDetails: spfDetails?.toString() ?? null,
- dmarcAdded: dmarcRecord ? true : false,
- } satisfies Domain;
-
- const domainWithDns = withDnsRecords(normalizedDomain);
- const normalizedLastCheckedTime =
- lastCheckedTime instanceof Date
- ? lastCheckedTime.toISOString()
- : (lastCheckedTime ?? null);
-
- const response = {
- ...domainWithDns,
- dkimStatus: normalizedDomain.dkimStatus,
- spfDetails: normalizedDomain.spfDetails,
- verificationError: verificationError?.toString() ?? null,
- lastCheckedTime: normalizedLastCheckedTime,
- dmarcAdded: normalizedDomain.dmarcAdded,
- };
-
- if (previousStatus !== domainWithDns.status) {
- const eventType: DomainWebhookEventType =
- domainWithDns.status === DomainStatus.SUCCESS
- ? "domain.verified"
- : "domain.updated";
- await emitDomainEvent(domainWithDns, eventType);
+ if (updatedDomain.status === DomainStatus.SUCCESS) {
+ await markDomainEverVerified(domain.id);
+ }
+
+ if (
+ shouldSendDomainStatusNotification({
+ previousStatus,
+ currentStatus: updatedDomain.status,
+ hasEverVerified:
+ verificationState.hasEverVerified ||
+ updatedDomain.status === DomainStatus.SUCCESS,
+ lastNotifiedStatus: verificationState.lastNotifiedStatus,
+ })
+ ) {
+ const reservedNotification = await reserveDomainStatusNotification(
+ domain.id,
+ updatedDomain.status,
+ );
+
+ if (reservedNotification) {
+ try {
+ await sendDomainStatusNotification({
+ domain: updatedDomain,
+ previousStatus,
+ verificationError,
+ });
+ await setLastNotifiedDomainStatus(domain.id, updatedDomain.status);
+ } catch (error) {
+ logger.error(
+ { err: error, domainId: domain.id, status: updatedDomain.status },
+ "[DomainService]: Failed to send domain status notification",
+ );
+ }
}
+ }
- return response;
+ const normalizedDomain = {
+ ...updatedDomain,
+ dkimStatus: dkimStatus ?? null,
+ spfDetails: spfDetails ?? null,
+ dmarcAdded: Boolean(dmarcRecord),
+ } satisfies Domain;
+
+ const domainWithDns = withDnsRecords(normalizedDomain);
+ const normalizedLastCheckedTime =
+ lastCheckedTime instanceof Date
+ ? lastCheckedTime.toISOString()
+ : lastCheckedTime != null
+ ? String(lastCheckedTime)
+ : null;
+
+ if (previousStatus !== domainWithDns.status) {
+ const eventType: DomainWebhookEventType =
+ domainWithDns.status === DomainStatus.SUCCESS
+ ? "domain.verified"
+ : "domain.updated";
+ await emitDomainEvent(domainWithDns, eventType);
}
- return withDnsRecords(domain);
+ return {
+ ...domainWithDns,
+ dkimStatus: normalizedDomain.dkimStatus,
+ spfDetails: normalizedDomain.spfDetails,
+ verificationError,
+ lastCheckedTime: normalizedLastCheckedTime,
+ dmarcAdded: normalizedDomain.dmarcAdded,
+ previousStatus,
+ statusChanged: previousStatus !== domainWithDns.status,
+ hasEverVerified:
+ verificationState.hasEverVerified ||
+ domainWithDns.status === DomainStatus.SUCCESS,
+ };
}
export async function updateDomain(
@@ -351,6 +638,14 @@ export async function deleteDomain(id: number) {
}
const deletedRecord = await db.domain.delete({ where: { id } });
+ try {
+ await clearDomainVerificationState(id);
+ } catch (error) {
+ logger.error(
+ { err: error, domainId: id },
+ "[DomainService]: Failed to clear domain verification state",
+ );
+ }
await emitDomainEvent(domain, "domain.deleted");
@@ -396,3 +691,29 @@ async function emitDomainEvent(domain: Domain, type: DomainWebhookEventType) {
);
}
}
+
+export async function isDomainVerificationDue(domain: Domain) {
+ const verificationState = await getDomainVerificationState(domain.id);
+
+ if (
+ !verificationState.hasEverVerified &&
+ domain.status === DomainStatus.FAILED &&
+ !domain.isVerifying
+ ) {
+ return false;
+ }
+
+ const now = Date.now();
+ const lastCheckedAt = verificationState.lastCheckedAt?.getTime() ?? 0;
+ const intervalMs =
+ verificationState.hasEverVerified ||
+ VERIFIED_DOMAIN_STATUSES.has(domain.status)
+ ? DOMAIN_VERIFIED_RECHECK_MS
+ : DOMAIN_UNVERIFIED_RECHECK_MS;
+
+ if (!verificationState.lastCheckedAt) {
+ return true;
+ }
+
+ return now - lastCheckedAt >= intervalMs;
+}
diff --git a/apps/web/src/server/service/domain-service.unit.test.ts b/apps/web/src/server/service/domain-service.unit.test.ts
new file mode 100644
index 00000000..a2fbf78d
--- /dev/null
+++ b/apps/web/src/server/service/domain-service.unit.test.ts
@@ -0,0 +1,414 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { DomainStatus, type Domain } from "@prisma/client";
+
+const {
+ mockDb,
+ mockGetDomainIdentity,
+ mockWebhookEmit,
+ mockRedis,
+ mockSendMail,
+ mockRenderDomainVerificationStatusEmail,
+ mockResolveTxt,
+} = vi.hoisted(() => ({
+ mockDb: {
+ domain: {
+ update: vi.fn(),
+ findUnique: vi.fn(),
+ },
+ teamUser: {
+ findMany: vi.fn(),
+ },
+ },
+ mockGetDomainIdentity: vi.fn(),
+ mockWebhookEmit: vi.fn(),
+ mockRedis: {
+ mget: vi.fn(),
+ set: vi.fn(),
+ del: vi.fn(),
+ },
+ mockSendMail: vi.fn(),
+ mockRenderDomainVerificationStatusEmail: vi.fn(),
+ mockResolveTxt: vi.fn(),
+}));
+
+function wasLastNotifiedStatusStored() {
+ return mockRedis.set.mock.calls.some(
+ (call) => call[0] === "domain:verification:last-notified-status:42",
+ );
+}
+
+vi.mock("dns", () => ({
+ default: {
+ resolveTxt: mockResolveTxt,
+ },
+}));
+
+vi.mock("~/server/db", () => ({
+ db: mockDb,
+}));
+
+vi.mock("~/server/aws/ses", () => ({
+ getDomainIdentity: mockGetDomainIdentity,
+}));
+
+vi.mock("~/server/service/webhook-service", () => ({
+ WebhookService: {
+ emit: mockWebhookEmit,
+ },
+}));
+
+vi.mock("~/server/redis", () => ({
+ getRedis: () => mockRedis,
+ redisKey: (key: string) => key,
+}));
+
+vi.mock("~/server/mailer", () => ({
+ sendMail: mockSendMail,
+}));
+
+vi.mock("~/server/email-templates", () => ({
+ renderDomainVerificationStatusEmail: mockRenderDomainVerificationStatusEmail,
+}));
+
+import {
+ DOMAIN_UNVERIFIED_RECHECK_MS,
+ DOMAIN_VERIFIED_RECHECK_MS,
+ isDomainVerificationDue,
+ refreshDomainVerification,
+} from "~/server/service/domain-service";
+
+function createDomain(overrides: Partial = {}): Domain {
+ return {
+ id: 42,
+ name: "example.com",
+ teamId: 7,
+ status: DomainStatus.PENDING,
+ region: "us-east-1",
+ clickTracking: false,
+ openTracking: false,
+ publicKey: "public-key",
+ dkimSelector: "usesend",
+ dkimStatus: DomainStatus.NOT_STARTED,
+ spfDetails: DomainStatus.NOT_STARTED,
+ dmarcAdded: false,
+ errorMessage: null,
+ subdomain: null,
+ sesTenantId: null,
+ isVerifying: true,
+ createdAt: new Date("2026-03-01T00:00:00.000Z"),
+ updatedAt: new Date("2026-03-01T00:00:00.000Z"),
+ ...overrides,
+ };
+}
+
+describe("domain-service", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-09T12:00:00.000Z"));
+
+ mockDb.domain.update.mockReset();
+ mockDb.domain.findUnique.mockReset();
+ mockDb.teamUser.findMany.mockReset();
+ mockGetDomainIdentity.mockReset();
+ mockWebhookEmit.mockReset();
+ mockRedis.mget.mockReset();
+ mockRedis.set.mockReset();
+ mockRedis.del.mockReset();
+ mockSendMail.mockReset();
+ mockRenderDomainVerificationStatusEmail.mockReset();
+ mockResolveTxt.mockReset();
+
+ mockRenderDomainVerificationStatusEmail.mockResolvedValue(
+ "domain status
",
+ );
+ mockRedis.set.mockResolvedValue("OK");
+ mockDb.teamUser.findMany.mockResolvedValue([
+ { user: { email: "alice@example.com" } },
+ { user: { email: "bob@example.com" } },
+ ]);
+ mockResolveTxt.mockImplementation(
+ (_name: string, cb: (err: Error | null, value?: string[][]) => void) => {
+ cb(null, [["v=DMARC1; p=none;"]]);
+ },
+ );
+ });
+
+ it("sends success status emails to all team members when a new domain becomes verified", async () => {
+ const domain = createDomain();
+ mockRedis.mget.mockResolvedValue([null, null, null]);
+ mockGetDomainIdentity.mockResolvedValue({
+ DkimAttributes: { Status: DomainStatus.SUCCESS },
+ MailFromAttributes: { MailFromDomainStatus: DomainStatus.SUCCESS },
+ VerificationInfo: {
+ ErrorType: null,
+ LastCheckedTimestamp: new Date("2026-03-09T12:00:00.000Z"),
+ },
+ VerificationStatus: DomainStatus.SUCCESS,
+ });
+ mockDb.domain.update.mockResolvedValue(
+ createDomain({
+ status: DomainStatus.SUCCESS,
+ dkimStatus: DomainStatus.SUCCESS,
+ spfDetails: DomainStatus.SUCCESS,
+ dmarcAdded: true,
+ isVerifying: false,
+ }),
+ );
+
+ const result = await refreshDomainVerification(domain);
+
+ expect(mockDb.domain.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ status: DomainStatus.SUCCESS,
+ isVerifying: false,
+ errorMessage: null,
+ }),
+ }),
+ );
+ expect(mockSendMail).toHaveBeenCalledTimes(2);
+ expect(wasLastNotifiedStatusStored()).toBe(true);
+ expect(result.status).toBe(DomainStatus.SUCCESS);
+ expect(result.hasEverVerified).toBe(true);
+ });
+
+ it("sends one failure email and stops polling on terminal failure", async () => {
+ const domain = createDomain();
+ mockRedis.mget.mockResolvedValue([null, null, null]);
+ mockGetDomainIdentity.mockResolvedValue({
+ DkimAttributes: { Status: DomainStatus.PENDING },
+ MailFromAttributes: { MailFromDomainStatus: DomainStatus.PENDING },
+ VerificationInfo: {
+ ErrorType: "MAIL_FROM_DOMAIN_NOT_VERIFIED",
+ LastCheckedTimestamp: new Date("2026-03-09T12:00:00.000Z"),
+ },
+ VerificationStatus: DomainStatus.FAILED,
+ });
+ mockDb.domain.update.mockResolvedValue(
+ createDomain({
+ status: DomainStatus.FAILED,
+ dkimStatus: DomainStatus.PENDING,
+ spfDetails: DomainStatus.PENDING,
+ errorMessage: "MAIL_FROM_DOMAIN_NOT_VERIFIED",
+ isVerifying: false,
+ }),
+ );
+
+ const result = await refreshDomainVerification(domain);
+
+ expect(mockDb.domain.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ status: DomainStatus.FAILED,
+ isVerifying: false,
+ errorMessage: "MAIL_FROM_DOMAIN_NOT_VERIFIED",
+ }),
+ }),
+ );
+ expect(mockSendMail).toHaveBeenCalledTimes(2);
+ expect(result.status).toBe(DomainStatus.FAILED);
+ });
+
+ it("does not resend status emails when the current status was already notified", async () => {
+ const domain = createDomain({
+ status: DomainStatus.SUCCESS,
+ isVerifying: false,
+ });
+ mockRedis.mget.mockResolvedValue([
+ new Date("2026-03-08T12:00:00.000Z").toISOString(),
+ DomainStatus.SUCCESS,
+ "1",
+ ]);
+ mockGetDomainIdentity.mockResolvedValue({
+ DkimAttributes: { Status: DomainStatus.SUCCESS },
+ MailFromAttributes: { MailFromDomainStatus: DomainStatus.SUCCESS },
+ VerificationInfo: {
+ ErrorType: null,
+ LastCheckedTimestamp: new Date("2026-03-09T12:00:00.000Z"),
+ },
+ VerificationStatus: DomainStatus.SUCCESS,
+ });
+ mockDb.domain.update.mockResolvedValue(
+ createDomain({
+ status: DomainStatus.SUCCESS,
+ dkimStatus: DomainStatus.SUCCESS,
+ spfDetails: DomainStatus.SUCCESS,
+ dmarcAdded: true,
+ isVerifying: false,
+ }),
+ );
+
+ await refreshDomainVerification(domain);
+
+ expect(mockSendMail).not.toHaveBeenCalled();
+ });
+
+ it("does not send status email on first refresh when status is unchanged", async () => {
+ const domain = createDomain({
+ status: DomainStatus.SUCCESS,
+ dkimStatus: DomainStatus.SUCCESS,
+ spfDetails: DomainStatus.SUCCESS,
+ isVerifying: false,
+ });
+ mockRedis.mget.mockResolvedValue([null, null, null]);
+ mockGetDomainIdentity.mockResolvedValue({
+ DkimAttributes: { Status: DomainStatus.SUCCESS },
+ MailFromAttributes: { MailFromDomainStatus: DomainStatus.SUCCESS },
+ VerificationInfo: {
+ ErrorType: null,
+ LastCheckedTimestamp: new Date("2026-03-09T12:00:00.000Z"),
+ },
+ VerificationStatus: DomainStatus.SUCCESS,
+ });
+ mockDb.domain.update.mockResolvedValue(
+ createDomain({
+ status: DomainStatus.SUCCESS,
+ dkimStatus: DomainStatus.SUCCESS,
+ spfDetails: DomainStatus.SUCCESS,
+ dmarcAdded: true,
+ isVerifying: false,
+ }),
+ );
+
+ await refreshDomainVerification(domain);
+
+ expect(mockSendMail).not.toHaveBeenCalled();
+ expect(wasLastNotifiedStatusStored()).toBe(false);
+ });
+
+ it("reserves the notification so concurrent refreshes do not double-send", async () => {
+ const domain = createDomain();
+ mockRedis.mget.mockResolvedValue([null, null, null]);
+ let reservedOnce = false;
+ mockRedis.set.mockImplementation(async (key: string) => {
+ if (key.includes("notification-lock")) {
+ if (reservedOnce) {
+ return null;
+ }
+
+ reservedOnce = true;
+ return "OK";
+ }
+
+ return "OK";
+ });
+ mockGetDomainIdentity.mockResolvedValue({
+ DkimAttributes: { Status: DomainStatus.SUCCESS },
+ MailFromAttributes: { MailFromDomainStatus: DomainStatus.SUCCESS },
+ VerificationInfo: {
+ ErrorType: null,
+ LastCheckedTimestamp: new Date("2026-03-09T12:00:00.000Z"),
+ },
+ VerificationStatus: DomainStatus.SUCCESS,
+ });
+ mockDb.domain.update.mockResolvedValue(
+ createDomain({
+ status: DomainStatus.SUCCESS,
+ dkimStatus: DomainStatus.SUCCESS,
+ spfDetails: DomainStatus.SUCCESS,
+ dmarcAdded: true,
+ isVerifying: false,
+ }),
+ );
+
+ await Promise.all([
+ refreshDomainVerification(domain),
+ refreshDomainVerification(domain),
+ ]);
+
+ expect(mockSendMail).toHaveBeenCalledTimes(2);
+ expect(mockDb.domain.update).toHaveBeenCalledTimes(2);
+ });
+
+ it("logs and continues when sending the status email fails", async () => {
+ const domain = createDomain();
+ mockRedis.mget.mockResolvedValue([null, null, null]);
+ mockGetDomainIdentity.mockResolvedValue({
+ DkimAttributes: { Status: DomainStatus.SUCCESS },
+ MailFromAttributes: { MailFromDomainStatus: DomainStatus.SUCCESS },
+ VerificationInfo: {
+ ErrorType: null,
+ LastCheckedTimestamp: new Date("2026-03-09T12:00:00.000Z"),
+ },
+ VerificationStatus: DomainStatus.SUCCESS,
+ });
+ mockDb.domain.update.mockResolvedValue(
+ createDomain({
+ status: DomainStatus.SUCCESS,
+ dkimStatus: DomainStatus.SUCCESS,
+ spfDetails: DomainStatus.SUCCESS,
+ dmarcAdded: true,
+ isVerifying: false,
+ }),
+ );
+ mockSendMail
+ .mockRejectedValueOnce(new Error("mail failed"))
+ .mockResolvedValueOnce(undefined);
+
+ const result = await refreshDomainVerification(domain);
+
+ expect(result.status).toBe(DomainStatus.SUCCESS);
+ expect(mockDb.domain.update).toHaveBeenCalled();
+ expect(wasLastNotifiedStatusStored()).toBe(false);
+ });
+
+ it("uses a 6 hour cadence for domains that have never verified", async () => {
+ const domain = createDomain({ status: DomainStatus.PENDING });
+ mockRedis.mget.mockResolvedValue([
+ new Date(
+ Date.now() - DOMAIN_UNVERIFIED_RECHECK_MS + 5 * 60 * 1000,
+ ).toISOString(),
+ null,
+ null,
+ ]);
+
+ await expect(isDomainVerificationDue(domain)).resolves.toBe(false);
+
+ mockRedis.mget.mockResolvedValue([
+ new Date(
+ Date.now() - DOMAIN_UNVERIFIED_RECHECK_MS - 5 * 60 * 1000,
+ ).toISOString(),
+ null,
+ null,
+ ]);
+
+ await expect(isDomainVerificationDue(domain)).resolves.toBe(true);
+ });
+
+ it("uses a 30 day cadence after a domain has been verified", async () => {
+ const domain = createDomain({ status: DomainStatus.FAILED });
+ mockRedis.mget.mockResolvedValue([
+ new Date(
+ Date.now() - DOMAIN_VERIFIED_RECHECK_MS + 5 * 60 * 1000,
+ ).toISOString(),
+ DomainStatus.SUCCESS,
+ "1",
+ ]);
+
+ await expect(isDomainVerificationDue(domain)).resolves.toBe(false);
+
+ mockRedis.mget.mockResolvedValue([
+ new Date(
+ Date.now() - DOMAIN_VERIFIED_RECHECK_MS - 5 * 60 * 1000,
+ ).toISOString(),
+ DomainStatus.SUCCESS,
+ "1",
+ ]);
+
+ await expect(isDomainVerificationDue(domain)).resolves.toBe(true);
+ });
+
+ it("stops automatic retries after an initial terminal failure", async () => {
+ const domain = createDomain({
+ status: DomainStatus.FAILED,
+ isVerifying: false,
+ });
+ mockRedis.mget.mockResolvedValue([
+ new Date("2026-03-09T06:00:00.000Z").toISOString(),
+ DomainStatus.FAILED,
+ null,
+ ]);
+
+ await expect(isDomainVerificationDue(domain)).resolves.toBe(false);
+ });
+});