mirror of
https://github.com/34892002/edgeKey.git
synced 2026-05-07 23:57:02 +08:00
feat(邮件): 支持多邮局配置管理
- 将邮件配置从单例改为多实例,支持创建、编辑、删除多个配置 - 修改数据库模型,移除 provider 唯一约束,添加索引 - 新增配置激活、删除、清空日志功能 - 重构服务层,统一推送设置同步逻辑 - 更新前端接口,支持配置 ID 参数传递
This commit is contained in:
@@ -139,7 +139,7 @@ export async function logAdminOperation(input: {
|
||||
const prisma = options?.prisma ?? context!.prisma;
|
||||
const adminId = options?.adminId ?? Number(context?.session?.user?.id);
|
||||
|
||||
if (!Number.isFinite(adminId)) {
|
||||
if (!Number.isFinite(adminId) || adminId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,34 +3,34 @@ import type { EmailApiProvider, EmailChannel, EmailScene } from "./types";
|
||||
|
||||
export function listEmailConfigRecords(prisma: PrismaClient) {
|
||||
return prisma.emailConfig.findMany({
|
||||
orderBy: [{ provider: "asc" }],
|
||||
orderBy: [{ id: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export function getEmailConfigRecord(prisma: PrismaClient, provider: EmailChannel) {
|
||||
export function getEmailConfigRecordById(prisma: PrismaClient, id: number) {
|
||||
return prisma.emailConfig.findUnique({
|
||||
where: { provider },
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export function upsertEmailConfigRecord(
|
||||
export function getActiveEmailConfigRecord(prisma: PrismaClient) {
|
||||
return prisma.emailConfig.findFirst({
|
||||
where: { isEnabled: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function createEmailConfigRecord(
|
||||
prisma: PrismaClient,
|
||||
provider: EmailChannel,
|
||||
input: {
|
||||
provider: EmailChannel;
|
||||
name: string;
|
||||
isEnabled: boolean;
|
||||
configJson: string;
|
||||
},
|
||||
) {
|
||||
return prisma.emailConfig.upsert({
|
||||
where: { provider },
|
||||
create: {
|
||||
provider,
|
||||
name: input.name,
|
||||
isEnabled: input.isEnabled,
|
||||
configJson: input.configJson,
|
||||
},
|
||||
update: {
|
||||
return prisma.emailConfig.create({
|
||||
data: {
|
||||
provider: input.provider,
|
||||
name: input.name,
|
||||
isEnabled: input.isEnabled,
|
||||
configJson: input.configJson,
|
||||
@@ -38,6 +38,74 @@ export function upsertEmailConfigRecord(
|
||||
});
|
||||
}
|
||||
|
||||
export function updateEmailConfigRecord(
|
||||
prisma: PrismaClient,
|
||||
id: number,
|
||||
input: {
|
||||
provider?: EmailChannel;
|
||||
name?: string;
|
||||
isEnabled?: boolean;
|
||||
configJson?: string;
|
||||
},
|
||||
) {
|
||||
return prisma.emailConfig.update({
|
||||
where: { id },
|
||||
data: input,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteEmailConfigRecord(prisma: PrismaClient, id: number) {
|
||||
return prisma.emailConfig.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function activateEmailConfigById(prisma: PrismaClient, id: number) {
|
||||
await prisma.emailConfig.updateMany({
|
||||
where: { id: { not: id } },
|
||||
data: { isEnabled: false },
|
||||
});
|
||||
return prisma.emailConfig.update({
|
||||
where: { id },
|
||||
data: { isEnabled: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updatePushFlagsForAllConfigs(
|
||||
prisma: PrismaClient,
|
||||
flags: {
|
||||
customerSendOrderPaidEmail: boolean;
|
||||
customerSendDeliverySuccessEmail: boolean;
|
||||
customerSendDeliveryFailedEmail: boolean;
|
||||
adminSendOrderPaidEmail: boolean;
|
||||
adminSendDeliverySuccessEmail: boolean;
|
||||
adminSendDeliveryFailedEmail: boolean;
|
||||
},
|
||||
) {
|
||||
const records = await prisma.emailConfig.findMany();
|
||||
for (const record of records) {
|
||||
let configJson: Record<string, unknown> = {};
|
||||
try {
|
||||
configJson = JSON.parse(record.configJson);
|
||||
} catch {
|
||||
configJson = {};
|
||||
}
|
||||
configJson.customerSendOrderPaidEmail = flags.customerSendOrderPaidEmail;
|
||||
configJson.customerSendDeliverySuccessEmail = flags.customerSendDeliverySuccessEmail;
|
||||
configJson.customerSendDeliveryFailedEmail = flags.customerSendDeliveryFailedEmail;
|
||||
configJson.adminSendOrderPaidEmail = flags.adminSendOrderPaidEmail;
|
||||
configJson.adminSendDeliverySuccessEmail = flags.adminSendDeliverySuccessEmail;
|
||||
configJson.adminSendDeliveryFailedEmail = flags.adminSendDeliveryFailedEmail;
|
||||
|
||||
await prisma.emailConfig.update({
|
||||
where: { id: record.id },
|
||||
data: { configJson: JSON.stringify(configJson) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function listEmailTemplateRecords(prisma: PrismaClient) {
|
||||
return prisma.emailTemplate.findMany({
|
||||
orderBy: [{ scene: "asc" }],
|
||||
@@ -108,4 +176,4 @@ export function createEmailLogRecord(
|
||||
triggeredBy: input.triggeredBy ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,37 @@ import { validateEmailConfigInput, validateEmailTemplateInput, validateTestEmail
|
||||
import { getAdminContext, logAdminOperation } from "../auth/service";
|
||||
import { getSiteSetting } from "../site/service";
|
||||
import { createApiEmailAdapter } from "./provider";
|
||||
import { createEmailLogRecord, getEmailConfigRecord, listEmailConfigRecords, listEmailLogRecords, listEmailTemplateRecords, upsertEmailConfigRecord, upsertEmailTemplateRecord } from "./repository";
|
||||
import type { EmailApiConfigValue, EmailChannel, EmailCloudflareConfigValue, EmailConfigValue, EmailLogItem, EmailOverviewMetric, EmailScene, EmailSmtpConfigValue, EmailTemplateValue } from "./types";
|
||||
import {
|
||||
activateEmailConfigById,
|
||||
createEmailConfigRecord,
|
||||
createEmailLogRecord,
|
||||
deleteEmailConfigRecord,
|
||||
getActiveEmailConfigRecord,
|
||||
getEmailConfigRecordById,
|
||||
listEmailConfigRecords,
|
||||
listEmailLogRecords,
|
||||
listEmailTemplateRecords,
|
||||
updateEmailConfigRecord,
|
||||
updatePushFlagsForAllConfigs,
|
||||
upsertEmailTemplateRecord,
|
||||
} from "./repository";
|
||||
import type {
|
||||
EmailApiConfigValue,
|
||||
EmailChannel,
|
||||
EmailCloudflareConfigValue,
|
||||
EmailConfigValue,
|
||||
EmailLogItem,
|
||||
EmailOverviewMetric,
|
||||
EmailScene,
|
||||
EmailSmtpConfigValue,
|
||||
EmailTemplateValue,
|
||||
} from "./types";
|
||||
|
||||
type EmailConfigRecord = Awaited<ReturnType<typeof listEmailConfigRecords>>[number];
|
||||
type EmailTemplateRecord = Awaited<ReturnType<typeof listEmailTemplateRecords>>[number];
|
||||
type EmailLogRecord = Awaited<ReturnType<typeof listEmailLogRecords>>[number];
|
||||
|
||||
const emailScenes = ["TEST", "ORDER_PAID", "DELIVERY_SUCCESS", "DELIVERY_FAILED"] as const;
|
||||
const emailChannels = ["API", "SMTP", "CLOUDFLARE"] as const;
|
||||
|
||||
function getChannelDisplayName(channel: EmailChannel) {
|
||||
return channel === "API" ? "API" : channel === "SMTP" ? "SMTP" : "CloudFlare";
|
||||
@@ -105,28 +127,30 @@ function getEmailContext() {
|
||||
}
|
||||
|
||||
function getDefaultConfig(provider: EmailChannel): EmailConfigValue {
|
||||
if (provider === "API") return defaultApiConfig;
|
||||
if (provider === "SMTP") return defaultSmtpConfig;
|
||||
return defaultCloudflareConfig;
|
||||
if (provider === "API") return { ...defaultApiConfig };
|
||||
if (provider === "SMTP") return { ...defaultSmtpConfig };
|
||||
return { ...defaultCloudflareConfig };
|
||||
}
|
||||
|
||||
function normalizeEmailConfig(record: Awaited<ReturnType<typeof getEmailConfigRecord>>, provider: EmailChannel): EmailConfigValue {
|
||||
function normalizeEmailConfigFromRecord(record: EmailConfigRecord): EmailConfigValue {
|
||||
const provider = record.provider as EmailChannel;
|
||||
const defaults = getDefaultConfig(provider);
|
||||
if (!record) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(record.configJson) as Partial<EmailConfigValue>;
|
||||
return {
|
||||
...defaults,
|
||||
...parsed,
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
provider,
|
||||
isEnabled: record.isEnabled,
|
||||
} as EmailConfigValue;
|
||||
} catch {
|
||||
return {
|
||||
...defaults,
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
isEnabled: record.isEnabled,
|
||||
} as EmailConfigValue;
|
||||
}
|
||||
@@ -198,14 +222,13 @@ async function createLog(prisma: PrismaClient, input: {
|
||||
});
|
||||
}
|
||||
|
||||
async function getActiveEmailConfig(prisma: PrismaClient) {
|
||||
const records = await listEmailConfigRecords(prisma);
|
||||
const activeRecord = records.find((record: EmailConfigRecord) => record.isEnabled);
|
||||
if (!activeRecord) {
|
||||
throw badRequestError("请先启用一个邮件发送分类", "EMAIL_CHANNEL_NOT_ENABLED");
|
||||
async function getActiveEmailConfig(prisma: PrismaClient): Promise<EmailConfigValue> {
|
||||
const record = await getActiveEmailConfigRecord(prisma);
|
||||
if (!record) {
|
||||
throw badRequestError("请先启用一个邮局配置", "EMAIL_CHANNEL_NOT_ENABLED");
|
||||
}
|
||||
|
||||
return normalizeEmailConfig(activeRecord, activeRecord.provider as EmailChannel);
|
||||
return normalizeEmailConfigFromRecord(record);
|
||||
}
|
||||
|
||||
async function getEmailBaseValues(prisma: PrismaClient) {
|
||||
@@ -259,8 +282,9 @@ async function sendSceneEmail(prisma: PrismaClient, input: {
|
||||
values: Record<string, string>;
|
||||
orderId?: number;
|
||||
triggeredBy?: string;
|
||||
config?: EmailConfigValue;
|
||||
}) {
|
||||
const config = await getActiveEmailConfig(prisma);
|
||||
const config = input.config ?? await getActiveEmailConfig(prisma);
|
||||
const templates = await listEmailTemplateRecords(prisma);
|
||||
const template = normalizeEmailTemplate(templates.find((item: EmailTemplateRecord) => item.scene === input.scene), input.scene);
|
||||
|
||||
@@ -321,6 +345,10 @@ async function sendSceneEmail(prisma: PrismaClient, input: {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: management data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getEmailManagementData(prisma?: PrismaClient) {
|
||||
const client = prisma ?? getEmailContext().prisma;
|
||||
const [configRecords, templateRecords, logRecords] = await Promise.all([
|
||||
@@ -329,11 +357,16 @@ export async function getEmailManagementData(prisma?: PrismaClient) {
|
||||
listEmailLogRecords(client, 100),
|
||||
]);
|
||||
|
||||
const configs = Object.fromEntries(
|
||||
emailChannels.map((provider) => [provider, normalizeEmailConfig(configRecords.find((item: EmailConfigRecord) => item.provider === provider) ?? null, provider)]),
|
||||
) as Record<EmailChannel, EmailConfigValue>;
|
||||
const configs: EmailConfigValue[] = configRecords.map((record: EmailConfigRecord) =>
|
||||
normalizeEmailConfigFromRecord(record),
|
||||
);
|
||||
|
||||
const templates = emailScenes.map((scene) => normalizeEmailTemplate(templateRecords.find((item: EmailTemplateRecord) => item.scene === scene), scene));
|
||||
const templates = emailScenes.map((scene) =>
|
||||
normalizeEmailTemplate(
|
||||
templateRecords.find((item: EmailTemplateRecord) => item.scene === scene),
|
||||
scene,
|
||||
),
|
||||
);
|
||||
|
||||
const statsMap = {
|
||||
total: logRecords.length,
|
||||
@@ -363,14 +396,30 @@ export async function getEmailManagementData(prisma?: PrismaClient) {
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
// Derive current push settings from the first config (they're synced across all)
|
||||
const firstConfig = configs[0] ?? defaultApiConfig;
|
||||
const pushSettings = {
|
||||
customerSendOrderPaidEmail: firstConfig.customerSendOrderPaidEmail,
|
||||
customerSendDeliverySuccessEmail: firstConfig.customerSendDeliverySuccessEmail,
|
||||
customerSendDeliveryFailedEmail: firstConfig.customerSendDeliveryFailedEmail,
|
||||
adminSendOrderPaidEmail: firstConfig.adminSendOrderPaidEmail,
|
||||
adminSendDeliverySuccessEmail: firstConfig.adminSendDeliverySuccessEmail,
|
||||
adminSendDeliveryFailedEmail: firstConfig.adminSendDeliveryFailedEmail,
|
||||
};
|
||||
|
||||
return {
|
||||
configs,
|
||||
templates,
|
||||
logs,
|
||||
metrics,
|
||||
pushSettings,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: save push settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function saveEmailPushSettings(input: {
|
||||
customerSendOrderPaidEmail: boolean;
|
||||
customerSendDeliverySuccessEmail: boolean;
|
||||
@@ -383,27 +432,14 @@ export async function saveEmailPushSettings(input: {
|
||||
const { prisma } = adminContext;
|
||||
const adminId = Number(adminContext.session?.user?.id);
|
||||
|
||||
const records = await prisma.emailConfig.findMany();
|
||||
|
||||
for (const record of records) {
|
||||
let configJson: Record<string, unknown> = {};
|
||||
try {
|
||||
configJson = JSON.parse(record.configJson);
|
||||
} catch {
|
||||
configJson = {};
|
||||
}
|
||||
configJson.customerSendOrderPaidEmail = Boolean(input.customerSendOrderPaidEmail);
|
||||
configJson.customerSendDeliverySuccessEmail = Boolean(input.customerSendDeliverySuccessEmail);
|
||||
configJson.customerSendDeliveryFailedEmail = Boolean(input.customerSendDeliveryFailedEmail);
|
||||
configJson.adminSendOrderPaidEmail = Boolean(input.adminSendOrderPaidEmail);
|
||||
configJson.adminSendDeliverySuccessEmail = Boolean(input.adminSendDeliverySuccessEmail);
|
||||
configJson.adminSendDeliveryFailedEmail = Boolean(input.adminSendDeliveryFailedEmail);
|
||||
|
||||
await prisma.emailConfig.update({
|
||||
where: { id: record.id },
|
||||
data: { configJson: JSON.stringify(configJson) }
|
||||
});
|
||||
}
|
||||
await updatePushFlagsForAllConfigs(prisma, {
|
||||
customerSendOrderPaidEmail: Boolean(input.customerSendOrderPaidEmail),
|
||||
customerSendDeliverySuccessEmail: Boolean(input.customerSendDeliverySuccessEmail),
|
||||
customerSendDeliveryFailedEmail: Boolean(input.customerSendDeliveryFailedEmail),
|
||||
adminSendOrderPaidEmail: Boolean(input.adminSendOrderPaidEmail),
|
||||
adminSendDeliverySuccessEmail: Boolean(input.adminSendDeliverySuccessEmail),
|
||||
adminSendDeliveryFailedEmail: Boolean(input.adminSendDeliveryFailedEmail),
|
||||
});
|
||||
|
||||
await logAdminOperation(
|
||||
{
|
||||
@@ -420,32 +456,28 @@ export async function saveEmailPushSettings(input: {
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function activateEmailProvider(provider: EmailChannel) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: activate a specific mailbox config by id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function activateEmailProvider(id: number) {
|
||||
const adminContext = getAdminContext();
|
||||
const { prisma } = adminContext;
|
||||
const adminId = Number(adminContext.session?.user?.id);
|
||||
|
||||
await prisma.emailConfig.updateMany({
|
||||
where: { provider: { not: provider } },
|
||||
data: { isEnabled: false },
|
||||
});
|
||||
|
||||
const record = await prisma.emailConfig.findUnique({ where: { provider } });
|
||||
if (record) {
|
||||
await prisma.emailConfig.update({
|
||||
where: { provider },
|
||||
data: { isEnabled: true },
|
||||
});
|
||||
} else {
|
||||
throw badRequestError(`配置 ${provider} 不存在,请先保存配置`, "EMAIL_CONFIG_NOT_FOUND");
|
||||
const record = await getEmailConfigRecordById(prisma, id);
|
||||
if (!record) {
|
||||
throw badRequestError("邮局配置不存在", "EMAIL_CONFIG_NOT_FOUND");
|
||||
}
|
||||
|
||||
await activateEmailConfigById(prisma, id);
|
||||
|
||||
await logAdminOperation(
|
||||
{
|
||||
action: "ACTIVATE_EMAIL_PROVIDER",
|
||||
targetType: "EmailConfig",
|
||||
targetId: provider,
|
||||
detail: `activated`,
|
||||
targetId: String(id),
|
||||
detail: `activated ${record.name} (${record.provider})`,
|
||||
},
|
||||
{
|
||||
prisma,
|
||||
@@ -456,16 +488,14 @@ export async function activateEmailProvider(provider: EmailChannel) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function saveEmailConfig(input: EmailConfigValue) {
|
||||
const adminContext = getAdminContext();
|
||||
const { prisma } = adminContext;
|
||||
const adminId = Number(adminContext.session?.user?.id);
|
||||
const validated = validateEmailConfigInput(input);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: save (create or update) an email config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let config: Record<string, unknown>;
|
||||
if (validated.provider === "API") {
|
||||
function buildConfigJson(input: EmailConfigValue): Record<string, unknown> {
|
||||
if (input.provider === "API") {
|
||||
const apiInput = input as EmailApiConfigValue;
|
||||
config = {
|
||||
return {
|
||||
apiProvider: apiInput.apiProvider,
|
||||
fromEmail: apiInput.fromEmail.trim(),
|
||||
fromName: apiInput.fromName?.trim() || "",
|
||||
@@ -481,9 +511,11 @@ export async function saveEmailConfig(input: EmailConfigValue) {
|
||||
adminSendDeliverySuccessEmail: Boolean(apiInput.adminSendDeliverySuccessEmail),
|
||||
adminSendDeliveryFailedEmail: Boolean(apiInput.adminSendDeliveryFailedEmail),
|
||||
};
|
||||
} else if (validated.provider === "SMTP") {
|
||||
}
|
||||
|
||||
if (input.provider === "SMTP") {
|
||||
const smtpInput = input as EmailSmtpConfigValue;
|
||||
config = {
|
||||
return {
|
||||
fromEmail: smtpInput.fromEmail.trim(),
|
||||
fromName: smtpInput.fromName?.trim() || "",
|
||||
replyTo: smtpInput.replyTo?.trim() || "",
|
||||
@@ -499,40 +531,90 @@ export async function saveEmailConfig(input: EmailConfigValue) {
|
||||
adminSendDeliverySuccessEmail: Boolean(smtpInput.adminSendDeliverySuccessEmail),
|
||||
adminSendDeliveryFailedEmail: Boolean(smtpInput.adminSendDeliveryFailedEmail),
|
||||
};
|
||||
} else {
|
||||
const cloudflareInput = input as EmailCloudflareConfigValue;
|
||||
config = {
|
||||
fromEmail: cloudflareInput.fromEmail.trim(),
|
||||
fromName: cloudflareInput.fromName?.trim() || "",
|
||||
replyTo: cloudflareInput.replyTo?.trim() || "",
|
||||
cloudflareBindingName: cloudflareInput.cloudflareBindingName?.trim() || "",
|
||||
cloudflareDestinationAddress: cloudflareInput.cloudflareDestinationAddress?.trim() || "",
|
||||
cloudflareAllowedDestinationAddresses: cloudflareInput.cloudflareAllowedDestinationAddresses ?? [],
|
||||
customerSendOrderPaidEmail: Boolean(cloudflareInput.customerSendOrderPaidEmail),
|
||||
customerSendDeliverySuccessEmail: Boolean(cloudflareInput.customerSendDeliverySuccessEmail),
|
||||
customerSendDeliveryFailedEmail: Boolean(cloudflareInput.customerSendDeliveryFailedEmail),
|
||||
adminSendOrderPaidEmail: Boolean(cloudflareInput.adminSendOrderPaidEmail),
|
||||
adminSendDeliverySuccessEmail: Boolean(cloudflareInput.adminSendDeliverySuccessEmail),
|
||||
adminSendDeliveryFailedEmail: Boolean(cloudflareInput.adminSendDeliveryFailedEmail),
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecord = await prisma.emailConfig.findUnique({
|
||||
where: { provider: validated.provider },
|
||||
});
|
||||
const cloudflareInput = input as EmailCloudflareConfigValue;
|
||||
return {
|
||||
fromEmail: cloudflareInput.fromEmail.trim(),
|
||||
fromName: cloudflareInput.fromName?.trim() || "",
|
||||
replyTo: cloudflareInput.replyTo?.trim() || "",
|
||||
cloudflareBindingName: cloudflareInput.cloudflareBindingName?.trim() || "",
|
||||
cloudflareDestinationAddress: cloudflareInput.cloudflareDestinationAddress?.trim() || "",
|
||||
cloudflareAllowedDestinationAddresses: cloudflareInput.cloudflareAllowedDestinationAddresses ?? [],
|
||||
customerSendOrderPaidEmail: Boolean(cloudflareInput.customerSendOrderPaidEmail),
|
||||
customerSendDeliverySuccessEmail: Boolean(cloudflareInput.customerSendDeliverySuccessEmail),
|
||||
customerSendDeliveryFailedEmail: Boolean(cloudflareInput.customerSendDeliveryFailedEmail),
|
||||
adminSendOrderPaidEmail: Boolean(cloudflareInput.adminSendOrderPaidEmail),
|
||||
adminSendDeliverySuccessEmail: Boolean(cloudflareInput.adminSendDeliverySuccessEmail),
|
||||
adminSendDeliveryFailedEmail: Boolean(cloudflareInput.adminSendDeliveryFailedEmail),
|
||||
};
|
||||
}
|
||||
|
||||
const record = await upsertEmailConfigRecord(prisma, validated.provider, {
|
||||
name: getChannelDisplayName(validated.provider),
|
||||
isEnabled: existingRecord?.isEnabled ?? false,
|
||||
configJson: JSON.stringify(config),
|
||||
function getDefaultName(input: EmailConfigValue): string {
|
||||
if (input.provider === "API") {
|
||||
const apiInput = input as EmailApiConfigValue;
|
||||
return `${apiInput.apiProvider} - ${apiInput.fromEmail || "未配置"}`;
|
||||
}
|
||||
if (input.provider === "SMTP") {
|
||||
const smtpInput = input as EmailSmtpConfigValue;
|
||||
return `SMTP - ${smtpInput.smtpHost || smtpInput.fromEmail || "未配置"}`;
|
||||
}
|
||||
const cfInput = input as EmailCloudflareConfigValue;
|
||||
return `CloudFlare - ${cfInput.fromEmail || "未配置"}`;
|
||||
}
|
||||
|
||||
export async function saveEmailConfig(input: EmailConfigValue) {
|
||||
const adminContext = getAdminContext();
|
||||
const { prisma } = adminContext;
|
||||
const adminId = Number(adminContext.session?.user?.id);
|
||||
const validated = validateEmailConfigInput(input);
|
||||
|
||||
const configJson = buildConfigJson({ ...input, provider: validated.provider } as EmailConfigValue);
|
||||
const name = input.name?.trim() || getDefaultName(input);
|
||||
|
||||
// If input has an id, update existing record; otherwise create new
|
||||
if (input.id && input.id > 0) {
|
||||
const existingRecord = await getEmailConfigRecordById(prisma, input.id);
|
||||
if (!existingRecord) {
|
||||
throw badRequestError("邮局配置不存在", "EMAIL_CONFIG_NOT_FOUND");
|
||||
}
|
||||
|
||||
const record = await updateEmailConfigRecord(prisma, input.id, {
|
||||
provider: validated.provider as EmailChannel,
|
||||
name,
|
||||
configJson: JSON.stringify(configJson),
|
||||
});
|
||||
|
||||
await logAdminOperation(
|
||||
{
|
||||
action: "SAVE_EMAIL_CONFIG",
|
||||
targetType: "EmailConfig",
|
||||
targetId: String(input.id),
|
||||
detail: `updated: ${name}`,
|
||||
},
|
||||
{
|
||||
prisma,
|
||||
adminId,
|
||||
},
|
||||
);
|
||||
|
||||
return normalizeEmailConfigFromRecord(record);
|
||||
}
|
||||
|
||||
// Create new
|
||||
const record = await createEmailConfigRecord(prisma, {
|
||||
provider: validated.provider as EmailChannel,
|
||||
name,
|
||||
isEnabled: false,
|
||||
configJson: JSON.stringify(configJson),
|
||||
});
|
||||
|
||||
await logAdminOperation(
|
||||
{
|
||||
action: "SAVE_EMAIL_CONFIG",
|
||||
action: "CREATE_EMAIL_CONFIG",
|
||||
targetType: "EmailConfig",
|
||||
targetId: input.provider,
|
||||
detail: `updated`,
|
||||
targetId: String(record.id),
|
||||
detail: `created: ${name}`,
|
||||
},
|
||||
{
|
||||
prisma,
|
||||
@@ -540,9 +622,49 @@ export async function saveEmailConfig(input: EmailConfigValue) {
|
||||
},
|
||||
);
|
||||
|
||||
return normalizeEmailConfig(record, validated.provider);
|
||||
return normalizeEmailConfigFromRecord(record);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: delete an email config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function deleteEmailConfig(id: number) {
|
||||
const adminContext = getAdminContext();
|
||||
const { prisma } = adminContext;
|
||||
const adminId = Number(adminContext.session?.user?.id);
|
||||
|
||||
const record = await getEmailConfigRecordById(prisma, id);
|
||||
if (!record) {
|
||||
throw badRequestError("邮局配置不存在", "EMAIL_CONFIG_NOT_FOUND");
|
||||
}
|
||||
|
||||
if (record.isEnabled) {
|
||||
throw badRequestError("不能删除当前激活的邮局配置,请先激活其他配置", "EMAIL_CONFIG_IS_ACTIVE");
|
||||
}
|
||||
|
||||
await deleteEmailConfigRecord(prisma, id);
|
||||
|
||||
await logAdminOperation(
|
||||
{
|
||||
action: "DELETE_EMAIL_CONFIG",
|
||||
targetType: "EmailConfig",
|
||||
targetId: String(id),
|
||||
detail: `deleted: ${record.name}`,
|
||||
},
|
||||
{
|
||||
prisma,
|
||||
adminId,
|
||||
},
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: save template
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function saveEmailTemplate(input: EmailTemplateValue) {
|
||||
const adminContext = getAdminContext();
|
||||
const { prisma } = adminContext;
|
||||
@@ -576,25 +698,41 @@ export async function saveEmailTemplate(input: EmailTemplateValue) {
|
||||
return normalizeEmailTemplate(record, validated.scene as EmailScene);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: send test email
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function sendTestEmail(input: {
|
||||
toEmail: string;
|
||||
customContent?: string;
|
||||
configId?: number;
|
||||
}) {
|
||||
const adminContext = getAdminContext();
|
||||
const { prisma } = adminContext;
|
||||
const adminId = Number(adminContext.session?.user?.id);
|
||||
const validated = validateTestEmailInput(input);
|
||||
const baseValues = await getEmailBaseValues(prisma);
|
||||
const site = await getSiteSetting(prisma);
|
||||
|
||||
// If a specific configId is provided, load that config directly instead of using the active one
|
||||
let targetConfig: EmailConfigValue | undefined;
|
||||
if (input.configId && input.configId > 0) {
|
||||
const record = await getEmailConfigRecordById(prisma, input.configId);
|
||||
if (!record) {
|
||||
throw badRequestError("指定的邮局配置不存在", "EMAIL_CONFIG_NOT_FOUND");
|
||||
}
|
||||
targetConfig = normalizeEmailConfigFromRecord(record);
|
||||
}
|
||||
|
||||
const result = await sendSceneEmail(prisma, {
|
||||
scene: "TEST",
|
||||
toEmail: validated.toEmail,
|
||||
values: {
|
||||
siteName: baseValues.siteName,
|
||||
siteName: site.siteName,
|
||||
sentAt: new Date().toLocaleString("zh-CN"),
|
||||
customContent: validated.customContent,
|
||||
},
|
||||
triggeredBy: `admin:${adminId || 0}`,
|
||||
config: targetConfig,
|
||||
});
|
||||
|
||||
await logAdminOperation(
|
||||
@@ -612,6 +750,29 @@ export async function sendTestEmail(input: {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: clear email logs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function clearEmailLogs() {
|
||||
const adminContext = getAdminContext();
|
||||
const { prisma } = adminContext;
|
||||
const adminId = Number(adminContext.session?.user?.id);
|
||||
|
||||
const { count } = await prisma.emailLog.deleteMany({});
|
||||
|
||||
await logAdminOperation(
|
||||
{ action: "CLEAR_EMAIL_LOGS", targetType: "EmailLog", detail: `deleted ${count} records` },
|
||||
{ prisma, adminId },
|
||||
);
|
||||
|
||||
return { count };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: notification hooks (called from order/delivery flows)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function notifyOrderPaid(input: {
|
||||
prisma?: PrismaClient;
|
||||
orderId: number;
|
||||
@@ -786,4 +947,4 @@ export async function notifyDeliveryFailed(input: {
|
||||
|
||||
await Promise.all(tasks);
|
||||
return { processed: true };
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export interface EmailPushFlags {
|
||||
}
|
||||
|
||||
export interface EmailApiConfigValue extends EmailPushFlags {
|
||||
id?: number;
|
||||
name?: string;
|
||||
provider: "API";
|
||||
isEnabled: boolean;
|
||||
apiProvider: EmailApiProvider;
|
||||
@@ -27,6 +29,8 @@ export interface EmailApiConfigValue extends EmailPushFlags {
|
||||
}
|
||||
|
||||
export interface EmailSmtpConfigValue extends EmailPushFlags {
|
||||
id?: number;
|
||||
name?: string;
|
||||
provider: "SMTP";
|
||||
isEnabled: boolean;
|
||||
fromEmail: string;
|
||||
@@ -40,6 +44,8 @@ export interface EmailSmtpConfigValue extends EmailPushFlags {
|
||||
}
|
||||
|
||||
export interface EmailCloudflareConfigValue extends EmailPushFlags {
|
||||
id?: number;
|
||||
name?: string;
|
||||
provider: "CLOUDFLARE";
|
||||
isEnabled: boolean;
|
||||
fromEmail: string;
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
<h1 class="text-2xl font-bold">邮件管理</h1>
|
||||
<p class="text-sm text-base-content/70">配置邮件发送通道、推送开关、日志列表和模板。</p>
|
||||
</div>
|
||||
<!-- <div class="badge badge-outline">配置分类:API / SMTP / CloudFlare</div> -->
|
||||
</div>
|
||||
|
||||
<div role="tablist" class="tabs tabs-box">
|
||||
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'stats' }" @click="activeTab = 'stats'">统计</a>
|
||||
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'config' }" @click="activeTab = 'config'">配置</a>
|
||||
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'list' }" @click="activeTab = 'list'">列表</a>
|
||||
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'list' }" @click="activeTab = 'list'">日志</a>
|
||||
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'template' }" @click="activeTab = 'template'">模板</a>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 统计 ==================== -->
|
||||
<section v-if="activeTab === 'stats'" class="space-y-4">
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<article v-for="metric in metrics" :key="metric.label" class="card bg-base-100 shadow-sm">
|
||||
@@ -26,6 +26,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ==================== 配置 ==================== -->
|
||||
<section v-if="activeTab === 'config'" class="space-y-6">
|
||||
<!-- 1. 消息推送配置 -->
|
||||
<section class="card bg-base-100 shadow-sm">
|
||||
@@ -40,39 +41,39 @@
|
||||
<h3 class="font-semibold text-base-content/80">发给客户</h3>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input v-model="pushSettings.customerSendOrderPaidEmail" type="checkbox" class="checkbox checkbox-primary" />
|
||||
<input v-model="pushSettings.customerSendOrderPaidEmail" type="checkbox" class="checkbox checkbox-primary checkbox-sm" />
|
||||
<span class="label-text font-medium">支付成功发送</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input v-model="pushSettings.customerSendDeliverySuccessEmail" type="checkbox" class="checkbox checkbox-primary" />
|
||||
<input v-model="pushSettings.customerSendDeliverySuccessEmail" type="checkbox" class="checkbox checkbox-primary checkbox-sm" />
|
||||
<span class="label-text font-medium">发货成功发送</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input v-model="pushSettings.customerSendDeliveryFailedEmail" type="checkbox" class="checkbox checkbox-primary" />
|
||||
<input v-model="pushSettings.customerSendDeliveryFailedEmail" type="checkbox" class="checkbox checkbox-primary checkbox-sm" />
|
||||
<span class="label-text font-medium">发货失败发送</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="space-y-2 mt-4">
|
||||
<h3 class="font-semibold text-base-content/80">发给管理员 (需在个人资料配置邮箱)</h3>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input v-model="pushSettings.adminSendOrderPaidEmail" type="checkbox" class="checkbox checkbox-primary" />
|
||||
<input v-model="pushSettings.adminSendOrderPaidEmail" type="checkbox" class="checkbox checkbox-primary checkbox-sm" />
|
||||
<span class="label-text font-medium">支付成功发送</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input v-model="pushSettings.adminSendDeliverySuccessEmail" type="checkbox" class="checkbox checkbox-primary" />
|
||||
<input v-model="pushSettings.adminSendDeliverySuccessEmail" type="checkbox" class="checkbox checkbox-primary checkbox-sm" />
|
||||
<span class="label-text font-medium">发货成功发送</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input v-model="pushSettings.adminSendDeliveryFailedEmail" type="checkbox" class="checkbox checkbox-primary" />
|
||||
<input v-model="pushSettings.adminSendDeliveryFailedEmail" type="checkbox" class="checkbox checkbox-primary checkbox-sm" />
|
||||
<span class="label-text font-medium">发货失败发送</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button class="btn btn-primary" :disabled="savingPushSettings" @click="handleSavePushSettings">
|
||||
<button class="btn btn-primary btn-sm" :disabled="savingPushSettings" @click="handleSavePushSettings">
|
||||
{{ savingPushSettings ? '保存中...' : '保存推送设置' }}
|
||||
</button>
|
||||
<span v-if="pushSettingsMessage" class="text-sm" :class="pushSettingsError ? 'text-error' : 'text-success'">
|
||||
@@ -82,96 +83,30 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. 邮局配置表单 -->
|
||||
<section class="card bg-base-100 shadow-sm" id="provider-form">
|
||||
<!-- 2. 邮局列表 -->
|
||||
<section class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">邮局配置</h2>
|
||||
<p class="text-sm text-base-content/70">配置对应的发送通道参数,保存后可在下方列表中测试和激活。</p>
|
||||
<h2 class="text-xl font-semibold">邮局列表</h2>
|
||||
<p class="text-sm text-base-content/70">支持添加多个邮局配置,可自由选择激活其中一个用于发信。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1.5 max-w-xs">
|
||||
<span class="label-text font-medium">选择邮件类型</span>
|
||||
<select v-model="activeProviderFormType" class="select select-bordered w-full">
|
||||
<option value="API">API</option>
|
||||
<option value="SMTP">SMTP</option>
|
||||
<option value="CLOUDFLARE">Cloudflare</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="divider my-0"></div>
|
||||
|
||||
<!-- API Form -->
|
||||
<div v-if="activeProviderFormType === 'API'" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">API 服务商</span>
|
||||
<select v-model="apiForm.apiProvider" class="select select-bordered w-full">
|
||||
<option value="BREVO">Brevo</option>
|
||||
<option value="MAILJET">Mailjet</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">发件邮箱</span><input v-model="apiForm.fromEmail" class="input input-bordered w-full" placeholder="admin@example.com" /></label>
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">发件人名称</span><input v-model="apiForm.fromName" class="input input-bordered w-full" /></label>
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">回复邮箱</span><input v-model="apiForm.replyTo" class="input input-bordered w-full" /></label>
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">API 地址</span><input v-model="apiForm.apiBaseUrl" class="input input-bordered w-full" :placeholder="apiForm.apiProvider === 'BREVO' ? 'https://api.brevo.com/v3/smtp/email' : 'https://api.mailjet.com/v3.1/send'" /></label>
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">API Key</span><input v-model="apiForm.apiKey" class="input input-bordered w-full" /></label>
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">Secret Key</span><input v-model="apiForm.secretKey" class="input input-bordered w-full" :disabled="apiForm.apiProvider !== 'MAILJET'" :placeholder="apiForm.apiProvider === 'MAILJET' ? 'Mailjet Secret Key' : 'Brevo 不需要该字段'" /></label>
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">超时(ms)</span><input v-model.number="apiForm.timeoutMs" type="number" class="input input-bordered w-full" /></label>
|
||||
</div>
|
||||
|
||||
<!-- SMTP Form -->
|
||||
<div v-if="activeProviderFormType === 'SMTP'">
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3 mb-4">
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">发件邮箱</span><input v-model="smtpForm.fromEmail" class="input input-bordered w-full" /></label>
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">发件人名称</span><input v-model="smtpForm.fromName" class="input input-bordered w-full" /></label>
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">回复邮箱</span><input v-model="smtpForm.replyTo" class="input input-bordered w-full" /></label>
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">SMTP Host</span><input v-model="smtpForm.smtpHost" class="input input-bordered w-full" placeholder="smtp.example.com" /></label>
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">SMTP Port</span><input v-model.number="smtpForm.smtpPort" type="number" class="input input-bordered w-full" /></label>
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">SMTP 用户名</span><input v-model="smtpForm.smtpUsername" class="input input-bordered w-full" /></label>
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">SMTP 密码</span><input v-model="smtpForm.smtpPassword" class="input input-bordered w-full" /></label>
|
||||
</div>
|
||||
<label class="label cursor-pointer justify-start gap-3 w-fit">
|
||||
<input v-model="smtpForm.smtpSecure" type="checkbox" class="checkbox checkbox-primary" />
|
||||
<span class="label-text font-medium">使用 SMTPS / SSL</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Cloudflare Form -->
|
||||
<div v-if="activeProviderFormType === 'CLOUDFLARE'" class="space-y-4">
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">发件邮箱</span><input v-model="cloudflareForm.fromEmail" class="input input-bordered w-full" placeholder="sender@your-domain.com" /></label>
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">发件人名称</span><input v-model="cloudflareForm.fromName" class="input input-bordered w-full" /></label>
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">回复邮箱</span><input v-model="cloudflareForm.replyTo" class="input input-bordered w-full" /></label>
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">Binding 名称</span><input v-model="cloudflareForm.cloudflareBindingName" class="input input-bordered w-full" placeholder="SEB" /></label>
|
||||
<label class="flex flex-col gap-1.5"><span class="label-text font-medium">目标邮箱</span><input v-model="cloudflareForm.cloudflareDestinationAddress" class="input input-bordered w-full" placeholder="you@example.com" /></label>
|
||||
</div>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">允许目标邮箱列表</span>
|
||||
<textarea v-model="cloudflareAllowedText" class="textarea textarea-bordered w-full" rows="4" placeholder="一行一个邮箱"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 mt-4">
|
||||
<button class="btn btn-primary" :disabled="savingChannel === activeProviderFormType" @click="handleSaveConfig(activeProviderFormType)">
|
||||
{{ savingChannel === activeProviderFormType ? '保存中...' : '保存/更新配置到列表' }}
|
||||
<button class="btn btn-primary btn-sm" @click="openCreateDialog">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
|
||||
新增邮局
|
||||
</button>
|
||||
<span v-if="channelMessages[activeProviderFormType]" class="text-sm" :class="channelErrors[activeProviderFormType] ? 'text-error' : 'text-success'">
|
||||
{{ channelMessages[activeProviderFormType] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. 邮局列表 -->
|
||||
<section class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<h2 class="text-xl font-semibold">邮局列表</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<div v-if="!mailboxList.length" class="text-center py-8 text-base-content/50">
|
||||
暂无邮局配置,点击上方"新增邮局"按钮添加
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto" v-else>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>发件邮箱</th>
|
||||
<th>服务商/地址</th>
|
||||
@@ -180,29 +115,41 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="channel in ['API', 'SMTP', 'CLOUDFLARE']" :key="channel">
|
||||
<td>{{ getChannelLabel(channel) }}</td>
|
||||
<td>{{ getChannelForm(channel as any).fromEmail || '-' }}</td>
|
||||
<tr v-for="item in mailboxList" :key="item.id">
|
||||
<td class="font-mono text-sm">{{ item.id }}</td>
|
||||
<td>{{ item.name || '-' }}</td>
|
||||
<td>
|
||||
<span v-if="channel === 'API'">{{ getChannelForm(channel).apiProvider || '-' }}</span>
|
||||
<span v-else-if="channel === 'SMTP'">{{ getChannelForm(channel).smtpHost || '-' }}</span>
|
||||
<span v-else>{{ getChannelForm(channel).cloudflareBindingName || '-' }}</span>
|
||||
<span class="badge badge-outline">{{ getChannelLabel(item.provider) }}</span>
|
||||
</td>
|
||||
<td>{{ item.fromEmail || '-' }}</td>
|
||||
<td>
|
||||
<span v-if="item.provider === 'API'">{{ (item as any).apiProvider || '-' }}</span>
|
||||
<span v-else-if="item.provider === 'SMTP'">{{ (item as any).smtpHost || '-' }}</span>
|
||||
<span v-else>{{ (item as any).cloudflareBindingName || '-' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="getChannelForm(channel as any).isEnabled ? 'badge-success' : 'badge-ghost'">
|
||||
{{ getChannelForm(channel as any).isEnabled ? '已激活' : '未激活' }}
|
||||
<span class="badge" :class="item.isEnabled ? 'badge-success' : 'badge-ghost'">
|
||||
{{ item.isEnabled ? '已激活' : '未激活' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-xs btn-outline" @click="editProvider(channel as any)">编辑</button>
|
||||
<button class="btn btn-xs btn-outline" @click="openTestModal(channel as any)">测试发送</button>
|
||||
<button
|
||||
class="btn btn-xs"
|
||||
:class="getChannelForm(channel as any).isEnabled ? 'btn-disabled' : 'btn-primary'"
|
||||
@click="handleActivateProvider(channel as any)"
|
||||
<button class="btn btn-sm btn-outline" @click="openEditDialog(item)">编辑</button>
|
||||
<button class="btn btn-sm btn-outline" @click="openTestModal(item)">测试</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="item.isEnabled ? 'btn-disabled' : 'btn-primary'"
|
||||
:disabled="item.isEnabled"
|
||||
@click="handleActivate(item)"
|
||||
>
|
||||
{{ getChannelForm(channel as any).isEnabled ? '当前激活' : '激活' }}
|
||||
{{ item.isEnabled ? '当前激活' : '激活' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-error btn-outline"
|
||||
:disabled="item.isEnabled"
|
||||
@click="handleDelete(item)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -214,10 +161,158 @@
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- 测试发送弹窗 -->
|
||||
<dialog id="test-email-modal" class="modal" :class="{ 'modal-open': showTestModal }">
|
||||
<!-- ==================== 新增/编辑邮局弹窗 ==================== -->
|
||||
<dialog class="modal" :class="{ 'modal-open': showConfigDialog }">
|
||||
<div class="modal-box w-11/12 max-w-3xl">
|
||||
<h3 class="font-bold text-lg mb-4">{{ editingId ? '编辑邮局' : '新增邮局' }}</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 名称 -->
|
||||
<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 主账号" />
|
||||
</label>
|
||||
|
||||
<!-- 类型选择 -->
|
||||
<label class="flex flex-col gap-1.5 max-w-xs">
|
||||
<span class="label-text font-medium">邮件类型</span>
|
||||
<select v-model="configForm.provider" class="select select-bordered w-full">
|
||||
<option value="API">API</option>
|
||||
<option value="SMTP">SMTP</option>
|
||||
<option value="CLOUDFLARE">Cloudflare</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="divider my-0"></div>
|
||||
|
||||
<!-- API Form -->
|
||||
<div v-if="configForm.provider === 'API'" class="grid gap-4 md:grid-cols-2">
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<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>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">发件邮箱</span>
|
||||
<input v-model="configForm.fromEmail" class="input input-bordered w-full" placeholder="admin@example.com" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">发件人名称</span>
|
||||
<input v-model="configForm.fromName" class="input input-bordered w-full" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">回复邮箱</span>
|
||||
<input v-model="configForm.replyTo" class="input input-bordered w-full" />
|
||||
</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'" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">API Key</span>
|
||||
<input v-model="configForm.apiKey" class="input input-bordered w-full" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">Secret Key</span>
|
||||
<input v-model="configForm.secretKey" class="input input-bordered w-full" :disabled="configForm.apiProvider !== 'MAILJET'" :placeholder="configForm.apiProvider === 'MAILJET' ? 'Mailjet Secret Key' : 'Brevo 不需要该字段'" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">超时(ms)</span>
|
||||
<input v-model.number="configForm.timeoutMs" type="number" class="input input-bordered w-full" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- SMTP Form -->
|
||||
<div v-if="configForm.provider === 'SMTP'" class="space-y-4">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">发件邮箱</span>
|
||||
<input v-model="configForm.fromEmail" class="input input-bordered w-full" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">发件人名称</span>
|
||||
<input v-model="configForm.fromName" class="input input-bordered w-full" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">回复邮箱</span>
|
||||
<input v-model="configForm.replyTo" class="input input-bordered w-full" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">SMTP Host</span>
|
||||
<input v-model="configForm.smtpHost" class="input input-bordered w-full" placeholder="smtp.example.com" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">SMTP Port</span>
|
||||
<input v-model.number="configForm.smtpPort" type="number" class="input input-bordered w-full" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">SMTP 用户名</span>
|
||||
<input v-model="configForm.smtpUsername" class="input input-bordered w-full" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">SMTP 密码</span>
|
||||
<input v-model="configForm.smtpPassword" class="input input-bordered w-full" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="label cursor-pointer justify-start gap-3 w-fit">
|
||||
<input v-model="configForm.smtpSecure" type="checkbox" class="checkbox checkbox-primary" />
|
||||
<span class="label-text font-medium">使用 SMTPS / SSL</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Cloudflare Form -->
|
||||
<div v-if="configForm.provider === 'CLOUDFLARE'" class="space-y-4">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">发件邮箱</span>
|
||||
<input v-model="configForm.fromEmail" class="input input-bordered w-full" placeholder="sender@your-domain.com" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">发件人名称</span>
|
||||
<input v-model="configForm.fromName" class="input input-bordered w-full" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">回复邮箱</span>
|
||||
<input v-model="configForm.replyTo" class="input input-bordered w-full" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">Binding 名称</span>
|
||||
<input v-model="configForm.cloudflareBindingName" class="input input-bordered w-full" placeholder="SEB" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">目标邮箱</span>
|
||||
<input v-model="configForm.cloudflareDestinationAddress" class="input input-bordered w-full" placeholder="you@example.com" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">允许目标邮箱列表</span>
|
||||
<textarea v-model="configForm.cloudflareAllowedText" class="textarea textarea-bordered w-full" rows="3" placeholder="一行一个邮箱"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="configDialogMessage" class="text-sm mt-2" :class="configDialogError ? 'text-error' : 'text-success'">
|
||||
{{ configDialogMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn" @click="closeConfigDialog" type="button">取消</button>
|
||||
<button class="btn btn-primary" :disabled="savingConfig" @click="handleSaveConfig" type="button">
|
||||
{{ savingConfig ? '保存中...' : (editingId ? '更新' : '创建') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button @click="closeConfigDialog">关闭</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- ==================== 测试发送弹窗 ==================== -->
|
||||
<dialog class="modal" :class="{ 'modal-open': showTestModal }">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">测试发送 ({{ getChannelLabel(testingChannel) }})</h3>
|
||||
<h3 class="font-bold text-lg">测试发送 — {{ testingMailboxName }}</h3>
|
||||
<div class="py-4 space-y-4">
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">测试收件邮箱</span>
|
||||
@@ -225,19 +320,17 @@
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">测试内容</span>
|
||||
<textarea v-model="testContent" class="textarea textarea-bordered w-full" rows="5" placeholder="这是从 Cloudflare 发出的第一封邮件。"></textarea>
|
||||
<textarea v-model="testContent" class="textarea textarea-bordered w-full" rows="5" placeholder="这是一封测试邮件。"></textarea>
|
||||
</label>
|
||||
<div v-if="testModalMessage" class="text-sm mt-2" :class="testModalError ? 'text-error' : 'text-success'">
|
||||
{{ testModalMessage }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<form method="dialog" class="flex gap-2">
|
||||
<button class="btn" @click="closeTestModal" type="button">关闭</button>
|
||||
<button class="btn btn-primary" :disabled="isTesting" @click="handleSendTest(testingChannel)" type="button">
|
||||
{{ isTesting ? '发送中...' : '发送' }}
|
||||
</button>
|
||||
</form>
|
||||
<button class="btn" @click="closeTestModal" type="button">关闭</button>
|
||||
<button class="btn btn-primary" :disabled="isTesting" @click="handleSendTest" type="button">
|
||||
{{ isTesting ? '发送中...' : '发送' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
@@ -245,8 +338,46 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- ==================== 删除确认弹窗 ==================== -->
|
||||
<dialog class="modal" :class="{ 'modal-open': showDeleteConfirm }">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">确认删除</h3>
|
||||
<p class="py-4">确定要删除邮局配置 <strong>{{ deletingMailboxName }}</strong> 吗?此操作不可恢复。</p>
|
||||
<div v-if="deleteMessage" class="text-sm text-error mb-2">{{ deleteMessage }}</div>
|
||||
<div class="modal-action">
|
||||
<button class="btn" @click="closeDeleteConfirm" type="button">取消</button>
|
||||
<button class="btn btn-error" :disabled="deleting" @click="confirmDelete" type="button">
|
||||
{{ deleting ? '删除中...' : '确认删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button @click="closeDeleteConfirm">关闭</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- ==================== 清除日志确认弹窗 ==================== -->
|
||||
<dialog class="modal" :class="{ 'modal-open': showClearConfirm }">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">确认清除</h3>
|
||||
<p class="py-4">确定要清除所有邮件日志吗?此操作不可恢复。</p>
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-sm" @click="showClearConfirm = false" type="button">取消</button>
|
||||
<button class="btn btn-sm btn-error" :disabled="clearingLogs" @click="handleClearLogs" type="button">
|
||||
{{ clearingLogs ? '清除中...' : '确认清除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button @click="showClearConfirm = false">关闭</button></form>
|
||||
</dialog>
|
||||
|
||||
<!-- ==================== 日志 ==================== -->
|
||||
<section v-if="activeTab === 'list'" class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base-content/60">共 {{ logList.length }} 条记录</span>
|
||||
<button class="btn btn-sm btn-error btn-outline" :disabled="!logList.length" @click="showClearConfirm = true">清除日志</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
@@ -264,8 +395,8 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!logs.length"><td colspan="10" class="text-center text-base-content/60">暂无邮件日志</td></tr>
|
||||
<tr v-for="(log, index) in logs" :key="log.id">
|
||||
<tr v-if="!logList.length"><td colspan="10" class="text-center text-base-content/60">暂无邮件日志</td></tr>
|
||||
<tr v-for="(log, index) in logList" :key="log.id">
|
||||
<th>{{ index + 1 }}</th>
|
||||
<td class="whitespace-nowrap">{{ formatDate(log.createdAt) }}</td>
|
||||
<td class="whitespace-nowrap">{{ getChannelLabel(log.provider) }}</td>
|
||||
@@ -287,6 +418,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ==================== 模板 ==================== -->
|
||||
<section v-if="activeTab === 'template'" class="space-y-4">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4 p-4 md:p-6">
|
||||
@@ -344,58 +476,235 @@
|
||||
import { normalizeTelefuncError } from "../../../lib/app-error";
|
||||
import { reactive, ref, computed } from "vue";
|
||||
import { useData } from "vike-vue/useData";
|
||||
import { onSaveEmailConfig, onSaveEmailPushSettings, onActivateEmailProvider } from "./saveEmailConfig.telefunc";
|
||||
import { onSaveEmailConfig, onDeleteEmailConfig, onSaveEmailPushSettings, onActivateEmailProvider, onClearEmailLogs } from "./saveEmailConfig.telefunc";
|
||||
import { onSaveEmailTemplate } from "./saveEmailTemplate.telefunc";
|
||||
import { onSendTestEmail } from "./sendTestEmail.telefunc";
|
||||
import type { Data } from "./+data";
|
||||
|
||||
const { configs, templates, logs, metrics } = useData<Data>();
|
||||
type MailboxItem = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
provider: "API" | "SMTP" | "CLOUDFLARE";
|
||||
isEnabled: boolean;
|
||||
fromEmail: string;
|
||||
fromName?: string;
|
||||
replyTo?: string;
|
||||
// API fields
|
||||
apiProvider?: string;
|
||||
apiBaseUrl?: string;
|
||||
apiKey?: string;
|
||||
secretKey?: string;
|
||||
timeoutMs?: number;
|
||||
// SMTP fields
|
||||
smtpHost?: string;
|
||||
smtpPort?: number;
|
||||
smtpSecure?: boolean;
|
||||
smtpUsername?: string;
|
||||
smtpPassword?: string;
|
||||
// Cloudflare fields
|
||||
cloudflareBindingName?: string;
|
||||
cloudflareDestinationAddress?: string;
|
||||
cloudflareAllowedDestinationAddresses?: string[];
|
||||
// push flags
|
||||
customerSendOrderPaidEmail: boolean;
|
||||
customerSendDeliverySuccessEmail: boolean;
|
||||
customerSendDeliveryFailedEmail: boolean;
|
||||
adminSendOrderPaidEmail: boolean;
|
||||
adminSendDeliverySuccessEmail: boolean;
|
||||
adminSendDeliveryFailedEmail: boolean;
|
||||
};
|
||||
|
||||
const { configs, templates, logs: initialLogs, metrics, pushSettings: initialPushSettings } = useData<Data>();
|
||||
|
||||
const activeTab = ref<"stats" | "config" | "list" | "template">("stats");
|
||||
|
||||
const apiForm = reactive({ ...(configs?.API as any) });
|
||||
const smtpForm = reactive({ ...(configs?.SMTP as any) });
|
||||
const cloudflareForm = reactive({ ...(configs?.CLOUDFLARE as any) });
|
||||
const templateList = reactive(templates.map((item) => ({ ...item })));
|
||||
// ===================== Mailbox list =====================
|
||||
const logList = reactive([...initialLogs]);
|
||||
|
||||
const activeTemplateScene = ref<"TEST" | "ORDER_PAID" | "DELIVERY_SUCCESS" | "DELIVERY_FAILED">(templateList[0]?.scene || "TEST");
|
||||
const activeTemplate = computed(() => {
|
||||
return templateList.find((t) => t.scene === activeTemplateScene.value) || templateList[0];
|
||||
});
|
||||
|
||||
const activeProviderFormType = ref<"API" | "SMTP" | "CLOUDFLARE">("API");
|
||||
const mailboxList = reactive<MailboxItem[]>(
|
||||
Array.isArray(configs) ? configs.map((c: any) => ({ ...c })) : []
|
||||
);
|
||||
|
||||
// ===================== Push settings =====================
|
||||
const pushSettings = reactive({
|
||||
customerSendOrderPaidEmail: configs?.API?.customerSendOrderPaidEmail ?? configs?.SMTP?.customerSendOrderPaidEmail ?? configs?.CLOUDFLARE?.customerSendOrderPaidEmail ?? false,
|
||||
customerSendDeliverySuccessEmail: configs?.API?.customerSendDeliverySuccessEmail ?? false,
|
||||
customerSendDeliveryFailedEmail: configs?.API?.customerSendDeliveryFailedEmail ?? false,
|
||||
adminSendOrderPaidEmail: configs?.API?.adminSendOrderPaidEmail ?? configs?.SMTP?.adminSendOrderPaidEmail ?? configs?.CLOUDFLARE?.adminSendOrderPaidEmail ?? false,
|
||||
adminSendDeliverySuccessEmail: configs?.API?.adminSendDeliverySuccessEmail ?? false,
|
||||
adminSendDeliveryFailedEmail: configs?.API?.adminSendDeliveryFailedEmail ?? false,
|
||||
customerSendOrderPaidEmail: (initialPushSettings as any)?.customerSendOrderPaidEmail ?? false,
|
||||
customerSendDeliverySuccessEmail: (initialPushSettings as any)?.customerSendDeliverySuccessEmail ?? false,
|
||||
customerSendDeliveryFailedEmail: (initialPushSettings as any)?.customerSendDeliveryFailedEmail ?? false,
|
||||
adminSendOrderPaidEmail: (initialPushSettings as any)?.adminSendOrderPaidEmail ?? false,
|
||||
adminSendDeliverySuccessEmail: (initialPushSettings as any)?.adminSendDeliverySuccessEmail ?? false,
|
||||
adminSendDeliveryFailedEmail: (initialPushSettings as any)?.adminSendDeliveryFailedEmail ?? false,
|
||||
});
|
||||
|
||||
const savingPushSettings = ref(false);
|
||||
const pushSettingsMessage = ref("");
|
||||
const pushSettingsError = ref(false);
|
||||
|
||||
const testToEmail = ref("");
|
||||
const testContent = ref("嘿!API 跑通了\n\n这是从 Cloudflare 发出的第一封邮件。");
|
||||
const cloudflareAllowedText = ref(Array.isArray((cloudflareForm as any).cloudflareAllowedDestinationAddresses) ? (cloudflareForm as any).cloudflareAllowedDestinationAddresses.join("\n") : "");
|
||||
|
||||
const savingChannel = ref<"API" | "SMTP" | "CLOUDFLARE" | "">("");
|
||||
const testingChannel = ref<"API" | "SMTP" | "CLOUDFLARE" | "">("");
|
||||
const channelMessages = reactive<Record<string, string>>({ API: "", SMTP: "", CLOUDFLARE: "" });
|
||||
const channelErrors = reactive<Record<string, boolean>>({ API: false, SMTP: false, CLOUDFLARE: false });
|
||||
|
||||
// ===================== Template =====================
|
||||
const templateList = reactive(templates.map((item: any) => ({ ...item })));
|
||||
const activeTemplateScene = ref<"TEST" | "ORDER_PAID" | "DELIVERY_SUCCESS" | "DELIVERY_FAILED">(templateList[0]?.scene || "TEST");
|
||||
const activeTemplate = computed(() => {
|
||||
return templateList.find((t: any) => t.scene === activeTemplateScene.value) || templateList[0];
|
||||
});
|
||||
const savingTemplate = ref<"TEST" | "ORDER_PAID" | "DELIVERY_SUCCESS" | "DELIVERY_FAILED" | "">("");
|
||||
const templateMessages = reactive<Record<string, string>>({ TEST: "", ORDER_PAID: "", DELIVERY_SUCCESS: "", DELIVERY_FAILED: "" });
|
||||
const templateErrors = reactive<Record<string, boolean>>({ TEST: false, ORDER_PAID: false, DELIVERY_SUCCESS: false, DELIVERY_FAILED: false });
|
||||
|
||||
// ===================== Config dialog =====================
|
||||
const showConfigDialog = ref(false);
|
||||
const editingId = ref<number | null>(null);
|
||||
const savingConfig = ref(false);
|
||||
const configDialogMessage = ref("");
|
||||
const configDialogError = ref(false);
|
||||
|
||||
interface ConfigFormState {
|
||||
name: string;
|
||||
provider: "API" | "SMTP" | "CLOUDFLARE";
|
||||
fromEmail: string;
|
||||
fromName: string;
|
||||
replyTo: string;
|
||||
// API
|
||||
apiProvider: "BREVO" | "MAILJET";
|
||||
apiBaseUrl: string;
|
||||
apiKey: string;
|
||||
secretKey: string;
|
||||
timeoutMs: number;
|
||||
// SMTP
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpSecure: boolean;
|
||||
smtpUsername: string;
|
||||
smtpPassword: string;
|
||||
// Cloudflare
|
||||
cloudflareBindingName: string;
|
||||
cloudflareDestinationAddress: string;
|
||||
cloudflareAllowedText: string;
|
||||
}
|
||||
|
||||
function createEmptyForm(): ConfigFormState {
|
||||
return {
|
||||
name: "",
|
||||
provider: "API",
|
||||
fromEmail: "",
|
||||
fromName: "",
|
||||
replyTo: "",
|
||||
apiProvider: "BREVO",
|
||||
apiBaseUrl: "https://api.brevo.com/v3/smtp/email",
|
||||
apiKey: "",
|
||||
secretKey: "",
|
||||
timeoutMs: 10000,
|
||||
smtpHost: "",
|
||||
smtpPort: 587,
|
||||
smtpSecure: false,
|
||||
smtpUsername: "",
|
||||
smtpPassword: "",
|
||||
cloudflareBindingName: "",
|
||||
cloudflareDestinationAddress: "",
|
||||
cloudflareAllowedText: "",
|
||||
};
|
||||
}
|
||||
|
||||
const configForm = reactive<ConfigFormState>(createEmptyForm());
|
||||
|
||||
function openCreateDialog() {
|
||||
editingId.value = null;
|
||||
Object.assign(configForm, createEmptyForm());
|
||||
configDialogMessage.value = "";
|
||||
configDialogError.value = false;
|
||||
showConfigDialog.value = true;
|
||||
}
|
||||
|
||||
function openEditDialog(item: MailboxItem) {
|
||||
editingId.value = item.id ?? null;
|
||||
Object.assign(configForm, {
|
||||
name: item.name || "",
|
||||
provider: item.provider,
|
||||
fromEmail: item.fromEmail || "",
|
||||
fromName: item.fromName || "",
|
||||
replyTo: item.replyTo || "",
|
||||
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,
|
||||
smtpSecure: (item as any).smtpSecure || false,
|
||||
smtpUsername: (item as any).smtpUsername || "",
|
||||
smtpPassword: (item as any).smtpPassword || "",
|
||||
cloudflareBindingName: (item as any).cloudflareBindingName || "",
|
||||
cloudflareDestinationAddress: (item as any).cloudflareDestinationAddress || "",
|
||||
cloudflareAllowedText: Array.isArray((item as any).cloudflareAllowedDestinationAddresses)
|
||||
? (item as any).cloudflareAllowedDestinationAddresses.join("\n")
|
||||
: "",
|
||||
});
|
||||
configDialogMessage.value = "";
|
||||
configDialogError.value = false;
|
||||
showConfigDialog.value = true;
|
||||
}
|
||||
|
||||
function closeConfigDialog() {
|
||||
showConfigDialog.value = false;
|
||||
}
|
||||
|
||||
// ===================== Test modal =====================
|
||||
const showTestModal = ref(false);
|
||||
const testingMailboxId = ref<number | null>(null);
|
||||
const testingMailboxName = ref("");
|
||||
const testToEmail = ref("");
|
||||
const testContent = ref("嘿!API 跑通了\n\n这是一封测试邮件。");
|
||||
const isTesting = ref(false);
|
||||
const testModalMessage = ref("");
|
||||
const testModalError = ref(false);
|
||||
|
||||
function openTestModal(item: MailboxItem) {
|
||||
testingMailboxId.value = item.id ?? null;
|
||||
testingMailboxName.value = item.name || getChannelLabel(item.provider);
|
||||
testModalMessage.value = "";
|
||||
testModalError.value = false;
|
||||
showTestModal.value = true;
|
||||
}
|
||||
|
||||
function closeTestModal() {
|
||||
showTestModal.value = false;
|
||||
}
|
||||
|
||||
// ===================== Delete confirm =====================
|
||||
const showDeleteConfirm = ref(false);
|
||||
const deletingId = ref<number | null>(null);
|
||||
const deletingMailboxName = ref("");
|
||||
const deleting = ref(false);
|
||||
const deleteMessage = ref("");
|
||||
|
||||
function handleDelete(item: MailboxItem) {
|
||||
if (item.isEnabled) return;
|
||||
deletingId.value = item.id ?? null;
|
||||
deletingMailboxName.value = item.name || getChannelLabel(item.provider);
|
||||
deleteMessage.value = "";
|
||||
showDeleteConfirm.value = true;
|
||||
}
|
||||
|
||||
function closeDeleteConfirm() {
|
||||
showDeleteConfirm.value = false;
|
||||
}
|
||||
|
||||
// ===================== Clear logs =====================
|
||||
const showClearConfirm = ref(false);
|
||||
const clearingLogs = ref(false);
|
||||
|
||||
async function handleClearLogs() {
|
||||
clearingLogs.value = true;
|
||||
try {
|
||||
await onClearEmailLogs();
|
||||
logList.splice(0);
|
||||
showClearConfirm.value = false;
|
||||
} catch (error) {
|
||||
alert(normalizeTelefuncError(error, "清除失败"));
|
||||
} finally {
|
||||
clearingLogs.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== Helpers =====================
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleString("zh-CN");
|
||||
}
|
||||
@@ -408,40 +717,13 @@ function getChannelLabel(provider: string) {
|
||||
return ({ API: "API", SMTP: "SMTP", CLOUDFLARE: "CloudFlare" } as Record<string, string>)[provider] || provider;
|
||||
}
|
||||
|
||||
function getChannelForm(channel: "API" | "SMTP" | "CLOUDFLARE") {
|
||||
if (channel === "API") return apiForm;
|
||||
if (channel === "SMTP") return smtpForm;
|
||||
return cloudflareForm;
|
||||
}
|
||||
|
||||
// ===================== Actions =====================
|
||||
async function handleSavePushSettings() {
|
||||
savingPushSettings.value = true;
|
||||
pushSettingsMessage.value = "";
|
||||
pushSettingsError.value = false;
|
||||
try {
|
||||
await onSaveEmailPushSettings({ ...pushSettings });
|
||||
// 同步到本地表单状态,避免下次保存邮局时覆盖
|
||||
apiForm.customerSendOrderPaidEmail = pushSettings.customerSendOrderPaidEmail;
|
||||
apiForm.customerSendDeliverySuccessEmail = pushSettings.customerSendDeliverySuccessEmail;
|
||||
apiForm.customerSendDeliveryFailedEmail = pushSettings.customerSendDeliveryFailedEmail;
|
||||
apiForm.adminSendOrderPaidEmail = pushSettings.adminSendOrderPaidEmail;
|
||||
apiForm.adminSendDeliverySuccessEmail = pushSettings.adminSendDeliverySuccessEmail;
|
||||
apiForm.adminSendDeliveryFailedEmail = pushSettings.adminSendDeliveryFailedEmail;
|
||||
|
||||
smtpForm.customerSendOrderPaidEmail = pushSettings.customerSendOrderPaidEmail;
|
||||
smtpForm.customerSendDeliverySuccessEmail = pushSettings.customerSendDeliverySuccessEmail;
|
||||
smtpForm.customerSendDeliveryFailedEmail = pushSettings.customerSendDeliveryFailedEmail;
|
||||
smtpForm.adminSendOrderPaidEmail = pushSettings.adminSendOrderPaidEmail;
|
||||
smtpForm.adminSendDeliverySuccessEmail = pushSettings.adminSendDeliverySuccessEmail;
|
||||
smtpForm.adminSendDeliveryFailedEmail = pushSettings.adminSendDeliveryFailedEmail;
|
||||
|
||||
cloudflareForm.customerSendOrderPaidEmail = pushSettings.customerSendOrderPaidEmail;
|
||||
cloudflareForm.customerSendDeliverySuccessEmail = pushSettings.customerSendDeliverySuccessEmail;
|
||||
cloudflareForm.customerSendDeliveryFailedEmail = pushSettings.customerSendDeliveryFailedEmail;
|
||||
cloudflareForm.adminSendOrderPaidEmail = pushSettings.adminSendOrderPaidEmail;
|
||||
cloudflareForm.adminSendDeliverySuccessEmail = pushSettings.adminSendDeliverySuccessEmail;
|
||||
cloudflareForm.adminSendDeliveryFailedEmail = pushSettings.adminSendDeliveryFailedEmail;
|
||||
|
||||
pushSettingsMessage.value = "推送设置保存成功";
|
||||
} catch (error) {
|
||||
pushSettingsError.value = true;
|
||||
@@ -451,76 +733,121 @@ async function handleSavePushSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveConfig(channel: "API" | "SMTP" | "CLOUDFLARE") {
|
||||
savingChannel.value = channel;
|
||||
channelMessages[channel] = "";
|
||||
channelErrors[channel] = false;
|
||||
async function handleSaveConfig() {
|
||||
savingConfig.value = true;
|
||||
configDialogMessage.value = "";
|
||||
configDialogError.value = false;
|
||||
|
||||
try {
|
||||
const form = getChannelForm(channel) as any;
|
||||
const payload = {
|
||||
...form,
|
||||
provider: channel,
|
||||
// 使用当前最新的推送设置
|
||||
const payload: Record<string, unknown> = {
|
||||
provider: configForm.provider,
|
||||
name: configForm.name,
|
||||
fromEmail: configForm.fromEmail,
|
||||
fromName: configForm.fromName,
|
||||
replyTo: configForm.replyTo,
|
||||
// always carry current push settings
|
||||
customerSendOrderPaidEmail: pushSettings.customerSendOrderPaidEmail,
|
||||
customerSendDeliverySuccessEmail: pushSettings.customerSendDeliverySuccessEmail,
|
||||
customerSendDeliveryFailedEmail: pushSettings.customerSendDeliveryFailedEmail,
|
||||
adminSendOrderPaidEmail: pushSettings.adminSendOrderPaidEmail,
|
||||
adminSendDeliverySuccessEmail: pushSettings.adminSendDeliverySuccessEmail,
|
||||
adminSendDeliveryFailedEmail: pushSettings.adminSendDeliveryFailedEmail,
|
||||
cloudflareAllowedDestinationAddresses: channel === "CLOUDFLARE" ? cloudflareAllowedText.value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean) : undefined,
|
||||
};
|
||||
const result = await onSaveEmailConfig(payload);
|
||||
Object.assign(form, result);
|
||||
if (channel === "CLOUDFLARE") {
|
||||
cloudflareAllowedText.value = Array.isArray((result as any).cloudflareAllowedDestinationAddresses)
|
||||
? (result as any).cloudflareAllowedDestinationAddresses.join("\n")
|
||||
: "";
|
||||
|
||||
if (editingId.value) {
|
||||
payload.id = editingId.value;
|
||||
}
|
||||
channelMessages[channel] = "保存成功";
|
||||
|
||||
if (configForm.provider === "API") {
|
||||
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;
|
||||
payload.smtpPort = configForm.smtpPort;
|
||||
payload.smtpSecure = configForm.smtpSecure;
|
||||
payload.smtpUsername = configForm.smtpUsername;
|
||||
payload.smtpPassword = configForm.smtpPassword;
|
||||
} else {
|
||||
payload.cloudflareBindingName = configForm.cloudflareBindingName;
|
||||
payload.cloudflareDestinationAddress = configForm.cloudflareDestinationAddress;
|
||||
payload.cloudflareAllowedDestinationAddresses = configForm.cloudflareAllowedText
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const result = await onSaveEmailConfig(payload) as any;
|
||||
|
||||
if (editingId.value) {
|
||||
// Update existing entry in list
|
||||
const idx = mailboxList.findIndex((m) => m.id === editingId.value);
|
||||
if (idx >= 0) {
|
||||
Object.assign(mailboxList[idx], result);
|
||||
}
|
||||
} else {
|
||||
// Add to list
|
||||
mailboxList.push({ ...result });
|
||||
}
|
||||
|
||||
configDialogMessage.value = editingId.value ? "更新成功" : "创建成功";
|
||||
// Close dialog after short delay on success
|
||||
setTimeout(() => {
|
||||
showConfigDialog.value = false;
|
||||
}, 600);
|
||||
} catch (error) {
|
||||
channelErrors[channel] = true;
|
||||
channelMessages[channel] = normalizeTelefuncError(error, "保存失败");
|
||||
configDialogError.value = true;
|
||||
configDialogMessage.value = normalizeTelefuncError(error, "保存失败");
|
||||
} finally {
|
||||
savingChannel.value = "";
|
||||
savingConfig.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function editProvider(channel: "API" | "SMTP" | "CLOUDFLARE") {
|
||||
activeProviderFormType.value = channel;
|
||||
document.getElementById("provider-form")?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
async function handleActivateProvider(channel: "API" | "SMTP" | "CLOUDFLARE") {
|
||||
async function handleActivate(item: MailboxItem) {
|
||||
if (!item.id || item.isEnabled) return;
|
||||
try {
|
||||
await onActivateEmailProvider(channel);
|
||||
apiForm.isEnabled = channel === "API";
|
||||
smtpForm.isEnabled = channel === "SMTP";
|
||||
cloudflareForm.isEnabled = channel === "CLOUDFLARE";
|
||||
await onActivateEmailProvider(item.id);
|
||||
// Update all items in the list
|
||||
for (const m of mailboxList) {
|
||||
m.isEnabled = m.id === item.id;
|
||||
}
|
||||
} catch (error) {
|
||||
alert(normalizeTelefuncError(error, "激活失败"));
|
||||
}
|
||||
}
|
||||
|
||||
function openTestModal(channel: "API" | "SMTP" | "CLOUDFLARE") {
|
||||
testingChannel.value = channel;
|
||||
showTestModal.value = true;
|
||||
testModalMessage.value = "";
|
||||
testModalError.value = false;
|
||||
async function confirmDelete() {
|
||||
if (!deletingId.value) return;
|
||||
deleting.value = true;
|
||||
deleteMessage.value = "";
|
||||
try {
|
||||
await onDeleteEmailConfig(deletingId.value);
|
||||
const idx = mailboxList.findIndex((m) => m.id === deletingId.value);
|
||||
if (idx >= 0) {
|
||||
mailboxList.splice(idx, 1);
|
||||
}
|
||||
showDeleteConfirm.value = false;
|
||||
} catch (error) {
|
||||
deleteMessage.value = normalizeTelefuncError(error, "删除失败");
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeTestModal() {
|
||||
showTestModal.value = false;
|
||||
}
|
||||
|
||||
async function handleSendTest(channel: "API" | "SMTP" | "CLOUDFLARE") {
|
||||
async function handleSendTest() {
|
||||
if (!testingMailboxId.value) return;
|
||||
isTesting.value = true;
|
||||
testModalMessage.value = "";
|
||||
testModalError.value = false;
|
||||
|
||||
try {
|
||||
await handleSaveConfig(channel);
|
||||
await onSendTestEmail({ toEmail: testToEmail.value, customContent: testContent.value });
|
||||
await onSendTestEmail({
|
||||
toEmail: testToEmail.value,
|
||||
customContent: testContent.value,
|
||||
configId: testingMailboxId.value,
|
||||
});
|
||||
testModalMessage.value = "测试邮件发送成功";
|
||||
} catch (error) {
|
||||
testModalError.value = true;
|
||||
@@ -536,7 +863,7 @@ async function handleSaveTemplate(scene: "TEST" | "ORDER_PAID" | "DELIVERY_SUCCE
|
||||
templateErrors[scene] = false;
|
||||
|
||||
try {
|
||||
const target = templateList.find((item) => item.scene === scene);
|
||||
const target = templateList.find((item: any) => item.scene === scene);
|
||||
if (!target) return;
|
||||
const result = await onSaveEmailTemplate({ ...target });
|
||||
Object.assign(target, result);
|
||||
@@ -548,4 +875,4 @@ async function handleSaveTemplate(scene: "TEST" | "ORDER_PAID" | "DELIVERY_SUCCE
|
||||
savingTemplate.value = "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getEmailManagementData } from "../../../modules/email/service";
|
||||
|
||||
export type Data = ReturnType<typeof data>;
|
||||
export type Data = Awaited<ReturnType<typeof data>>;
|
||||
|
||||
export async function data(pageContext: {
|
||||
prisma: import("../../../generated/prisma/client").PrismaClient;
|
||||
@@ -8,10 +8,18 @@ export async function data(pageContext: {
|
||||
}) {
|
||||
if (pageContext.session?.user?.role !== "admin") {
|
||||
return {
|
||||
configs: null,
|
||||
configs: [],
|
||||
templates: [],
|
||||
logs: [],
|
||||
metrics: [],
|
||||
pushSettings: {
|
||||
customerSendOrderPaidEmail: false,
|
||||
customerSendDeliverySuccessEmail: false,
|
||||
customerSendDeliveryFailedEmail: false,
|
||||
adminSendOrderPaidEmail: false,
|
||||
adminSendDeliverySuccessEmail: false,
|
||||
adminSendDeliveryFailedEmail: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { assertAdminAccess } from "../../../modules/auth/service";
|
||||
import { activateEmailProvider, saveEmailConfig, saveEmailPushSettings } from "../../../modules/email/service";
|
||||
import { activateEmailProvider, clearEmailLogs, deleteEmailConfig, saveEmailConfig, saveEmailPushSettings } from "../../../modules/email/service";
|
||||
|
||||
export async function onSaveEmailConfig(input: Record<string, unknown>) {
|
||||
assertAdminAccess();
|
||||
return saveEmailConfig(input as any);
|
||||
}
|
||||
|
||||
export async function onDeleteEmailConfig(id: number) {
|
||||
assertAdminAccess();
|
||||
return deleteEmailConfig(id);
|
||||
}
|
||||
|
||||
export async function onSaveEmailPushSettings(input: {
|
||||
customerSendOrderPaidEmail: boolean;
|
||||
customerSendDeliverySuccessEmail: boolean;
|
||||
@@ -18,7 +23,12 @@ export async function onSaveEmailPushSettings(input: {
|
||||
return saveEmailPushSettings(input);
|
||||
}
|
||||
|
||||
export async function onActivateEmailProvider(provider: "API" | "SMTP" | "CLOUDFLARE") {
|
||||
export async function onActivateEmailProvider(id: number) {
|
||||
assertAdminAccess();
|
||||
return activateEmailProvider(provider);
|
||||
return activateEmailProvider(id);
|
||||
}
|
||||
|
||||
export async function onClearEmailLogs() {
|
||||
assertAdminAccess();
|
||||
return clearEmailLogs();
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { sendTestEmail } from "../../../modules/email/service";
|
||||
export async function onSendTestEmail(input: {
|
||||
toEmail: string;
|
||||
customContent?: string;
|
||||
configId?: number;
|
||||
}) {
|
||||
assertAdminAccess();
|
||||
return sendTestEmail(input);
|
||||
|
||||
@@ -245,7 +245,7 @@ CREATE INDEX "PaymentLog_orderNo_idx" ON "PaymentLog"("orderNo");
|
||||
CREATE INDEX "PaymentLog_orderId_idx" ON "PaymentLog"("orderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "EmailConfig_provider_key" ON "EmailConfig"("provider");
|
||||
CREATE INDEX "EmailConfig_provider_idx" ON "EmailConfig"("provider");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "EmailTemplate_scene_key" ON "EmailTemplate"("scene");
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
|
||||
@@ -273,12 +273,14 @@ model PaymentLog {
|
||||
|
||||
model EmailConfig {
|
||||
id Int @id @default(autoincrement())
|
||||
provider EmailChannel @unique
|
||||
provider EmailChannel
|
||||
name String
|
||||
isEnabled Boolean @default(false)
|
||||
configJson String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([provider])
|
||||
}
|
||||
|
||||
model EmailTemplate {
|
||||
|
||||
Reference in New Issue
Block a user