diff --git a/lib/http-client.ts b/lib/http-client.ts new file mode 100644 index 0000000..d9d9fa2 --- /dev/null +++ b/lib/http-client.ts @@ -0,0 +1,120 @@ +/** + * 统一 HTTP 请求封装 + * 支持超时、重试、错误处理 + */ + +export interface RequestOptions { + method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + headers?: Record; + body?: unknown; + timeoutMs?: number; + retries?: number; + retryDelayMs?: number; +} + +export interface HttpResponse { + ok: boolean; + status: number; + data: T | null; + raw: Response; +} + +class HttpError extends Error { + constructor( + message: string, + public status?: number, + public data?: unknown, + ) { + super(message); + this.name = "HttpError"; + } +} + +async function parseResponse(response: Response): Promise { + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + try { + return await response.json(); + } catch { + return null; + } + } + return await response.text().catch(() => null); +} + +/** + * 发送 HTTP 请求 + */ +export async function httpRequest( + url: string, + options: RequestOptions = {}, +): Promise> { + const { + method = "GET", + headers = {}, + body, + timeoutMs = 15000, + retries = 0, + retryDelayMs = 1000, + } = options; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const fetchOptions: RequestInit = { + method, + headers: { + "Content-Type": "application/json", + "User-Agent": "edgeKey/1.0", + ...headers, + }, + signal: controller.signal, + }; + + if (body !== undefined && method !== "GET") { + fetchOptions.body = typeof body === "string" ? body : JSON.stringify(body); + } + + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const response = await fetch(url, fetchOptions); + clearTimeout(timeoutId); + + const data = await parseResponse(response) as T; + + return { + ok: response.ok, + status: response.status, + data, + raw: response, + }; + } catch (error: any) { + lastError = error; + + if (error.name === "AbortError") { + clearTimeout(timeoutId); + throw new HttpError(`请求超时 (${timeoutMs}ms)`, 408); + } + + if (attempt < retries) { + await new Promise((r) => setTimeout(r, retryDelayMs * (attempt + 1))); + } + } + } + + clearTimeout(timeoutId); + throw lastError || new HttpError("请求失败"); +} + +/** + * POST 请求快捷方法 + */ +export async function httpPost( + url: string, + body: unknown, + options: Omit = {}, +): Promise> { + return httpRequest(url, { ...options, method: "POST", body }); +} diff --git a/lib/validators/email.ts b/lib/validators/email.ts index e31010f..39dd0dd 100644 --- a/lib/validators/email.ts +++ b/lib/validators/email.ts @@ -41,7 +41,7 @@ export function validateEmailConfigInput(input: any) { if (provider === "API") { const apiProvider = input.apiProvider?.trim().toUpperCase() || ""; - if (!["BREVO", "MAILJET"].includes(apiProvider)) { + if (!["BREVO", "RESEND"].includes(apiProvider)) { throw badRequestError("API 服务商不正确", "EMAIL_API_PROVIDER_INVALID"); } @@ -54,14 +54,10 @@ export function validateEmailConfigInput(input: any) { throw badRequestError("必须填写 API Key", "EMAIL_API_KEY_REQUIRED"); } - if (apiProvider === "MAILJET" && !(input.secretKey?.trim())) { - throw badRequestError("Mailjet 必须填写 Secret Key", "MAILJET_SECRET_REQUIRED"); - } - return { provider: "API" as const, fromEmail, - apiProvider: apiProvider as "BREVO" | "MAILJET", + apiProvider: apiProvider, apiBaseUrl, ...flags, }; diff --git a/modules/email/provider.ts b/modules/email/provider.ts index ec7ff6f..907e5c5 100644 --- a/modules/email/provider.ts +++ b/modules/email/provider.ts @@ -1,18 +1,11 @@ import { badRequestError, externalServiceError } from "../../lib/app-error"; +import { httpPost } from "../../lib/http-client"; import type { EmailApiConfigValue, EmailProviderAdapter, EmailSendInput, EmailSmtpConfigValue } from "./types"; function normalizeBaseUrl(value: string) { return value.replace(/\/+$/, ""); } -async function parseJsonSafely(response: Response) { - try { - return (await response.json()) as Record; - } catch { - return null; - } -} - function buildBrevoPayload(config: EmailApiConfigValue, input: EmailSendInput) { return { sender: { @@ -27,21 +20,18 @@ function buildBrevoPayload(config: EmailApiConfigValue, input: EmailSendInput) { }; } -function buildMailjetPayload(config: EmailApiConfigValue, input: EmailSendInput) { +function buildResendPayload(config: EmailApiConfigValue, input: EmailSendInput) { + const from = config.fromName + ? `${config.fromName} <${config.fromEmail}>` + : config.fromEmail; + return { - Messages: [ - { - From: { - Email: config.fromEmail, - Name: config.fromName || "API Mail", - }, - To: [{ Email: input.toEmail }], - ReplyTo: input.replyTo || config.replyTo ? { Email: input.replyTo || config.replyTo } : undefined, - Subject: input.subject, - TextPart: input.text, - HTMLPart: input.html || `
${input.text.replace(/[&<>]/g, (char) => ({ "&": "&", "<": "<", ">": ">" }[char] || char))}
`, - }, - ], + from, + to: [input.toEmail], + reply_to: input.replyTo || config.replyTo || undefined, + subject: input.subject, + html: input.html || `
${input.text.replace(/[&<>]/g, (char) => ({ "&": "&", "<": "<", ">": ">" }[char] || char))}
`, + text: input.text, }; } @@ -50,55 +40,50 @@ async function sendBrevoEmail(config: EmailApiConfigValue, input: EmailSendInput throw badRequestError("Brevo 配置不完整", "BREVO_CONFIG_INCOMPLETE"); } - const response = await fetch(normalizeBaseUrl(config.apiBaseUrl), { - method: "POST", - headers: { - accept: "application/json", - "content-type": "application/json", - "api-key": config.apiKey, + const { ok, status, data } = await httpPost>( + normalizeBaseUrl(config.apiBaseUrl), + buildBrevoPayload(config, input), + { + headers: { "api-key": config.apiKey }, + timeoutMs: config.timeoutMs || 15000, }, - body: JSON.stringify(buildBrevoPayload(config, input)), - }); + ); - const json = await parseJsonSafely(response); - if (!response.ok) { - throw externalServiceError(typeof json?.message === "string" ? json.message : "Brevo 发送邮件失败", "BREVO_SEND_FAILED"); + if (!ok) { + const message = typeof data?.message === "string" ? data.message : "Brevo 发送邮件失败"; + throw externalServiceError(message, "BREVO_SEND_FAILED"); } return { - messageId: typeof json?.messageId === "string" ? json.messageId : undefined, - raw: json, + messageId: typeof data?.messageId === "string" ? data.messageId : undefined, + raw: data, }; } -async function sendMailjetEmail(config: EmailApiConfigValue, input: EmailSendInput) { - if (!config.apiBaseUrl || !config.apiKey || !config.secretKey) { - throw badRequestError("Mailjet 配置不完整", "MAILJET_CONFIG_INCOMPLETE"); +async function sendResendEmail(config: EmailApiConfigValue, input: EmailSendInput) { + if (!config.apiKey) { + throw badRequestError("Resend 配置不完整", "RESEND_CONFIG_INCOMPLETE"); } - const token = btoa(`${config.apiKey}:${config.secretKey}`); - const response = await fetch(normalizeBaseUrl(config.apiBaseUrl), { - method: "POST", - headers: { - accept: "application/json", - "content-type": "application/json", - authorization: `Basic ${token}`, + const apiBaseUrl = config.apiBaseUrl || "https://api.resend.com"; + + const { ok, status, data } = await httpPost>( + `${normalizeBaseUrl(apiBaseUrl)}/emails`, + buildResendPayload(config, input), + { + headers: { Authorization: `Bearer ${config.apiKey}` }, + timeoutMs: config.timeoutMs || 15000, }, - body: JSON.stringify(buildMailjetPayload(config, input)), - }); + ); - const json = await parseJsonSafely(response); - if (!response.ok) { - throw externalServiceError(typeof json?.ErrorMessage === "string" ? json.ErrorMessage : "Mailjet 发送邮件失败", "MAILJET_SEND_FAILED"); + if (!ok) { + const message = typeof data?.message === "string" ? data.message : "Resend 发送邮件失败"; + throw externalServiceError(message, "RESEND_SEND_FAILED"); } - const messages = Array.isArray(json?.Messages) ? json.Messages : []; - const firstMessage = messages[0] as { To?: Array<{ MessageID?: number }> } | undefined; - const messageId = firstMessage?.To?.[0]?.MessageID; - return { - messageId: typeof messageId === "number" ? String(messageId) : undefined, - raw: json, + messageId: typeof data?.id === "string" ? data.id : undefined, + raw: data, }; } @@ -136,13 +121,15 @@ export function createSmtpEmailAdapter(config: EmailSmtpConfigValue): EmailProvi } export function createApiEmailAdapter(config: EmailApiConfigValue): EmailProviderAdapter { + const senders: Record Promise<{ messageId?: string; raw?: unknown }>> = { + BREVO: sendBrevoEmail, + RESEND: sendResendEmail, + }; + return { async send(input) { - if (config.apiProvider === "BREVO") { - return sendBrevoEmail(config, input); - } - - return sendMailjetEmail(config, input); + const sender = senders[config.apiProvider] || sendBrevoEmail; + return sender(config, input); }, }; } diff --git a/modules/email/service.ts b/modules/email/service.ts index 0c1b9aa..f3fe3cf 100644 --- a/modules/email/service.ts +++ b/modules/email/service.ts @@ -60,7 +60,6 @@ const defaultApiConfig: EmailApiConfigValue = { replyTo: "", apiBaseUrl: "https://api.brevo.com/v3/smtp/email", apiKey: "", - secretKey: "", timeoutMs: 10000, ...defaultPushSettings, }; @@ -187,7 +186,7 @@ function buildHtmlContent(text: string) { async function createLog(prisma: PrismaClient, input: { orderId?: number; provider: EmailChannel; - apiProvider?: "BREVO" | "MAILJET" | null; + apiProvider?: string | null; scene: EmailScene; status: "SUCCESS" | "FAILED"; toEmail: string; @@ -392,7 +391,7 @@ export async function getEmailManagementData(prisma?: PrismaClient) { const logs: EmailLogItem[] = logRecords.map((item: EmailLogRecord) => ({ id: item.id, provider: item.provider as EmailChannel, - apiProvider: item.apiProvider as "BREVO" | "MAILJET" | null, + apiProvider: item.apiProvider as string | null, scene: item.scene as EmailScene, status: item.status, toEmail: item.toEmail, @@ -509,7 +508,6 @@ function buildConfigJson(input: EmailConfigValue): Record { replyTo: apiInput.replyTo?.trim() || "", apiBaseUrl: apiInput.apiBaseUrl.trim(), apiKey: apiInput.apiKey?.trim() || "", - secretKey: apiInput.secretKey?.trim() || "", timeoutMs: Number(apiInput.timeoutMs || 10000), customerSendOrderPaidEmail: Boolean(apiInput.customerSendOrderPaidEmail), customerSendDeliverySuccessEmail: Boolean(apiInput.customerSendDeliverySuccessEmail), diff --git a/modules/email/types.ts b/modules/email/types.ts index 626555d..102fd7c 100644 --- a/modules/email/types.ts +++ b/modules/email/types.ts @@ -1,6 +1,6 @@ export type EmailChannel = "API" | "SMTP" | "CLOUDFLARE"; -export type EmailApiProvider = "BREVO" | "MAILJET"; +export type EmailApiProvider = string; export type EmailScene = "TEST" | "ORDER_PAID" | "DELIVERY_SUCCESS" | "DELIVERY_FAILED"; @@ -24,7 +24,6 @@ export interface EmailApiConfigValue extends EmailPushFlags { replyTo?: string; apiBaseUrl: string; apiKey?: string; - secretKey?: string; timeoutMs?: number; } diff --git a/pages/admin/email/+Page.vue b/pages/admin/email/+Page.vue index 5568554..bf4c960 100644 --- a/pages/admin/email/+Page.vue +++ b/pages/admin/email/+Page.vue @@ -152,7 +152,7 @@ @@ -173,7 +173,7 @@ API 服务商 -