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 { 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";
|
||||||
|
|
||||||
|
|||||||
@@ -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,60 +469,117 @@ export async function getDomain(id: number, teamId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (domain.isVerifying) {
|
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 previousStatus = domain.status;
|
||||||
const domainIdentity = await ses.getDomainIdentity(
|
const domainIdentity = await ses.getDomainIdentity(
|
||||||
domain.name,
|
domain.name,
|
||||||
domain.region,
|
domain.region,
|
||||||
);
|
);
|
||||||
|
const dkimStatus = domainIdentity.DkimAttributes?.Status?.toString();
|
||||||
const dkimStatus = domainIdentity.DkimAttributes?.Status;
|
const spfDetails =
|
||||||
const spfDetails = domainIdentity.MailFromAttributes?.MailFromDomainStatus;
|
domainIdentity.MailFromAttributes?.MailFromDomainStatus?.toString();
|
||||||
const verificationError = domainIdentity.VerificationInfo?.ErrorType;
|
const verificationError =
|
||||||
const verificationStatus = domainIdentity.VerificationStatus;
|
domainIdentity.VerificationInfo?.ErrorType?.toString() ?? null;
|
||||||
const lastCheckedTime =
|
const verificationStatus = parseDomainStatus(
|
||||||
domainIdentity.VerificationInfo?.LastCheckedTimestamp;
|
domainIdentity.VerificationStatus?.toString(),
|
||||||
const _dmarcRecord = await getDmarcRecord(tldts.getDomain(domain.name)!);
|
);
|
||||||
|
const lastCheckedTime = domainIdentity.VerificationInfo?.LastCheckedTimestamp;
|
||||||
|
const baseDomain = tldts.getDomain(domain.name);
|
||||||
|
const _dmarcRecord = baseDomain ? await getDmarcRecord(baseDomain) : null;
|
||||||
const dmarcRecord = _dmarcRecord?.[0]?.[0];
|
const dmarcRecord = _dmarcRecord?.[0]?.[0];
|
||||||
|
const checkedAt = new Date();
|
||||||
|
|
||||||
domain = await db.domain.update({
|
const updatedDomain = await db.domain.update({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id: domain.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
|
dkimStatus: dkimStatus ?? null,
|
||||||
|
spfDetails: spfDetails ?? null,
|
||||||
|
status: verificationStatus,
|
||||||
|
errorMessage: verificationError,
|
||||||
|
dmarcAdded: Boolean(dmarcRecord),
|
||||||
|
isVerifying: shouldContinueVerifying(
|
||||||
|
verificationStatus,
|
||||||
dkimStatus,
|
dkimStatus,
|
||||||
spfDetails,
|
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 = {
|
const normalizedDomain = {
|
||||||
...domain,
|
...updatedDomain,
|
||||||
dkimStatus: dkimStatus?.toString() ?? null,
|
dkimStatus: dkimStatus ?? null,
|
||||||
spfDetails: spfDetails?.toString() ?? null,
|
spfDetails: spfDetails ?? null,
|
||||||
dmarcAdded: dmarcRecord ? true : false,
|
dmarcAdded: Boolean(dmarcRecord),
|
||||||
} satisfies Domain;
|
} satisfies Domain;
|
||||||
|
|
||||||
const domainWithDns = withDnsRecords(normalizedDomain);
|
const domainWithDns = withDnsRecords(normalizedDomain);
|
||||||
const normalizedLastCheckedTime =
|
const normalizedLastCheckedTime =
|
||||||
lastCheckedTime instanceof Date
|
lastCheckedTime instanceof Date
|
||||||
? lastCheckedTime.toISOString()
|
? lastCheckedTime.toISOString()
|
||||||
: (lastCheckedTime ?? null);
|
: lastCheckedTime != null
|
||||||
|
? String(lastCheckedTime)
|
||||||
const response = {
|
: null;
|
||||||
...domainWithDns,
|
|
||||||
dkimStatus: normalizedDomain.dkimStatus,
|
|
||||||
spfDetails: normalizedDomain.spfDetails,
|
|
||||||
verificationError: verificationError?.toString() ?? null,
|
|
||||||
lastCheckedTime: normalizedLastCheckedTime,
|
|
||||||
dmarcAdded: normalizedDomain.dmarcAdded,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (previousStatus !== domainWithDns.status) {
|
if (previousStatus !== domainWithDns.status) {
|
||||||
const eventType: DomainWebhookEventType =
|
const eventType: DomainWebhookEventType =
|
||||||
@@ -311,10 +589,19 @@ export async function getDomain(id: number, teamId: number) {
|
|||||||
await emitDomainEvent(domainWithDns, eventType);
|
await emitDomainEvent(domainWithDns, eventType);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return {
|
||||||
}
|
...domainWithDns,
|
||||||
|
dkimStatus: normalizedDomain.dkimStatus,
|
||||||
return withDnsRecords(domain);
|
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(
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user