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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
renderUsageLimitReachedEmail,
|
||||
} from "./UsageLimitReachedEmail";
|
||||
export {
|
||||
DomainVerificationStatusEmail,
|
||||
renderDomainVerificationStatusEmail,
|
||||
} from "./DomainVerificationStatusEmail";
|
||||
|
||||
export * from "./components/EmailLayout";
|
||||
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 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";
|
||||
|
||||
|
||||
@@ -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>([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<T extends Domain>(
|
||||
|
||||
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 {
|
||||
return {
|
||||
id: domain.id,
|
||||
@@ -248,60 +469,117 @@ export async function getDomain(id: number, teamId: number) {
|
||||
}
|
||||
|
||||
if (domain.isVerifying) {
|
||||
return refreshDomainVerification(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;
|
||||
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 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();
|
||||
|
||||
domain = await db.domain.update({
|
||||
const updatedDomain = await db.domain.update({
|
||||
where: {
|
||||
id,
|
||||
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);
|
||||
|
||||
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 = {
|
||||
...domain,
|
||||
dkimStatus: dkimStatus?.toString() ?? null,
|
||||
spfDetails: spfDetails?.toString() ?? null,
|
||||
dmarcAdded: dmarcRecord ? true : false,
|
||||
...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);
|
||||
|
||||
const response = {
|
||||
...domainWithDns,
|
||||
dkimStatus: normalizedDomain.dkimStatus,
|
||||
spfDetails: normalizedDomain.spfDetails,
|
||||
verificationError: verificationError?.toString() ?? null,
|
||||
lastCheckedTime: normalizedLastCheckedTime,
|
||||
dmarcAdded: normalizedDomain.dmarcAdded,
|
||||
};
|
||||
: lastCheckedTime != null
|
||||
? String(lastCheckedTime)
|
||||
: null;
|
||||
|
||||
if (previousStatus !== domainWithDns.status) {
|
||||
const eventType: DomainWebhookEventType =
|
||||
@@ -311,10 +589,19 @@ export async function getDomain(id: number, teamId: number) {
|
||||
await emitDomainEvent(domainWithDns, eventType);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user