feta: 添加resend渠道

This commit is contained in:
ggyy
2026-05-21 22:14:41 +08:00
parent e052d29782
commit 99f4b4f52e
7 changed files with 197 additions and 94 deletions

120
lib/http-client.ts Normal file
View File

@@ -0,0 +1,120 @@
/**
* 统一 HTTP 请求封装
* 支持超时、重试、错误处理
*/
export interface RequestOptions {
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
headers?: Record<string, string>;
body?: unknown;
timeoutMs?: number;
retries?: number;
retryDelayMs?: number;
}
export interface HttpResponse<T = unknown> {
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<unknown> {
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<T = unknown>(
url: string,
options: RequestOptions = {},
): Promise<HttpResponse<T>> {
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<T = unknown>(
url: string,
body: unknown,
options: Omit<RequestOptions, "method" | "body"> = {},
): Promise<HttpResponse<T>> {
return httpRequest<T>(url, { ...options, method: "POST", body });
}

View File

@@ -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,
};

View File

@@ -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<string, unknown>;
} 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 || `<html><body><pre>${input.text.replace(/[&<>]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[char] || char))}</pre></body></html>`,
},
],
from,
to: [input.toEmail],
reply_to: input.replyTo || config.replyTo || undefined,
subject: input.subject,
html: input.html || `<html><body><pre>${input.text.replace(/[&<>]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[char] || char))}</pre></body></html>`,
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<Record<string, unknown>>(
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<Record<string, unknown>>(
`${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<string, (config: EmailApiConfigValue, input: EmailSendInput) => 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);
},
};
}

View File

@@ -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<string, unknown> {
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),

View File

@@ -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;
}

View File

@@ -152,7 +152,7 @@
<!-- 名称 -->
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">邮局名称 (可选留空自动生成)</span>
<input v-model="configForm.name" class="input input-bordered w-full" placeholder="例如:Mailjet 主账号" />
<input v-model="configForm.name" class="input input-bordered w-full" placeholder="例如:Resend 主账号" />
</label>
<!-- 类型选择 -->
@@ -173,7 +173,7 @@
<span class="label-text font-medium">API 服务商</span>
<select v-model="configForm.apiProvider" class="select select-bordered w-full">
<option value="BREVO">Brevo</option>
<option value="MAILJET">Mailjet</option>
<option value="RESEND">Resend</option>
</select>
</label>
<label class="flex flex-col gap-1.5">
@@ -190,15 +190,11 @@
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">API 地址</span>
<input v-model="configForm.apiBaseUrl" class="input input-bordered w-full" :placeholder="configForm.apiProvider === 'BREVO' ? 'https://api.brevo.com/v3/smtp/email' : 'https://api.mailjet.com/v3.1/send'" />
<input v-model="configForm.apiBaseUrl" class="input input-bordered w-full" :placeholder="configForm.apiProvider === 'BREVO' ? 'https://api.brevo.com/v3/smtp/email' : 'https://api.resend.com'" />
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">API Key</span>
<SecretInput v-model="configForm.apiKey" />
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">Secret Key</span>
<SecretInput v-model="configForm.secretKey" :disabled="configForm.apiProvider !== 'MAILJET'" :placeholder="configForm.apiProvider === 'MAILJET' ? 'Mailjet Secret Key' : 'Brevo 不需要该字段'" />
<SecretInput v-model="configForm.apiKey" :placeholder="configForm.apiProvider === 'RESEND' ? 're_xxxxxxxxx' : ''" />
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">超时(ms)</span>
@@ -477,7 +473,7 @@ import StatusTag from "../../../components/StatusTag.vue";
import ConfirmDialog from "../../../components/ConfirmDialog.vue";
import DataTable from "../../../components/DataTable.vue";
import { normalizeTelefuncError } from "../../../lib/app-error";
import { reactive, ref, computed, useTemplateRef } from "vue";
import { reactive, ref, computed, watch, useTemplateRef } from "vue";
import { useData } from "vike-vue/useData";
import { onSaveEmailConfig, onDeleteEmailConfig, onSaveEmailPushSettings, onActivateEmailProvider, onClearEmailLogs } from "./saveEmailConfig.telefunc";
import { onSaveEmailTemplate, onResetEmailTemplate } from "./saveEmailTemplate.telefunc";
@@ -497,7 +493,6 @@ type MailboxItem = {
apiProvider?: string;
apiBaseUrl?: string;
apiKey?: string;
secretKey?: string;
timeoutMs?: number;
// SMTP fields
smtpHost?: string;
@@ -590,10 +585,9 @@ interface ConfigFormState {
fromName: string;
replyTo: string;
// API
apiProvider: "BREVO" | "MAILJET";
apiProvider: string;
apiBaseUrl: string;
apiKey: string;
secretKey: string;
timeoutMs: number;
// SMTP
smtpHost: string;
@@ -618,7 +612,6 @@ function createEmptyForm(): ConfigFormState {
apiProvider: "BREVO",
apiBaseUrl: "https://api.brevo.com/v3/smtp/email",
apiKey: "",
secretKey: "",
timeoutMs: 10000,
smtpHost: "",
smtpPort: 587,
@@ -634,6 +627,23 @@ function createEmptyForm(): ConfigFormState {
const configForm = reactive<ConfigFormState>(createEmptyForm());
// API 服务商默认地址映射
const API_PROVIDER_URLS: Record<string, string> = {
BREVO: "https://api.brevo.com/v3/smtp/email",
RESEND: "https://api.resend.com",
};
// 切换 API 服务商时自动填充默认地址
watch(
() => configForm.apiProvider,
(provider) => {
const defaultUrl = API_PROVIDER_URLS[provider];
if (defaultUrl && (!configForm.apiBaseUrl || Object.values(API_PROVIDER_URLS).includes(configForm.apiBaseUrl))) {
configForm.apiBaseUrl = defaultUrl;
}
},
);
function openCreateDialog() {
editingId.value = null;
Object.assign(configForm, createEmptyForm());
@@ -653,7 +663,6 @@ function openEditDialog(item: MailboxItem) {
apiProvider: (item as any).apiProvider || "BREVO",
apiBaseUrl: (item as any).apiBaseUrl || "",
apiKey: (item as any).apiKey || "",
secretKey: (item as any).secretKey || "",
timeoutMs: (item as any).timeoutMs || 10000,
smtpHost: (item as any).smtpHost || "",
smtpPort: (item as any).smtpPort || 587,
@@ -796,7 +805,6 @@ async function handleSaveConfig() {
payload.apiProvider = configForm.apiProvider;
payload.apiBaseUrl = configForm.apiBaseUrl;
payload.apiKey = configForm.apiKey;
payload.secretKey = configForm.secretKey;
payload.timeoutMs = configForm.timeoutMs;
} else if (configForm.provider === "SMTP") {
payload.smtpHost = configForm.smtpHost;

View File

@@ -57,11 +57,6 @@ enum EmailChannel {
CLOUDFLARE
}
enum EmailApiProvider {
BREVO
MAILJET
}
enum EmailScene {
TEST
ORDER_PAID
@@ -329,7 +324,7 @@ model EmailLog {
id Int @id @default(autoincrement())
orderId Int?
provider EmailChannel
apiProvider EmailApiProvider?
apiProvider String?
scene EmailScene
status EmailSendStatus @default(SUCCESS)
toEmail String