Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion website/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
# RESEND_API_KEY=re_...
# RESEND_FROM_EMAIL="Docs Cloud <docs@notifications.example.com>"
# ENTERPRISE_SUPPORT_TO_EMAIL=support@example.com
# Optional: protects the enterprise support email worker when called outside Vercel Cron.
# Required for the enterprise support email retry worker.
# CRON_SECRET=...
# Optional: use a different secret for the enterprise support email worker.
# ENTERPRISE_SUPPORT_EMAIL_WORKER_SECRET=...

# Optional: switch docs search to Algolia for the website.
# DOCS_SEARCH_PROVIDER=algolia
Expand Down
170 changes: 8 additions & 162 deletions website/app/api/enterprise-support/email/process/route.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,15 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import {
sendEnterpriseSupportEmail,
type EnterpriseSupportEmailPayload,
} from "@/lib/enterprise-support-email";
import { prisma } from "@/lib/prisma";
getEnterpriseSupportEmailWorkerSecret,
processEnterpriseSupportEmailQueue,
readEnterpriseSupportEmailLimit,
} from "@/lib/enterprise-support-email-queue";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

const DEFAULT_LIMIT = 10;
const MAX_LIMIT = 25;

function getWorkerSecret() {
return (
process.env.ENTERPRISE_SUPPORT_EMAIL_WORKER_SECRET?.trim() || process.env.CRON_SECRET?.trim()
);
}

function isAuthorized(request: Request) {
const secret = getWorkerSecret();
const secret = getEnterpriseSupportEmailWorkerSecret();

if (!secret) {
return false;
Expand All @@ -28,66 +18,6 @@ function isAuthorized(request: Request) {
return request.headers.get("authorization") === `Bearer ${secret}`;
}

function readLimit(request: Request) {
const url = new URL(request.url);
const parsed = Number(url.searchParams.get("limit"));

if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_LIMIT;
}

return Math.min(Math.floor(parsed), MAX_LIMIT);
}

function nextAttemptDate(attempts: number) {
const minutes = Math.min(60, 2 ** Math.max(attempts - 1, 0) * 5);

return new Date(Date.now() + minutes * 60_000);
}

function truncateError(value?: string) {
return value ? value.slice(0, 1000) : "Unknown delivery error";
}

function optionalString(value: unknown) {
return typeof value === "string" && value.trim() ? value : undefined;
}

function readPayload(value: Prisma.JsonValue): EnterpriseSupportEmailPayload | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}

const data = value as Record<string, unknown>;
const supportNeeds = Array.isArray(data.supportNeeds)
? data.supportNeeds.filter((item): item is string => typeof item === "string")
: [];

if (
typeof data.email !== "string" ||
typeof data.company !== "string" ||
typeof data.submittedAt !== "string" ||
typeof data.origin !== "string" ||
typeof data.userAgent !== "string"
) {
return null;
}

return {
email: data.email,
company: data.company,
name: optionalString(data.name),
role: optionalString(data.role),
teamSize: optionalString(data.teamSize),
websiteUrl: optionalString(data.websiteUrl),
supportNeeds,
message: optionalString(data.message),
submittedAt: data.submittedAt,
origin: data.origin,
userAgent: data.userAgent,
};
}

async function processQueuedEmails(request: Request) {
if (!isAuthorized(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Expand All @@ -100,96 +30,12 @@ async function processQueuedEmails(request: Request) {
);
}

const limit = readLimit(request);
const now = new Date();
const candidates = await prisma.enterpriseSupportEmailNotification.findMany({
where: {
nextAttemptAt: { lte: now },
status: { in: ["PENDING", "FAILED"] },
},
orderBy: [{ nextAttemptAt: "asc" }, { createdAt: "asc" }],
take: limit * 3,
});
const notifications = candidates
.filter((notification) => notification.attempts < notification.maxAttempts)
.slice(0, limit);
let sent = 0;
let failed = 0;
let skipped = candidates.length - notifications.length;

for (const notification of notifications) {
const attempts = notification.attempts + 1;
const reservedUntil = nextAttemptDate(attempts);
const claim = await prisma.enterpriseSupportEmailNotification.updateMany({
where: {
id: notification.id,
attempts: notification.attempts,
nextAttemptAt: { lte: now },
status: { in: ["PENDING", "FAILED"] },
},
data: {
attempts,
nextAttemptAt: reservedUntil,
},
});

if (claim.count === 0) {
skipped += 1;
continue;
}

const payload = readPayload(notification.payload);

if (!payload) {
skipped += 1;
await prisma.enterpriseSupportEmailNotification.update({
where: { id: notification.id },
data: {
status: "EXHAUSTED",
lastError: "Notification payload is invalid.",
nextAttemptAt: reservedUntil,
},
});
continue;
}

const result = await sendEnterpriseSupportEmail(payload, notification.recipient).catch(
(error: unknown) => ({
sent: false,
error: error instanceof Error ? error.message : "Unknown email transport error",
}),
);

if (result.sent) {
sent += 1;
await prisma.enterpriseSupportEmailNotification.update({
where: { id: notification.id },
data: {
status: "SENT",
sentAt: new Date(),
lastError: null,
},
});
continue;
}

failed += 1;
await prisma.enterpriseSupportEmailNotification.update({
where: { id: notification.id },
data: {
status: attempts >= notification.maxAttempts ? "EXHAUSTED" : "FAILED",
lastError: truncateError(result.error),
nextAttemptAt: reservedUntil,
},
});
}
const limit = readEnterpriseSupportEmailLimit(request);
const result = await processEnterpriseSupportEmailQueue(limit);

return NextResponse.json({
ok: true,
processed: notifications.length,
sent,
failed,
skipped,
...result,
});
}

Expand Down
21 changes: 19 additions & 2 deletions website/app/api/enterprise-support/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { after, NextResponse } from "next/server";
import {
buildEnterpriseSupportEmail,
createEnterpriseSupportEmailPayload,
getEnterpriseSupportRecipient,
} from "@/lib/enterprise-support-email";
import { processEnterpriseSupportEmailNotification } from "@/lib/enterprise-support-email-queue";
import { prisma } from "@/lib/prisma";

export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -157,7 +158,7 @@ export async function POST(request: Request) {
"[enterprise support POST] Saved request but ENTERPRISE_SUPPORT_TO_EMAIL is not configured.",
);
} else {
await prisma.enterpriseSupportEmailNotification.create({
const notification = await prisma.enterpriseSupportEmailNotification.create({
data: {
requestId: entry.id,
recipient,
Expand All @@ -166,6 +167,22 @@ export async function POST(request: Request) {
},
});
queuedEmail = true;
after(async () => {
try {
const result = await processEnterpriseSupportEmailNotification(notification.id);

if (result.failed > 0) {
console.warn(
"[enterprise support POST] Queued email was processed but not delivered.",
);
}
} catch (deliveryError) {
console.warn(
"[enterprise support POST] Queued email could not be processed after save.",
deliveryError,
);
}
});
}
} catch (queueError) {
console.warn(
Expand Down
Loading
Loading