feat(server): 完成 F15 邮件通知(SMTP/Nodemailer)\n\n- 新增 email 服务(EMAIL_ENABLED + SMTP_* 配置)\n- 待办/审批/付款/密码重置触发可选邮件,失败降级不阻塞\n- 更新 server/README 邮件配置说明,并推进 CSV 状态为 DONE
这个提交包含在:
@@ -76,6 +76,15 @@ function getConfig() {
|
||||
tencentSecretId: process.env.TENCENT_SECRET_ID ?? "",
|
||||
tencentSecretKey: process.env.TENCENT_SECRET_KEY ?? "",
|
||||
tencentOcrRegion: process.env.TENCENT_OCR_REGION ?? "ap-beijing",
|
||||
|
||||
emailEnabled: process.env.EMAIL_ENABLED === "true",
|
||||
emailFrom: process.env.EMAIL_FROM ?? "[email protected]",
|
||||
emailBaseUrl: process.env.EMAIL_BASE_URL ?? "",
|
||||
smtpHost: process.env.SMTP_HOST ?? "",
|
||||
smtpPort: Number(process.env.SMTP_PORT ?? 587),
|
||||
smtpSecure: process.env.SMTP_SECURE === "true",
|
||||
smtpUser: process.env.SMTP_USER ?? "",
|
||||
smtpPass: process.env.SMTP_PASS ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ const {
|
||||
createNotifications,
|
||||
resolveRecipientsForAssignee,
|
||||
} = require("../services/notifications");
|
||||
const { sendNotificationEmailSafe } = require("../services/email");
|
||||
const {
|
||||
buildWorkflowStepsFromApprovalRule,
|
||||
resolveActionableStep,
|
||||
@@ -1141,6 +1142,7 @@ async function reimbursementRoutes(fastify) {
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
let submitResult = null;
|
||||
const emailTasks = [];
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
@@ -1365,6 +1367,14 @@ async function reimbursementRoutes(fastify) {
|
||||
targetType: "workflow_task",
|
||||
targetId: workflowTask.id,
|
||||
});
|
||||
|
||||
emailTasks.push({
|
||||
type: "workflow.task_assigned",
|
||||
recipientEmployeeIds: recipients,
|
||||
subject: `【待办】报销单 ${formNumber} 待审批`,
|
||||
body: `报销单 ${formNumber} 已提交,请及时处理。`,
|
||||
targetPath: `/workflow/tasks/${workflowTask.id}`,
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error(
|
||||
{ err: error, reimbursementId, taskId: workflowTask.id },
|
||||
@@ -1397,6 +1407,16 @@ async function reimbursementRoutes(fastify) {
|
||||
};
|
||||
});
|
||||
|
||||
for (const task of emailTasks) {
|
||||
void sendNotificationEmailSafe({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
logger: fastify.log,
|
||||
requestId: request.id,
|
||||
...task,
|
||||
});
|
||||
}
|
||||
|
||||
return sendData(reply, request, submitResult);
|
||||
},
|
||||
);
|
||||
@@ -1546,6 +1566,7 @@ async function reimbursementRoutes(fastify) {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const payInput = parsePayBody(request.body);
|
||||
let response = null;
|
||||
const emailTasks = [];
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
@@ -1635,6 +1656,14 @@ async function reimbursementRoutes(fastify) {
|
||||
targetType: "reimbursement_form",
|
||||
targetId: reimbursementId,
|
||||
});
|
||||
|
||||
emailTasks.push({
|
||||
type: "reimbursement.paid",
|
||||
recipientEmployeeIds: recipients,
|
||||
subject: `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`,
|
||||
body: `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`,
|
||||
targetPath: `/reimbursements/${reimbursementId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error(
|
||||
{ err: error, reimbursementId },
|
||||
@@ -1675,6 +1704,16 @@ async function reimbursementRoutes(fastify) {
|
||||
};
|
||||
});
|
||||
|
||||
for (const task of emailTasks) {
|
||||
void sendNotificationEmailSafe({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
logger: fastify.log,
|
||||
requestId: request.id,
|
||||
...task,
|
||||
});
|
||||
}
|
||||
|
||||
return sendData(reply, request, response);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
createNotifications,
|
||||
resolveRecipientsForAssignee,
|
||||
} = require("../services/notifications");
|
||||
const { sendNotificationEmailSafe } = require("../services/email");
|
||||
const {
|
||||
findActiveDelegationForDelegate,
|
||||
listActiveDelegationPrincipals,
|
||||
@@ -727,6 +728,7 @@ async function workflowRoutes(fastify) {
|
||||
async (request, reply) => {
|
||||
const taskId = parseTaskId(request.params?.taskId);
|
||||
let result = null;
|
||||
const emailTasks = [];
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const task = await lockWorkflowTask(tx, taskId);
|
||||
@@ -828,6 +830,14 @@ async function workflowRoutes(fastify) {
|
||||
targetType: "workflow_task",
|
||||
targetId: nextTask.id,
|
||||
});
|
||||
|
||||
emailTasks.push({
|
||||
type: "workflow.task_assigned",
|
||||
recipientEmployeeIds: recipients,
|
||||
subject: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`,
|
||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`,
|
||||
targetPath: `/workflow/tasks/${nextTask.id}`,
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error(
|
||||
{ err: error, taskId: nextTask.id },
|
||||
@@ -893,6 +903,14 @@ async function workflowRoutes(fastify) {
|
||||
targetType: "reimbursement_form",
|
||||
targetId: workflowInstance.entity_id,
|
||||
});
|
||||
|
||||
emailTasks.push({
|
||||
type: "reimbursement.approved",
|
||||
recipientEmployeeIds: recipients,
|
||||
subject: `【已批准】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已审批通过,等待财务付款。`,
|
||||
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error(
|
||||
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
|
||||
@@ -909,6 +927,16 @@ async function workflowRoutes(fastify) {
|
||||
};
|
||||
});
|
||||
|
||||
for (const task of emailTasks) {
|
||||
void sendNotificationEmailSafe({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
logger: fastify.log,
|
||||
requestId: request.id,
|
||||
...task,
|
||||
});
|
||||
}
|
||||
|
||||
return sendData(reply, request, result);
|
||||
},
|
||||
);
|
||||
@@ -922,6 +950,7 @@ async function workflowRoutes(fastify) {
|
||||
const taskId = parseTaskId(request.params?.taskId);
|
||||
const comment = parseRequiredComment(request.body);
|
||||
let result = null;
|
||||
const emailTasks = [];
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const task = await lockWorkflowTask(tx, taskId);
|
||||
@@ -992,6 +1021,14 @@ async function workflowRoutes(fastify) {
|
||||
targetType: "reimbursement_form",
|
||||
targetId: workflowInstance.entity_id,
|
||||
});
|
||||
|
||||
emailTasks.push({
|
||||
type: "reimbursement.rejected",
|
||||
recipientEmployeeIds: recipients,
|
||||
subject: `【已驳回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已被驳回:${comment}`,
|
||||
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error(
|
||||
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
|
||||
@@ -1007,6 +1044,16 @@ async function workflowRoutes(fastify) {
|
||||
};
|
||||
});
|
||||
|
||||
for (const task of emailTasks) {
|
||||
void sendNotificationEmailSafe({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
logger: fastify.log,
|
||||
requestId: request.id,
|
||||
...task,
|
||||
});
|
||||
}
|
||||
|
||||
return sendData(reply, request, result);
|
||||
},
|
||||
);
|
||||
@@ -1020,6 +1067,7 @@ async function workflowRoutes(fastify) {
|
||||
const taskId = parseTaskId(request.params?.taskId);
|
||||
const comment = parseRequiredComment(request.body);
|
||||
let result = null;
|
||||
const emailTasks = [];
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const task = await lockWorkflowTask(tx, taskId);
|
||||
@@ -1090,6 +1138,14 @@ async function workflowRoutes(fastify) {
|
||||
targetType: "reimbursement_form",
|
||||
targetId: workflowInstance.entity_id,
|
||||
});
|
||||
|
||||
emailTasks.push({
|
||||
type: "reimbursement.returned",
|
||||
recipientEmployeeIds: recipients,
|
||||
subject: `【已退回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已退回:${comment}`,
|
||||
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error(
|
||||
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
|
||||
@@ -1105,6 +1161,16 @@ async function workflowRoutes(fastify) {
|
||||
};
|
||||
});
|
||||
|
||||
for (const task of emailTasks) {
|
||||
void sendNotificationEmailSafe({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
logger: fastify.log,
|
||||
requestId: request.id,
|
||||
...task,
|
||||
});
|
||||
}
|
||||
|
||||
return sendData(reply, request, result);
|
||||
},
|
||||
);
|
||||
@@ -1118,6 +1184,7 @@ async function workflowRoutes(fastify) {
|
||||
const taskId = parseTaskId(request.params?.taskId);
|
||||
const { toApproverId, reason } = parseTransferBody(request.body);
|
||||
let result = null;
|
||||
const emailTasks = [];
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const task = await lockWorkflowTask(tx, taskId);
|
||||
@@ -1189,6 +1256,14 @@ async function workflowRoutes(fastify) {
|
||||
targetType: "workflow_task",
|
||||
targetId: taskId,
|
||||
});
|
||||
|
||||
emailTasks.push({
|
||||
type: "workflow.task_assigned",
|
||||
recipientEmployeeIds: [toApproverId],
|
||||
subject: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 转审待处理`,
|
||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已转审给你,请及时处理。`,
|
||||
targetPath: `/workflow/tasks/${taskId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error(
|
||||
{ err: error, taskId, toApproverId },
|
||||
@@ -1208,6 +1283,16 @@ async function workflowRoutes(fastify) {
|
||||
};
|
||||
});
|
||||
|
||||
for (const task of emailTasks) {
|
||||
void sendNotificationEmailSafe({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
logger: fastify.log,
|
||||
requestId: request.id,
|
||||
...task,
|
||||
});
|
||||
}
|
||||
|
||||
return sendData(reply, request, result);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户