From 38b66b01a6ada72e464c0d26ede5f1b51da3255e Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 9 Feb 2026 19:07:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E5=AE=8C=E6=88=90=20F15=20?= =?UTF-8?q?=E9=82=AE=E4=BB=B6=E9=80=9A=E7=9F=A5=EF=BC=88SMTP/Nodemailer?= =?UTF-8?q?=EF=BC=89\n\n-=20=E6=96=B0=E5=A2=9E=20email=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=EF=BC=88EMAIL=5FENABLED=20+=20SMTP=5F*=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=89\n-=20=E5=BE=85=E5=8A=9E/=E5=AE=A1=E6=89=B9/?= =?UTF-8?q?=E4=BB=98=E6=AC=BE/=E5=AF=86=E7=A0=81=E9=87=8D=E7=BD=AE?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E5=8F=AF=E9=80=89=E9=82=AE=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E9=99=8D=E7=BA=A7=E4=B8=8D=E9=98=BB=E5=A1=9E?= =?UTF-8?q?\n-=20=E6=9B=B4=E6=96=B0=20server/README=20=E9=82=AE=E4=BB=B6?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=AF=B4=E6=98=8E=EF=BC=8C=E5=B9=B6=E6=8E=A8?= =?UTF-8?q?=E8=BF=9B=20CSV=20=E7=8A=B6=E6=80=81=E4=B8=BA=20DONE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-02-09_13-39-02-p5-blueprint-v3.csv | 2 +- server/README.md | 15 +- server/package-lock.json | 10 ++ server/package.json | 1 + server/src/config.js | 9 + server/src/routes/auth.js | 31 ++++ server/src/routes/reimbursements.js | 39 +++++ server/src/routes/workflow.js | 85 +++++++++ server/src/services/email/index.js | 165 ++++++++++++++++++ 9 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 server/src/services/email/index.js diff --git a/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv b/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv index b34787bf..eadde3ec 100644 --- a/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv +++ b/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv @@ -13,7 +13,7 @@ F11,OCR失败重试API(次数/频控),"新增 POST /api/invoice-pool/:id/ret F12,90天未绑定自动归档(unused_timeout),"新增作业:available/ocr_failed 超过 90 天未绑定自动归档为 archived_reason=unused_timeout;并保证不参与判重","作业可重复运行幂等;归档后仍可查询/预览;不影响全局判重历史口径","cd server && ./scripts/regression-ocr.sh",manual,DONE,DONE,DONE,"server/src/jobs/invoiceArchiveJob.js | server/src/app.js | server/README.md","[""F11""]","后续迁移到 pg-boss;当前可先 node-cron 实现" F13,signed-url审计与下载行为记录,"对 signed-url 签发与下载两类事件写 audit_logs(不记录敏感 token 明文)","无权限仍 403;有权限获取 signed-url 与下载时均可在 audit_logs 检索到对应记录","cd server && ./scripts/regression-audit.sh",manual,DONE,DONE,DONE,"server/src/routes/invoicePool.js | server/src/routes/audit.js","[""F1""]","extra 仅保存 invoiceId/actorId/expireAt 等脱敏信息" F14,站内通知基础设施(DB+API),"新增 notifications 表与 API(list/markRead),用于工作流与系统事件通知","可分页读取;可标记已读;权限隔离正确;最小索引满足试运行","cd server && npm run prisma:migrate:dev && ./scripts/regression-all.sh",manual,DONE,DONE,DONE,"server/prisma/schema.prisma | server/src/routes/notifications.js | server/src/app.js","[""F1""]","只做最小字段:title/body/type/targetId/readAt" -F15,邮件通知(SMTP/Nodemailer),"引入 nodemailer 发送邮件:新待办/审批结果/付款完成/密码重置等;失败降级不阻塞主流程;可配置开关","EMAIL_ENABLED=false 时不发送;开启后可发出测试邮件;失败仅记录 audit/日志不影响业务","manual: 配置 SMTP 并触发一次待办通知/重置密码邮件",manual,TODO,TODO,TODO,"server/src/services/email/* | server/src/config.js | server/package.json","[""F14""]","优先复用成熟 nodemailer;写回滚说明" +F15,邮件通知(SMTP/Nodemailer),"引入 nodemailer 发送邮件:新待办/审批结果/付款完成/密码重置等;失败降级不阻塞主流程;可配置开关","EMAIL_ENABLED=false 时不发送;开启后可发出测试邮件;失败仅记录 audit/日志不影响业务","manual: 配置 SMTP 并触发一次待办通知/重置密码邮件",manual,DONE,DONE,DONE,"server/src/services/email/* | server/src/config.js | server/src/routes/auth.js | server/src/routes/reimbursements.js | server/src/routes/workflow.js | server/package.json | server/README.md","[""F14""]","优先复用成熟 nodemailer;写回滚说明" F16,前端通知入口(Bell),"新增通知列表页/弹层与未读角标;支持标记已读","四角色均可见自己的通知;读写后角标正确;e2e 覆盖核心路径","cd client && npm run test:e2e",manual,DONE,DONE,DONE,"client/src/components/navigation/Topbar.tsx | client/src/pages/NotificationsPage.tsx | client/src/api/notifications.ts","[""F14""]","保持 UI 轻量,不做复杂订阅推送" F17,报表导出(CSV/Excel),"新增报销/票据/审计的导出接口(权限控制),前端提供下载入口","导出字段与权限正确;大数据量时可分页/流式输出;审计记录导出操作","manual: 导出后核对字段;并在 audit_logs 中检索 EXPORT 动作",manual,TODO,TODO,TODO,"server/src/routes/exports.js | client/src/pages/* | server/package.json","[""F6"",""F16""]","推荐 exceljs 或 csv-stringify(二选一并写 Notes:用途/版本/回滚)" F18,审批规则维护(system.settings),"补齐 approval_rules 维护 API 与最小前端页(finance_mgr/admin),支持启停与优先级/金额段维护","规则变更可立即影响新 submit;历史 instance 不回写;审计记录变更","cd server && ./scripts/regression-workflow-actions.sh",manual,TODO,TODO,TODO,"server/src/routes/approvalRules.js | client/src/pages/ApprovalRulesPage.tsx | server/src/app.js","[""F5""]","只做最小可运营;复杂可视化后置" diff --git a/server/README.md b/server/README.md index 302ca996..84e6e9c5 100644 --- a/server/README.md +++ b/server/README.md @@ -65,7 +65,20 @@ npm run seed:trialrun - 解锁:`POST /api/users/:id/unlock`(权限:`system.users`)。 - 弱密码拦截: - 采用 `zxcvbn` 评分,默认 `score < 2` 拒绝(返回 `VALID_001.details.password`)。 - - 可通过 `WEAK_PASSWORD_CHECK_ENABLED=false` 关闭(不建议生产关闭)。 +- 可通过 `WEAK_PASSWORD_CHECK_ENABLED=false` 关闭(不建议生产关闭)。 + +### 2.3.2 邮件通知(可选,F15) +- 默认关闭:`EMAIL_ENABLED=false` +- 开启后支持触发邮件(失败降级不阻塞主流程): + - 新待办分配(workflow.task_assigned) + - 审批结果(approved/rejected/returned) + - 付款完成(reimbursement.paid) + - 密码重置(forgot-password / reset-password) +- 需要配置 SMTP: + - `SMTP_HOST`、`SMTP_PORT`(默认 587)、`SMTP_SECURE`(465 通常为 true) + - `SMTP_USER`、`SMTP_PASS`(按 SMTP 服务要求) + - `EMAIL_FROM`(发件人地址) + - `EMAIL_BASE_URL`(可选,邮件内“查看详情”链接的站点前缀;为空则尝试使用 `APP_ORIGIN` 的第一个值) ### 2.4 启动服务 ```bash diff --git a/server/package-lock.json b/server/package-lock.json index 4e1f0314..3278a97c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -23,6 +23,7 @@ "fastify": "^5.7.4", "fastify-plugin": "^5.1.0", "node-cron": "^4.2.1", + "nodemailer": "^6.10.1", "pg": "^8.18.0", "prisma": "^7.3.0", "tencentcloud-sdk-nodejs": "^4.1.14", @@ -1934,6 +1935,15 @@ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nypm": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.4.tgz", diff --git a/server/package.json b/server/package.json index ff471983..7aa0dce7 100644 --- a/server/package.json +++ b/server/package.json @@ -37,6 +37,7 @@ "bcryptjs": "^3.0.3", "fastify": "^5.7.4", "fastify-plugin": "^5.1.0", + "nodemailer": "^6.10.1", "node-cron": "^4.2.1", "pg": "^8.18.0", "prisma": "^7.3.0", diff --git a/server/src/config.js b/server/src/config.js index 2e3bcd33..48a0f25d 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -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 ?? "no-reply@hyinda.com", + 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 ?? "", }; } diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 9dafadc3..c5b6c329 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -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, {}); }); diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 98ca43f8..64c83ae2 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -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); }, ); diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js index a75bffb4..f4a6c53e 100644 --- a/server/src/routes/workflow.js +++ b/server/src/routes/workflow.js @@ -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); }, ); diff --git a/server/src/services/email/index.js b/server/src/services/email/index.js new file mode 100644 index 00000000..c1cf8fe0 --- /dev/null +++ b/server/src/services/email/index.js @@ -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, +}; +