feat(server): 完成 F15 邮件通知(SMTP/Nodemailer)\n\n- 新增 email 服务(EMAIL_ENABLED + SMTP_* 配置)\n- 待办/审批/付款/密码重置触发可选邮件,失败降级不阻塞\n- 更新 server/README 邮件配置说明,并推进 CSV 状态为 DONE
这个提交包含在:
@@ -0,0 +1,165 @@
|
||||
const nodemailer = require("nodemailer");
|
||||
|
||||
function resolveEmailBaseUrl(config) {
|
||||
const explicit = String(config.emailBaseUrl ?? "").trim();
|
||||
if (explicit) {
|
||||
return explicit.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
const origin = String(config.appOrigin ?? "").split(",")[0]?.trim?.() ?? "";
|
||||
if (origin) {
|
||||
return origin.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
let cachedTransportKey = null;
|
||||
let cachedTransporter = null;
|
||||
|
||||
function getTransporter(config) {
|
||||
if (!config.emailEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!config.smtpHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authUser = String(config.smtpUser ?? "").trim();
|
||||
const authPass = String(config.smtpPass ?? "").trim();
|
||||
|
||||
const key = JSON.stringify({
|
||||
host: config.smtpHost,
|
||||
port: config.smtpPort,
|
||||
secure: config.smtpSecure,
|
||||
user: authUser,
|
||||
});
|
||||
|
||||
if (cachedTransporter && cachedTransportKey === key) {
|
||||
return cachedTransporter;
|
||||
}
|
||||
|
||||
cachedTransportKey = key;
|
||||
cachedTransporter = nodemailer.createTransport({
|
||||
host: config.smtpHost,
|
||||
port: config.smtpPort,
|
||||
secure: config.smtpSecure,
|
||||
auth: authUser ? { user: authUser, pass: authPass } : undefined,
|
||||
});
|
||||
|
||||
return cachedTransporter;
|
||||
}
|
||||
|
||||
async function sendNotificationEmailSafe(params) {
|
||||
try {
|
||||
return await sendNotificationEmail(params);
|
||||
} catch (error) {
|
||||
params.logger?.error?.(
|
||||
{
|
||||
err: error,
|
||||
requestId: params.requestId,
|
||||
notificationType: params.type,
|
||||
recipients: params.recipientEmployeeIds?.length ?? 0,
|
||||
},
|
||||
"Failed to send notification email",
|
||||
);
|
||||
return { skipped: false, sent: 0, failed: params.recipientEmployeeIds?.length ?? 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async function sendNotificationEmail({
|
||||
prisma,
|
||||
config,
|
||||
logger,
|
||||
requestId,
|
||||
recipientEmployeeIds,
|
||||
subject,
|
||||
body,
|
||||
type = "notification",
|
||||
targetPath,
|
||||
}) {
|
||||
if (!config.emailEnabled) {
|
||||
return { skipped: true, sent: 0, failed: 0 };
|
||||
}
|
||||
|
||||
const transporter = getTransporter(config);
|
||||
if (!transporter) {
|
||||
logger?.warn?.({ requestId }, "Email enabled but transporter not configured");
|
||||
return { skipped: true, sent: 0, failed: 0 };
|
||||
}
|
||||
|
||||
const recipients = Array.from(new Set((recipientEmployeeIds ?? []).filter(Boolean)));
|
||||
if (!recipients.length) {
|
||||
return { skipped: true, sent: 0, failed: 0 };
|
||||
}
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: {
|
||||
id: { in: recipients },
|
||||
status: "active",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emailFrom = String(config.emailFrom ?? "").trim();
|
||||
if (!emailFrom) {
|
||||
logger?.warn?.({ requestId }, "Email enabled but EMAIL_FROM is empty");
|
||||
return { skipped: true, sent: 0, failed: 0 };
|
||||
}
|
||||
|
||||
const baseUrl = resolveEmailBaseUrl(config);
|
||||
const link = targetPath && baseUrl ? `${baseUrl}${targetPath}` : null;
|
||||
const safeSubject = String(subject ?? "").trim().slice(0, 200) || "系统通知";
|
||||
const safeBody = String(body ?? "").trim();
|
||||
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const employee of employees) {
|
||||
if (!employee.email) {
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = link ? `${safeBody}\n\n查看详情:${link}` : safeBody;
|
||||
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: emailFrom,
|
||||
to: employee.email,
|
||||
subject: safeSubject,
|
||||
text,
|
||||
headers: {
|
||||
"x-hyb-notification-type": type,
|
||||
"x-request-id": requestId ?? "",
|
||||
},
|
||||
});
|
||||
sent += 1;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
logger?.error?.(
|
||||
{
|
||||
err: error,
|
||||
requestId,
|
||||
employeeId: employee.id,
|
||||
email: employee.email,
|
||||
notificationType: type,
|
||||
},
|
||||
"Email send failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { skipped: false, sent, failed };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendNotificationEmailSafe,
|
||||
sendNotificationEmail,
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户