feat(server): 完成 F15 邮件通知(SMTP/Nodemailer)\n\n- 新增 email 服务(EMAIL_ENABLED + SMTP_* 配置)\n- 待办/审批/付款/密码重置触发可选邮件,失败降级不阻塞\n- 更新 server/README 邮件配置说明,并推进 CSV 状态为 DONE

这个提交包含在:
2026-02-09 19:07:32 +08:00
父节点 c9ac18e2e2
当前提交 38b66b01a6
共修改 9 个文件,包含 355 行新增和 2 行删除
+31
查看文件
@@ -5,6 +5,7 @@ const { hashPassword, randomRefreshToken, sha256Hex, verifyPassword } = require(
const { validatePassword } = require("../utils/passwordPolicy");
const { sendData } = require("../utils/reply");
const { createNotifications } = require("../services/notifications");
const { sendNotificationEmailSafe } = require("../services/email");
function getHeaderString(request, name) {
const value = request.headers[name];
@@ -678,6 +679,17 @@ async function authRoutes(fastify) {
);
}
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
type: "auth.password_reset_requested",
recipientEmployeeIds: [employee.id],
subject: "【安全】密码重置验证码",
body: `你的密码重置验证码为:${code}\n有效期至:${expiresAt.toISOString()}\n如非本人操作,请忽略并联系管理员核查账号安全。`,
});
if (fastify.config.devShowPasswordResetCode) {
fastify.log.info(
{ employeeId: employee.id, email, code, requestId: request.id },
@@ -748,6 +760,8 @@ async function authRoutes(fastify) {
const newPasswordHash = await hashPassword(newPassword);
const emailTasks = [];
await fastify.prisma.$transaction(async (tx) => {
await tx.passwordResetCode.update({
where: { id: resetCode.id },
@@ -790,6 +804,13 @@ async function authRoutes(fastify) {
targetType: "employee",
targetId: employee.id,
});
emailTasks.push({
type: "auth.password_reset_succeeded",
recipientEmployeeIds: [employee.id],
subject: "【安全】密码已重置",
body: "你的账号密码已重置成功。如非本人操作,请立即联系管理员。",
});
} catch (error) {
fastify.log.error(
{ err: error, employeeId: employee.id, requestId: request.id },
@@ -798,6 +819,16 @@ async function authRoutes(fastify) {
}
});
for (const task of emailTasks) {
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...task,
});
}
reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config));
return sendData(reply, request, {});
});