From fba4599bdfdd15d887f282ec4e0f993681497ede Mon Sep 17 00:00:00 2001 From: KMKoushik Date: Mon, 9 Mar 2026 09:59:44 +0000 Subject: [PATCH 1/5] feat: automate domain verification follow-ups --- apps/web/src/instrumentation.ts | 4 +- .../DomainVerificationStatusEmail.tsx | 122 ++++++ apps/web/src/server/email-templates/index.ts | 4 + .../server/jobs/domain-verification-job.ts | 76 ++++ .../jobs/domain-verification-job.unit.test.ts | 97 +++++ apps/web/src/server/queue/queue-constants.ts | 1 + apps/web/src/server/service/domain-service.ts | 390 +++++++++++++++--- .../service/domain-service.unit.test.ts | 301 ++++++++++++++ 8 files changed, 935 insertions(+), 60 deletions(-) create mode 100644 apps/web/src/server/email-templates/DomainVerificationStatusEmail.tsx create mode 100644 apps/web/src/server/jobs/domain-verification-job.ts create mode 100644 apps/web/src/server/jobs/domain-verification-job.unit.test.ts create mode 100644 apps/web/src/server/service/domain-service.unit.test.ts diff --git a/apps/web/src/instrumentation.ts b/apps/web/src/instrumentation.ts index 8cade36f..e7d08b12 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 { isCloud, isEmailCleanupEnabled } from "./utils/common"; let initialized = false; @@ -25,6 +25,8 @@ export async function register() { await import("~/server/jobs/usage-job"); } + await import("~/server/jobs/domain-verification-job"); + 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..0f0f6bfa --- /dev/null +++ b/apps/web/src/server/email-templates/DomainVerificationStatusEmail.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { Container, Text } from "jsx-email"; +import { render } from "jsx-email"; +import { DomainStatus } from "@prisma/client"; +import { EmailLayout } from "./components/EmailLayout"; +import { EmailHeader } from "./components/EmailHeader"; +import { EmailFooter } from "./components/EmailFooter"; +import { EmailButton } from "./components/EmailButton"; + +interface DomainVerificationStatusEmailProps { + domainName: string; + currentStatus: DomainStatus; + previousStatus: DomainStatus; + verificationError?: string | null; + domainUrl: string; +} + +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 preview = `${domainName} is now ${currentStatus.toLowerCase().replaceAll("_", " ")}`; + + return ( + + + + + + {domainName} is currently {currentStatus}. + + + {previousStatus !== currentStatus ? ( + + Previous status: {previousStatus} + + ) : null} + + {verificationError ? ( + + + Verification error: {verificationError} + + + ) : null} + + + Review the DNS records in useSend to make sure the domain stays ready + to send. + + + + Open domain settings + + + + + + ); +} + +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..c6f7f4a2 --- /dev/null +++ b/apps/web/src/server/jobs/domain-verification-job.ts @@ -0,0 +1,76 @@ +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"; + +const domainVerificationQueue = new Queue(DOMAIN_VERIFICATION_QUEUE, { + connection: getRedis(), + prefix: BULL_PREFIX, + skipVersionCheck: true, +}); + +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", + ); + } + } +} + +const worker = new Worker( + DOMAIN_VERIFICATION_QUEUE, + async () => { + await runDueDomainVerifications(); + }, + { + connection: getRedis(), + 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"); +}); 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..aff94335 --- /dev/null +++ b/apps/web/src/server/jobs/domain-verification-job.unit.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DomainStatus, type Domain } from "@prisma/client"; + +const { + mockFindMany, + mockIsDomainVerificationDue, + mockRefreshDomainVerification, + mockQueue, + mockWorker, +} = vi.hoisted(() => ({ + mockFindMany: vi.fn(), + mockIsDomainVerificationDue: vi.fn(), + mockRefreshDomainVerification: vi.fn(), + mockQueue: vi.fn().mockImplementation(() => ({ + upsertJobScheduler: vi.fn(), + })), + mockWorker: vi.fn().mockImplementation(() => ({ + on: vi.fn(), + })), +})); + +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 { 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(); + }); + + 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); + }); +}); 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..676464e3 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -3,6 +3,7 @@ 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 { SesSettingsService } from "./ses-settings-service"; import { UnsendApiError } from "../public-api/api-error"; import { logger } from "../logger/log"; @@ -14,8 +15,30 @@ import { import { LimitService } from "./limit-service"; import type { DomainDnsRecord } from "~/types/domain"; import { WebhookService } from "./webhook-service"; +import { getRedis, redisKey } from "../redis"; +import { sendMail } from "../mailer"; +import { renderDomainVerificationStatusEmail } from "../email-templates"; 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,177 @@ 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 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({ + currentStatus, + hasEverVerified, + lastNotifiedStatus, +}: { + currentStatus: DomainStatus; + hasEverVerified: boolean; + lastNotifiedStatus: DomainStatus | null; +}) { + 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 textLines = [ + `Domain: ${domain.name}`, + `Current status: ${domain.status}`, + previousStatus !== domain.status + ? `Previous status: ${previousStatus}` + : null, + verificationError ? `Verification error: ${verificationError}` : null, + `Manage domain: ${domainUrl}`, + ].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 +442,124 @@ 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); + + if (updatedDomain.status === DomainStatus.SUCCESS) { + await markDomainEverVerified(domain.id); + } + + if ( + shouldSendDomainStatusNotification({ + currentStatus: updatedDomain.status, + hasEverVerified: + verificationState.hasEverVerified || + updatedDomain.status === DomainStatus.SUCCESS, + lastNotifiedStatus: verificationState.lastNotifiedStatus, + }) + ) { + await sendDomainStatusNotification({ + domain: updatedDomain, + previousStatus, + verificationError, }); + await setLastNotifiedDomainStatus(domain.id, updatedDomain.status); + } - const normalizedDomain = { - ...domain, - dkimStatus: dkimStatus?.toString() ?? null, - spfDetails: spfDetails?.toString() ?? null, - dmarcAdded: dmarcRecord ? true : false, - } satisfies Domain; - - const domainWithDns = withDnsRecords(normalizedDomain); - const normalizedLastCheckedTime = - lastCheckedTime instanceof Date - ? lastCheckedTime.toISOString() - : (lastCheckedTime ?? null); - - const response = { - ...domainWithDns, - dkimStatus: normalizedDomain.dkimStatus, - spfDetails: normalizedDomain.spfDetails, - verificationError: verificationError?.toString() ?? null, - lastCheckedTime: normalizedLastCheckedTime, - dmarcAdded: normalizedDomain.dmarcAdded, - }; - - if (previousStatus !== domainWithDns.status) { - const eventType: DomainWebhookEventType = - domainWithDns.status === DomainStatus.SUCCESS - ? "domain.verified" - : "domain.updated"; - await emitDomainEvent(domainWithDns, eventType); - } - - return response; + 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 +596,7 @@ export async function deleteDomain(id: number) { } const deletedRecord = await db.domain.delete({ where: { id } }); + await clearDomainVerificationState(id); await emitDomainEvent(domain, "domain.deleted"); @@ -396,3 +642,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..19152c75 --- /dev/null +++ b/apps/web/src/server/service/domain-service.unit.test.ts @@ -0,0 +1,301 @@ +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(), +})); + +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

", + ); + 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 one success email 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(mockRedis.set).toHaveBeenCalledWith( + "domain:verification:last-notified-status:42", + DomainStatus.SUCCESS, + ); + 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("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); + }); +}); From 5d5d50eac04b19aa7fec8cc97323f65ce015c479 Mon Sep 17 00:00:00 2001 From: KMKoushik Date: Mon, 9 Mar 2026 20:48:43 +0000 Subject: [PATCH 2/5] fix: harden domain verification notifications --- apps/web/src/instrumentation.ts | 8 +- .../DomainVerificationStatusEmail.tsx | 8 +- .../server/jobs/domain-verification-job.ts | 78 +++++++++------- .../jobs/domain-verification-job.unit.test.ts | 32 ++++++- apps/web/src/server/service/domain-service.ts | 49 ++++++++--- .../service/domain-service.unit.test.ts | 88 ++++++++++++++++++- 6 files changed, 207 insertions(+), 56 deletions(-) diff --git a/apps/web/src/instrumentation.ts b/apps/web/src/instrumentation.ts index e7d08b12..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,7 +25,9 @@ export async function register() { await import("~/server/jobs/usage-job"); } - await import("~/server/jobs/domain-verification-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 index 0f0f6bfa..23999d7a 100644 --- a/apps/web/src/server/email-templates/DomainVerificationStatusEmail.tsx +++ b/apps/web/src/server/email-templates/DomainVerificationStatusEmail.tsx @@ -2,10 +2,10 @@ import React from "react"; import { Container, Text } from "jsx-email"; import { render } from "jsx-email"; import { DomainStatus } from "@prisma/client"; -import { EmailLayout } from "./components/EmailLayout"; -import { EmailHeader } from "./components/EmailHeader"; -import { EmailFooter } from "./components/EmailFooter"; -import { EmailButton } from "./components/EmailButton"; +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; diff --git a/apps/web/src/server/jobs/domain-verification-job.ts b/apps/web/src/server/jobs/domain-verification-job.ts index c6f7f4a2..a83a9951 100644 --- a/apps/web/src/server/jobs/domain-verification-job.ts +++ b/apps/web/src/server/jobs/domain-verification-job.ts @@ -11,11 +11,7 @@ import { refreshDomainVerification, } from "~/server/service/domain-service"; -const domainVerificationQueue = new Queue(DOMAIN_VERIFICATION_QUEUE, { - connection: getRedis(), - prefix: BULL_PREFIX, - skipVersionCheck: true, -}); +let initialized = false; export async function runDueDomainVerifications() { const domains = await db.domain.findMany({ @@ -41,36 +37,54 @@ export async function runDueDomainVerifications() { } } -const worker = new Worker( - DOMAIN_VERIFICATION_QUEUE, - async () => { - await runDueDomainVerifications(); - }, - { - connection: getRedis(), - concurrency: 1, +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, + 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("completed", (job) => { - logger.info({ jobId: job.id }, "[DomainVerificationJob]: Job completed"); -}); + worker.on("failed", (job, err) => { + logger.error( + { err, jobId: job?.id }, + "[DomainVerificationJob]: Job failed", + ); + }); -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 index aff94335..55c260ef 100644 --- a/apps/web/src/server/jobs/domain-verification-job.unit.test.ts +++ b/apps/web/src/server/jobs/domain-verification-job.unit.test.ts @@ -5,17 +5,21 @@ 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: vi.fn(), + upsertJobScheduler: mockUpsertJobScheduler, })), mockWorker: vi.fn().mockImplementation(() => ({ - on: vi.fn(), + on: mockWorkerOn, })), })); @@ -49,7 +53,10 @@ vi.mock("~/server/service/domain-service", () => ({ refreshDomainVerification: mockRefreshDomainVerification, })); -import { runDueDomainVerifications } from "~/server/jobs/domain-verification-job"; +import { + initDomainVerificationJob, + runDueDomainVerifications, +} from "~/server/jobs/domain-verification-job"; function createDomain(id: number, status: DomainStatus): Domain { return { @@ -79,6 +86,16 @@ describe("domain-verification-job", () => { 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 () => { @@ -94,4 +111,13 @@ describe("domain-verification-job", () => { 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/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index 676464e3..2fd7cf76 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -4,9 +4,12 @@ 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, @@ -15,9 +18,6 @@ import { import { LimitService } from "./limit-service"; import type { DomainDnsRecord } from "~/types/domain"; import { WebhookService } from "./webhook-service"; -import { getRedis, redisKey } from "../redis"; -import { sendMail } from "../mailer"; -import { renderDomainVerificationStatusEmail } from "../email-templates"; const DOMAIN_STATUS_VALUES = new Set(Object.values(DomainStatus)); export const DOMAIN_UNVERIFIED_RECHECK_MS = 6 * 60 * 60 * 1000; @@ -172,6 +172,21 @@ async function setLastNotifiedDomainStatus( ); } +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), @@ -516,12 +531,26 @@ export async function refreshDomainVerification( lastNotifiedStatus: verificationState.lastNotifiedStatus, }) ) { - await sendDomainStatusNotification({ - domain: updatedDomain, - previousStatus, - verificationError, - }); - await setLastNotifiedDomainStatus(domain.id, updatedDomain.status); + 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 = { diff --git a/apps/web/src/server/service/domain-service.unit.test.ts b/apps/web/src/server/service/domain-service.unit.test.ts index 19152c75..f9d080a6 100644 --- a/apps/web/src/server/service/domain-service.unit.test.ts +++ b/apps/web/src/server/service/domain-service.unit.test.ts @@ -31,6 +31,12 @@ const { 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, @@ -115,6 +121,7 @@ describe("domain-service", () => { mockRenderDomainVerificationStatusEmail.mockResolvedValue( "

domain status

", ); + mockRedis.set.mockResolvedValue("OK"); mockDb.teamUser.findMany.mockResolvedValue([ { user: { email: "alice@example.com" } }, { user: { email: "bob@example.com" } }, @@ -160,10 +167,7 @@ describe("domain-service", () => { }), ); expect(mockSendMail).toHaveBeenCalledTimes(2); - expect(mockRedis.set).toHaveBeenCalledWith( - "domain:verification:last-notified-status:42", - DomainStatus.SUCCESS, - ); + expect(wasLastNotifiedStatusStored()).toBe(true); expect(result.status).toBe(DomainStatus.SUCCESS); expect(result.hasEverVerified).toBe(true); }); @@ -239,6 +243,82 @@ describe("domain-service", () => { expect(mockSendMail).not.toHaveBeenCalled(); }); + 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([ From e82d2b5957a9bc915798dab011af042b297043ab Mon Sep 17 00:00:00 2001 From: KMKoushik Date: Fri, 13 Mar 2026 14:29:14 +0100 Subject: [PATCH 3/5] fix: skip unchanged first-run domain status emails --- apps/web/src/server/service/domain-service.ts | 7 ++++ .../service/domain-service.unit.test.ts | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index 2fd7cf76..de8a251c 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -212,14 +212,20 @@ function shouldContinueVerifying( } 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; } @@ -524,6 +530,7 @@ export async function refreshDomainVerification( if ( shouldSendDomainStatusNotification({ + previousStatus, currentStatus: updatedDomain.status, hasEverVerified: verificationState.hasEverVerified || diff --git a/apps/web/src/server/service/domain-service.unit.test.ts b/apps/web/src/server/service/domain-service.unit.test.ts index f9d080a6..8f39e1c5 100644 --- a/apps/web/src/server/service/domain-service.unit.test.ts +++ b/apps/web/src/server/service/domain-service.unit.test.ts @@ -243,6 +243,39 @@ describe("domain-service", () => { 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]); From 9a7197304590c931df87c731e7f2a18e0aaf20d3 Mon Sep 17 00:00:00 2001 From: KMKoushik Date: Fri, 13 Mar 2026 14:42:48 +0100 Subject: [PATCH 4/5] fix: make domain cleanup resilient and status labels readable --- .../DomainVerificationStatusEmail.tsx | 12 +++++++++--- apps/web/src/server/service/domain-service.ts | 9 ++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/web/src/server/email-templates/DomainVerificationStatusEmail.tsx b/apps/web/src/server/email-templates/DomainVerificationStatusEmail.tsx index 23999d7a..2eceb786 100644 --- a/apps/web/src/server/email-templates/DomainVerificationStatusEmail.tsx +++ b/apps/web/src/server/email-templates/DomainVerificationStatusEmail.tsx @@ -15,6 +15,10 @@ interface DomainVerificationStatusEmailProps { domainUrl: string; } +function formatDomainStatus(status: DomainStatus) { + return status.toLowerCase().replaceAll("_", " "); +} + function getTitle(currentStatus: DomainStatus, previousStatus: DomainStatus) { if (currentStatus === DomainStatus.SUCCESS) { return previousStatus === DomainStatus.SUCCESS @@ -36,7 +40,7 @@ export function DomainVerificationStatusEmail({ verificationError, domainUrl, }: DomainVerificationStatusEmailProps) { - const preview = `${domainName} is now ${currentStatus.toLowerCase().replaceAll("_", " ")}`; + const preview = `${domainName} is now ${formatDomainStatus(currentStatus)}`; return ( @@ -52,7 +56,8 @@ export function DomainVerificationStatusEmail({ textAlign: "left" as const, }} > - {domainName} is currently {currentStatus}. + {domainName} is currently{" "} + {formatDomainStatus(currentStatus)}. {previousStatus !== currentStatus ? ( @@ -65,7 +70,8 @@ export function DomainVerificationStatusEmail({ textAlign: "left" as const, }} > - Previous status: {previousStatus} + Previous status:{" "} + {formatDomainStatus(previousStatus)} ) : null} diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index de8a251c..1ef0bf80 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -632,7 +632,14 @@ export async function deleteDomain(id: number) { } const deletedRecord = await db.domain.delete({ where: { id } }); - await clearDomainVerificationState(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"); From 836dd674bf5d0b925be6d8da2530b3a8d6adc4c3 Mon Sep 17 00:00:00 2001 From: KMKoushik Date: Fri, 13 Mar 2026 15:16:17 +0100 Subject: [PATCH 5/5] fix: clarify domain verification notification messaging --- .../DomainVerificationStatusEmail.tsx | 43 +++++++++++++++---- apps/web/src/server/service/domain-service.ts | 18 +++++--- .../service/domain-service.unit.test.ts | 2 +- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/apps/web/src/server/email-templates/DomainVerificationStatusEmail.tsx b/apps/web/src/server/email-templates/DomainVerificationStatusEmail.tsx index 2eceb786..356838bd 100644 --- a/apps/web/src/server/email-templates/DomainVerificationStatusEmail.tsx +++ b/apps/web/src/server/email-templates/DomainVerificationStatusEmail.tsx @@ -40,6 +40,7 @@ export function DomainVerificationStatusEmail({ verificationError, domainUrl, }: DomainVerificationStatusEmailProps) { + const isSuccess = currentStatus === DomainStatus.SUCCESS; const preview = `${domainName} is now ${formatDomainStatus(currentStatus)}`; return ( @@ -56,11 +57,10 @@ export function DomainVerificationStatusEmail({ textAlign: "left" as const, }} > - {domainName} is currently{" "} - {formatDomainStatus(currentStatus)}. + Hey, - {previousStatus !== currentStatus ? ( + {isSuccess ? ( - Previous status:{" "} - {formatDomainStatus(previousStatus)} + Your domain {domainName} is now verified, and you + can start sending emails. - ) : null} + ) : ( + + 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 ? ( - Review the DNS records in useSend to make sure the domain stays ready - to send. + Open your domain settings to review records and verification details. Open domain settings + + + Thanks, +
+ useSend Team +
diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index 1ef0bf80..0226633d 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -285,14 +285,20 @@ async function sendDomainStatusNotification({ 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 = [ - `Domain: ${domain.name}`, - `Current status: ${domain.status}`, - previousStatus !== domain.status - ? `Previous status: ${previousStatus}` - : null, + "Hey,", + null, + statusMessage, verificationError ? `Verification error: ${verificationError}` : null, - `Manage domain: ${domainUrl}`, + null, + `Open domain settings: ${domainUrl}`, + null, + "Thanks,", + "useSend Team", ].filter((value): value is string => Boolean(value)); await Promise.all( diff --git a/apps/web/src/server/service/domain-service.unit.test.ts b/apps/web/src/server/service/domain-service.unit.test.ts index 8f39e1c5..a2fbf78d 100644 --- a/apps/web/src/server/service/domain-service.unit.test.ts +++ b/apps/web/src/server/service/domain-service.unit.test.ts @@ -133,7 +133,7 @@ describe("domain-service", () => { ); }); - it("sends one success email when a new domain becomes verified", async () => { + 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({