Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/web/src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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");
}
Expand Down
122 changes: 122 additions & 0 deletions apps/web/src/server/email-templates/DomainVerificationStatusEmail.tsx
Original file line number Diff line number Diff line change
@@ -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 { 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 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("_", " ")}`;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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,
}}
>
{domainName} is currently <strong>{currentStatus}</strong>.
</Text>

{previousStatus !== currentStatus ? (
<Text
style={{
fontSize: "15px",
color: "#4b5563",
margin: "0 0 16px 0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
Previous status: <strong>{previousStatus}</strong>
</Text>
) : null}

{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,
}}
>
Review the DNS records in useSend to make sure the domain stays ready
to send.
</Text>

<Container style={{ margin: "0 0 32px 0", textAlign: "left" as const }}>
<EmailButton href={domainUrl}>Open domain settings</EmailButton>
</Container>
</Container>

<EmailFooter />
</EmailLayout>
);
}

export async function renderDomainVerificationStatusEmail(
props: DomainVerificationStatusEmailProps,
): Promise<string> {
return render(<DomainVerificationStatusEmail {...props} />);
}
4 changes: 4 additions & 0 deletions apps/web/src/server/email-templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export {
UsageLimitReachedEmail,
renderUsageLimitReachedEmail,
} from "./UsageLimitReachedEmail";
export {
DomainVerificationStatusEmail,
renderDomainVerificationStatusEmail,
} from "./DomainVerificationStatusEmail";

export * from "./components/EmailLayout";
export * from "./components/EmailHeader";
Expand Down
90 changes: 90 additions & 0 deletions apps/web/src/server/jobs/domain-verification-job.ts
Original file line number Diff line number Diff line change
@@ -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;
}
123 changes: 123 additions & 0 deletions apps/web/src/server/jobs/domain-verification-job.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 1 addition & 0 deletions apps/web/src/server/queue/queue-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Loading
Loading