From 85f1909bb45fddd41eaf2513fc8ae6220bca41e0 Mon Sep 17 00:00:00 2001 From: Kinfe123 Date: Fri, 29 May 2026 17:10:51 +0300 Subject: [PATCH] fix: support email queue --- website/.env.example | 4 +- .../enterprise-support/email/process/route.ts | 170 +------------ website/app/api/enterprise-support/route.ts | 21 +- website/lib/enterprise-support-email-queue.ts | 230 ++++++++++++++++++ 4 files changed, 260 insertions(+), 165 deletions(-) create mode 100644 website/lib/enterprise-support-email-queue.ts diff --git a/website/.env.example b/website/.env.example index 9691b8f3..dbfba0c5 100644 --- a/website/.env.example +++ b/website/.env.example @@ -15,8 +15,10 @@ # RESEND_API_KEY=re_... # RESEND_FROM_EMAIL="Docs Cloud " # 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 diff --git a/website/app/api/enterprise-support/email/process/route.ts b/website/app/api/enterprise-support/email/process/route.ts index 290a50a0..c0611eeb 100644 --- a/website/app/api/enterprise-support/email/process/route.ts +++ b/website/app/api/enterprise-support/email/process/route.ts @@ -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; @@ -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; - 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 }); @@ -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, }); } diff --git a/website/app/api/enterprise-support/route.ts b/website/app/api/enterprise-support/route.ts index 3f86a963..79fc8b0b 100644 --- a/website/app/api/enterprise-support/route.ts +++ b/website/app/api/enterprise-support/route.ts @@ -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"; @@ -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, @@ -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( diff --git a/website/lib/enterprise-support-email-queue.ts b/website/lib/enterprise-support-email-queue.ts new file mode 100644 index 00000000..60a85590 --- /dev/null +++ b/website/lib/enterprise-support-email-queue.ts @@ -0,0 +1,230 @@ +import { Prisma, type EnterpriseSupportEmailNotification } from "@prisma/client"; +import { + sendEnterpriseSupportEmail, + type EnterpriseSupportEmailPayload, +} from "@/lib/enterprise-support-email"; +import { prisma } from "@/lib/prisma"; + +export const ENTERPRISE_SUPPORT_EMAIL_DEFAULT_LIMIT = 10; +export const ENTERPRISE_SUPPORT_EMAIL_MAX_LIMIT = 25; + +export type EnterpriseSupportEmailProcessResult = { + processed: number; + sent: number; + failed: number; + skipped: number; +}; + +export function getEnterpriseSupportEmailWorkerSecret() { + return ( + process.env.ENTERPRISE_SUPPORT_EMAIL_WORKER_SECRET?.trim() || process.env.CRON_SECRET?.trim() + ); +} + +export function readEnterpriseSupportEmailLimit(request: Request) { + const url = new URL(request.url); + const parsed = Number(url.searchParams.get("limit")); + + if (!Number.isFinite(parsed) || parsed <= 0) { + return ENTERPRISE_SUPPORT_EMAIL_DEFAULT_LIMIT; + } + + return Math.min(Math.floor(parsed), ENTERPRISE_SUPPORT_EMAIL_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; + 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, + }; +} + +function emptyResult(skipped = 0): EnterpriseSupportEmailProcessResult { + return { + processed: 0, + sent: 0, + failed: 0, + skipped, + }; +} + +async function processNotification( + notification: EnterpriseSupportEmailNotification, + now: Date, +): Promise { + if ( + notification.attempts >= notification.maxAttempts || + notification.nextAttemptAt > now || + !["PENDING", "FAILED"].includes(notification.status) + ) { + return emptyResult(1); + } + + 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) { + return emptyResult(1); + } + + const payload = readPayload(notification.payload); + + if (!payload) { + await prisma.enterpriseSupportEmailNotification.update({ + where: { id: notification.id }, + data: { + status: "EXHAUSTED", + lastError: "Notification payload is invalid.", + nextAttemptAt: reservedUntil, + }, + }); + return { + processed: 1, + sent: 0, + failed: 0, + skipped: 1, + }; + } + + 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) { + await prisma.enterpriseSupportEmailNotification.update({ + where: { id: notification.id }, + data: { + status: "SENT", + sentAt: new Date(), + lastError: null, + }, + }); + return { + processed: 1, + sent: 1, + failed: 0, + skipped: 0, + }; + } + + await prisma.enterpriseSupportEmailNotification.update({ + where: { id: notification.id }, + data: { + status: attempts >= notification.maxAttempts ? "EXHAUSTED" : "FAILED", + lastError: truncateError(result.error), + nextAttemptAt: reservedUntil, + }, + }); + + return { + processed: 1, + sent: 0, + failed: 1, + skipped: 0, + }; +} + +export async function processEnterpriseSupportEmailNotification( + notificationId: string, +): Promise { + const notification = await prisma.enterpriseSupportEmailNotification.findUnique({ + where: { id: notificationId }, + }); + + if (!notification) { + return emptyResult(1); + } + + return processNotification(notification, new Date()); +} + +export async function processEnterpriseSupportEmailQueue( + limit = ENTERPRISE_SUPPORT_EMAIL_DEFAULT_LIMIT, +): Promise { + 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); + const result: EnterpriseSupportEmailProcessResult = { + processed: 0, + sent: 0, + failed: 0, + skipped: candidates.length - notifications.length, + }; + + for (const notification of notifications) { + const current = await processNotification(notification, now); + + result.processed += current.processed; + result.sent += current.sent; + result.failed += current.failed; + result.skipped += current.skipped; + } + + return result; +}