feat(邮件): 支持多邮局配置管理

- 将邮件配置从单例改为多实例,支持创建、编辑、删除多个配置
- 修改数据库模型,移除 provider 唯一约束,添加索引
- 新增配置激活、删除、清空日志功能
- 重构服务层,统一推送设置同步逻辑
- 更新前端接口,支持配置 ID 参数传递
This commit is contained in:
ggyy
2026-04-22 18:48:03 +08:00
parent 959804eecb
commit ef8d17719f
11 changed files with 922 additions and 339 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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");

View File

@@ -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"

View File

@@ -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 {