mirror of
https://github.com/34892002/edgeKey.git
synced 2026-06-01 14:59:28 +08:00
feta: 添加resend渠道
This commit is contained in:
120
lib/http-client.ts
Normal file
120
lib/http-client.ts
Normal 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 });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">" }[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) => ({ "&": "&", "<": "<", ">": ">" }[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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user