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

domain status

", + ); + mockRedis.set.mockResolvedValue("OK"); + mockDb.teamUser.findMany.mockResolvedValue([ + { user: { email: "alice@example.com" } }, + { user: { email: "bob@example.com" } }, + ]); + mockResolveTxt.mockImplementation( + (_name: string, cb: (err: Error | null, value?: string[][]) => void) => { + cb(null, [["v=DMARC1; p=none;"]]); + }, + ); + }); + + it("sends success status emails to all team members when a new domain becomes verified", async () => { + const domain = createDomain(); + mockRedis.mget.mockResolvedValue([null, null, null]); + mockGetDomainIdentity.mockResolvedValue({ + DkimAttributes: { Status: DomainStatus.SUCCESS }, + MailFromAttributes: { MailFromDomainStatus: DomainStatus.SUCCESS }, + VerificationInfo: { + ErrorType: null, + LastCheckedTimestamp: new Date("2026-03-09T12:00:00.000Z"), + }, + VerificationStatus: DomainStatus.SUCCESS, + }); + mockDb.domain.update.mockResolvedValue( + createDomain({ + status: DomainStatus.SUCCESS, + dkimStatus: DomainStatus.SUCCESS, + spfDetails: DomainStatus.SUCCESS, + dmarcAdded: true, + isVerifying: false, + }), + ); + + const result = await refreshDomainVerification(domain); + + expect(mockDb.domain.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: DomainStatus.SUCCESS, + isVerifying: false, + errorMessage: null, + }), + }), + ); + expect(mockSendMail).toHaveBeenCalledTimes(2); + expect(wasLastNotifiedStatusStored()).toBe(true); + expect(result.status).toBe(DomainStatus.SUCCESS); + expect(result.hasEverVerified).toBe(true); + }); + + it("sends one failure email and stops polling on terminal failure", async () => { + const domain = createDomain(); + mockRedis.mget.mockResolvedValue([null, null, null]); + mockGetDomainIdentity.mockResolvedValue({ + DkimAttributes: { Status: DomainStatus.PENDING }, + MailFromAttributes: { MailFromDomainStatus: DomainStatus.PENDING }, + VerificationInfo: { + ErrorType: "MAIL_FROM_DOMAIN_NOT_VERIFIED", + LastCheckedTimestamp: new Date("2026-03-09T12:00:00.000Z"), + }, + VerificationStatus: DomainStatus.FAILED, + }); + mockDb.domain.update.mockResolvedValue( + createDomain({ + status: DomainStatus.FAILED, + dkimStatus: DomainStatus.PENDING, + spfDetails: DomainStatus.PENDING, + errorMessage: "MAIL_FROM_DOMAIN_NOT_VERIFIED", + isVerifying: false, + }), + ); + + const result = await refreshDomainVerification(domain); + + expect(mockDb.domain.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: DomainStatus.FAILED, + isVerifying: false, + errorMessage: "MAIL_FROM_DOMAIN_NOT_VERIFIED", + }), + }), + ); + expect(mockSendMail).toHaveBeenCalledTimes(2); + expect(result.status).toBe(DomainStatus.FAILED); + }); + + it("does not resend status emails when the current status was already notified", async () => { + const domain = createDomain({ + status: DomainStatus.SUCCESS, + isVerifying: false, + }); + mockRedis.mget.mockResolvedValue([ + new Date("2026-03-08T12:00:00.000Z").toISOString(), + DomainStatus.SUCCESS, + "1", + ]); + mockGetDomainIdentity.mockResolvedValue({ + DkimAttributes: { Status: DomainStatus.SUCCESS }, + MailFromAttributes: { MailFromDomainStatus: DomainStatus.SUCCESS }, + VerificationInfo: { + ErrorType: null, + LastCheckedTimestamp: new Date("2026-03-09T12:00:00.000Z"), + }, + VerificationStatus: DomainStatus.SUCCESS, + }); + mockDb.domain.update.mockResolvedValue( + createDomain({ + status: DomainStatus.SUCCESS, + dkimStatus: DomainStatus.SUCCESS, + spfDetails: DomainStatus.SUCCESS, + dmarcAdded: true, + isVerifying: false, + }), + ); + + await refreshDomainVerification(domain); + + expect(mockSendMail).not.toHaveBeenCalled(); + }); + + it("does not send status email on first refresh when status is unchanged", async () => { + const domain = createDomain({ + status: DomainStatus.SUCCESS, + dkimStatus: DomainStatus.SUCCESS, + spfDetails: DomainStatus.SUCCESS, + isVerifying: false, + }); + mockRedis.mget.mockResolvedValue([null, null, null]); + mockGetDomainIdentity.mockResolvedValue({ + DkimAttributes: { Status: DomainStatus.SUCCESS }, + MailFromAttributes: { MailFromDomainStatus: DomainStatus.SUCCESS }, + VerificationInfo: { + ErrorType: null, + LastCheckedTimestamp: new Date("2026-03-09T12:00:00.000Z"), + }, + VerificationStatus: DomainStatus.SUCCESS, + }); + mockDb.domain.update.mockResolvedValue( + createDomain({ + status: DomainStatus.SUCCESS, + dkimStatus: DomainStatus.SUCCESS, + spfDetails: DomainStatus.SUCCESS, + dmarcAdded: true, + isVerifying: false, + }), + ); + + await refreshDomainVerification(domain); + + expect(mockSendMail).not.toHaveBeenCalled(); + expect(wasLastNotifiedStatusStored()).toBe(false); + }); + + it("reserves the notification so concurrent refreshes do not double-send", async () => { + const domain = createDomain(); + mockRedis.mget.mockResolvedValue([null, null, null]); + let reservedOnce = false; + mockRedis.set.mockImplementation(async (key: string) => { + if (key.includes("notification-lock")) { + if (reservedOnce) { + return null; + } + + reservedOnce = true; + return "OK"; + } + + return "OK"; + }); + mockGetDomainIdentity.mockResolvedValue({ + DkimAttributes: { Status: DomainStatus.SUCCESS }, + MailFromAttributes: { MailFromDomainStatus: DomainStatus.SUCCESS }, + VerificationInfo: { + ErrorType: null, + LastCheckedTimestamp: new Date("2026-03-09T12:00:00.000Z"), + }, + VerificationStatus: DomainStatus.SUCCESS, + }); + mockDb.domain.update.mockResolvedValue( + createDomain({ + status: DomainStatus.SUCCESS, + dkimStatus: DomainStatus.SUCCESS, + spfDetails: DomainStatus.SUCCESS, + dmarcAdded: true, + isVerifying: false, + }), + ); + + await Promise.all([ + refreshDomainVerification(domain), + refreshDomainVerification(domain), + ]); + + expect(mockSendMail).toHaveBeenCalledTimes(2); + expect(mockDb.domain.update).toHaveBeenCalledTimes(2); + }); + + it("logs and continues when sending the status email fails", async () => { + const domain = createDomain(); + mockRedis.mget.mockResolvedValue([null, null, null]); + mockGetDomainIdentity.mockResolvedValue({ + DkimAttributes: { Status: DomainStatus.SUCCESS }, + MailFromAttributes: { MailFromDomainStatus: DomainStatus.SUCCESS }, + VerificationInfo: { + ErrorType: null, + LastCheckedTimestamp: new Date("2026-03-09T12:00:00.000Z"), + }, + VerificationStatus: DomainStatus.SUCCESS, + }); + mockDb.domain.update.mockResolvedValue( + createDomain({ + status: DomainStatus.SUCCESS, + dkimStatus: DomainStatus.SUCCESS, + spfDetails: DomainStatus.SUCCESS, + dmarcAdded: true, + isVerifying: false, + }), + ); + mockSendMail + .mockRejectedValueOnce(new Error("mail failed")) + .mockResolvedValueOnce(undefined); + + const result = await refreshDomainVerification(domain); + + expect(result.status).toBe(DomainStatus.SUCCESS); + expect(mockDb.domain.update).toHaveBeenCalled(); + expect(wasLastNotifiedStatusStored()).toBe(false); + }); + + it("uses a 6 hour cadence for domains that have never verified", async () => { + const domain = createDomain({ status: DomainStatus.PENDING }); + mockRedis.mget.mockResolvedValue([ + new Date( + Date.now() - DOMAIN_UNVERIFIED_RECHECK_MS + 5 * 60 * 1000, + ).toISOString(), + null, + null, + ]); + + await expect(isDomainVerificationDue(domain)).resolves.toBe(false); + + mockRedis.mget.mockResolvedValue([ + new Date( + Date.now() - DOMAIN_UNVERIFIED_RECHECK_MS - 5 * 60 * 1000, + ).toISOString(), + null, + null, + ]); + + await expect(isDomainVerificationDue(domain)).resolves.toBe(true); + }); + + it("uses a 30 day cadence after a domain has been verified", async () => { + const domain = createDomain({ status: DomainStatus.FAILED }); + mockRedis.mget.mockResolvedValue([ + new Date( + Date.now() - DOMAIN_VERIFIED_RECHECK_MS + 5 * 60 * 1000, + ).toISOString(), + DomainStatus.SUCCESS, + "1", + ]); + + await expect(isDomainVerificationDue(domain)).resolves.toBe(false); + + mockRedis.mget.mockResolvedValue([ + new Date( + Date.now() - DOMAIN_VERIFIED_RECHECK_MS - 5 * 60 * 1000, + ).toISOString(), + DomainStatus.SUCCESS, + "1", + ]); + + await expect(isDomainVerificationDue(domain)).resolves.toBe(true); + }); + + it("stops automatic retries after an initial terminal failure", async () => { + const domain = createDomain({ + status: DomainStatus.FAILED, + isVerifying: false, + }); + mockRedis.mget.mockResolvedValue([ + new Date("2026-03-09T06:00:00.000Z").toISOString(), + DomainStatus.FAILED, + null, + ]); + + await expect(isDomainVerificationDue(domain)).resolves.toBe(false); + }); +});