feat: automate domain verification follow-ups (#375)

* feat: automate domain verification follow-ups

* fix: harden domain verification notifications

* fix: skip unchanged first-run domain status emails

* fix: make domain cleanup resilient and status labels readable

* fix: clarify domain verification notification messaging
This commit is contained in:
KM Koushik
2026-03-14 01:19:44 +11:00
committed by GitHub
parent 3c2d37906e
commit 689cb8b366
8 changed files with 1179 additions and 67 deletions
+6 -2
View File
@@ -1,5 +1,5 @@
import { env } from "./env"; import { initDomainVerificationJob } from "~/server/jobs/domain-verification-job";
import { isCloud , isEmailCleanupEnabled } from "./utils/common"; import { isCloud, isEmailCleanupEnabled } from "~/utils/common";
let initialized = false; let initialized = false;
@@ -25,6 +25,10 @@ export async function register() {
await import("~/server/jobs/usage-job"); await import("~/server/jobs/usage-job");
} }
if (process.env.REDIS_URL) {
await initDomainVerificationJob();
}
if (isEmailCleanupEnabled()) { if (isEmailCleanupEnabled()) {
await import("~/server/jobs/cleanup-email-bodies"); await import("~/server/jobs/cleanup-email-bodies");
} }
@@ -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 (
<EmailLayout preview={preview}>
<EmailHeader title={getTitle(currentStatus, previousStatus)} />
<Container style={{ padding: "20px 0", textAlign: "left" as const }}>
<Text
style={{
fontSize: "16px",
color: "#374151",
margin: "0 0 16px 0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
Hey,
</Text>
{isSuccess ? (
<Text
style={{
fontSize: "15px",
color: "#4b5563",
margin: "0 0 16px 0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
Your domain <strong>{domainName}</strong> is now verified, and you
can start sending emails.
</Text>
) : (
<Text
style={{
fontSize: "15px",
color: "#4b5563",
margin: "0 0 16px 0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
Your domain <strong>{domainName}</strong> could not be verified
because the DNS records are not set up correctly yet. Please review
your DNS settings and try again.
</Text>
)}
{verificationError ? (
<Container
style={{
backgroundColor: "#fef2f2",
border: "1px solid #fecaca",
padding: "12px 16px",
margin: "0 0 24px 0",
borderRadius: "4px",
}}
>
<Text
style={{
margin: 0,
color: "#991b1b",
fontSize: 14,
textAlign: "left" as const,
}}
>
Verification error: {verificationError}
</Text>
</Container>
) : null}
<Text
style={{
fontSize: "14px",
color: "#6b7280",
margin: "0 0 24px 0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
Open your domain settings to review records and verification details.
</Text>
<Container style={{ margin: "0 0 32px 0", textAlign: "left" as const }}>
<EmailButton href={domainUrl}>Open domain settings</EmailButton>
</Container>
<Text
style={{
fontSize: "14px",
color: "#6b7280",
margin: "0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
Thanks,
<br />
useSend Team
</Text>
</Container>
<EmailFooter />
</EmailLayout>
);
}
export async function renderDomainVerificationStatusEmail(
props: DomainVerificationStatusEmailProps,
): Promise<string> {
return render(<DomainVerificationStatusEmail {...props} />);
}
@@ -8,6 +8,10 @@ export {
UsageLimitReachedEmail, UsageLimitReachedEmail,
renderUsageLimitReachedEmail, renderUsageLimitReachedEmail,
} from "./UsageLimitReachedEmail"; } from "./UsageLimitReachedEmail";
export {
DomainVerificationStatusEmail,
renderDomainVerificationStatusEmail,
} from "./DomainVerificationStatusEmail";
export * from "./components/EmailLayout"; export * from "./components/EmailLayout";
export * from "./components/EmailHeader"; export * from "./components/EmailHeader";
@@ -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;
}
@@ -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);
});
});
@@ -3,6 +3,7 @@ export const CAMPAIGN_MAIL_PROCESSING_QUEUE = "campaign-emails-processing";
export const CONTACT_BULK_ADD_QUEUE = "contact-bulk-add"; export const CONTACT_BULK_ADD_QUEUE = "contact-bulk-add";
export const CAMPAIGN_BATCH_QUEUE = "campaign-batch"; export const CAMPAIGN_BATCH_QUEUE = "campaign-batch";
export const CAMPAIGN_SCHEDULER_QUEUE = "campaign-scheduler"; export const CAMPAIGN_SCHEDULER_QUEUE = "campaign-scheduler";
export const DOMAIN_VERIFICATION_QUEUE = "domain-verification";
export const WEBHOOK_DISPATCH_QUEUE = "webhook-dispatch"; export const WEBHOOK_DISPATCH_QUEUE = "webhook-dispatch";
export const WEBHOOK_CLEANUP_QUEUE = "webhook-cleanup"; export const WEBHOOK_CLEANUP_QUEUE = "webhook-cleanup";
+386 -65
View File
@@ -3,9 +3,13 @@ import util from "util";
import * as tldts from "tldts"; import * as tldts from "tldts";
import * as ses from "~/server/aws/ses"; import * as ses from "~/server/aws/ses";
import { db } from "~/server/db"; 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 { SesSettingsService } from "./ses-settings-service";
import { UnsendApiError } from "../public-api/api-error"; import { UnsendApiError } from "../public-api/api-error";
import { logger } from "../logger/log";
import { ApiKey, DomainStatus, type Domain } from "@prisma/client"; import { ApiKey, DomainStatus, type Domain } from "@prisma/client";
import { import {
type DomainPayload, type DomainPayload,
@@ -16,6 +20,25 @@ import type { DomainDnsRecord } from "~/types/domain";
import { WebhookService } from "./webhook-service"; import { WebhookService } from "./webhook-service";
const DOMAIN_STATUS_VALUES = new Set(Object.values(DomainStatus)); 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>([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 { function parseDomainStatus(status?: string | null): DomainStatus {
if (!status) { if (!status) {
@@ -87,6 +110,204 @@ function withDnsRecords<T extends Domain>(
const dnsResolveTxt = util.promisify(dns.resolveTxt); 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<DomainVerificationState> {
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 { function buildDomainPayload(domain: Domain): DomainPayload {
return { return {
id: domain.id, id: domain.id,
@@ -248,75 +469,141 @@ export async function getDomain(id: number, teamId: number) {
} }
if (domain.isVerifying) { if (domain.isVerifying) {
const previousStatus = domain.status; return refreshDomainVerification(domain);
const domainIdentity = await ses.getDomainIdentity(
domain.name,
domain.region,
);
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];
domain = await db.domain.update({
where: {
id,
},
data: {
dkimStatus,
spfDetails,
status: verificationStatus ?? "NOT_STARTED",
dmarcAdded: dmarcRecord ? true : false,
isVerifying:
verificationStatus === "SUCCESS" &&
dkimStatus === "SUCCESS" &&
spfDetails === "SUCCESS"
? false
: true,
},
});
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);
}
return response;
} }
return withDnsRecords(domain); return withDnsRecords(domain);
} }
export async function refreshDomainVerification(
domainOrId: number | Domain,
): Promise<DomainVerificationRefreshResult> {
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,
),
},
});
await setDomainVerificationCheckedAt(domain.id, checkedAt);
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",
);
}
}
}
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 {
...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( export async function updateDomain(
id: number, id: number,
data: { clickTracking?: boolean; openTracking?: boolean }, data: { clickTracking?: boolean; openTracking?: boolean },
@@ -351,6 +638,14 @@ export async function deleteDomain(id: number) {
} }
const deletedRecord = await db.domain.delete({ where: { id } }); 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"); 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;
}
@@ -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> = {}): 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(
"<p>domain status</p>",
);
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);
});
});