/** * 发信端口:验证码(auth 的 EmailPort.sendCode)与加入申请通过通知。 * 每次投递写入 email_delivery_logs。本文件不读 `process.env`,配置经 env 注入。 */ import { createEmail } from '@drama/email' import { recordEmailDelivery } from './email-delivery-log.js' const EMAIL_LOCALES = Object.freeze(['en', 'zh', 'ja', 'ko', 'es', 'pt']) /** * @param {{ env: import('../env.js').VokoEnv, db: import('knex').Knex, logger: { error: Function } }} deps */ export function createEmailPorts({ env, db, logger }) { const { host, port, secure, user, pass, from } = env.email const email = createEmail({ provider: 'nodemailer', credentials: { host, port, secure, auth: { user, pass }, defaultFrom: from }, }) /** @type {import('@drama/auth').EmailPort} */ const authEmail = { async sendCode({ to, email: address, code, purpose, locale }) { const toEmail = to || address const result = await email.sendTemplate('verify-code', { to: toEmail, from, code, ttlMin: 10, locale: emailLocale(locale), }) await recordEmailDelivery(db, { provider: result.provider || 'nodemailer', messageId: result.messageId || null, purpose: purpose || 'verify', toEmail, status: 'sent', template: 'verify-code', metadata: env.nodeEnv === 'production' ? null : { code }, }) }, } const accessNotifier = { /** * 申请通过通知。暂复用 waitlist-invite 模板(文案为"邀请已就绪,完成注册"),链接指向登录页。 * @param {object} _ctx * @param {{ to_email: string }} input */ async sendApproved(_ctx, { to_email }) { try { const result = await email.sendTemplate('waitlist-invite', { to: to_email, from, locale: 'en', inviteUrl: `${env.publicUrl}/login`, }) await recordEmailDelivery(db, { provider: result.provider || 'nodemailer', messageId: result.message_id || null, purpose: 'access_approved', toEmail: to_email, status: 'sent', template: 'waitlist-invite', }) } catch (err) { // 外部 IO 的最小包裹:审核结果已提交,通知失败只记录,不回滚审核。 const errorMessage = err instanceof Error ? err.message : String(err) logger.error('申请通过通知投递失败', { to_email, error: errorMessage }) await recordEmailDelivery(db, { provider: 'nodemailer', purpose: 'access_approved', toEmail: to_email, status: 'failed', template: 'waitlist-invite', errorMessage, }) } }, } /** * 给主人发一封 voko-notice 并记投递日志。主人邮箱与语言取自 users 行。 * @param {string} user_id * @param {string} purpose * @param {{ kind: 'claimed'|'help'|'digest', path: string, agent?: string, task?: string, question?: string, summary?: (locale: 'en'|'zh') => string }} notice `summary` 按主人的语言生成正文 */ async function sendOwnerNotice(user_id, purpose, { path, ...notice }) { const owner = await db('users').where({ id: user_id }).first('email', 'locale') if (owner === undefined) throw new Error(`users row missing for owner notice, id: ${user_id}`) const locale = String(owner.locale).startsWith('zh') ? 'zh' : 'en' const result = await email.sendTemplate('voko-notice', { to: owner.email, from, locale, url: `${env.publicUrl}${path}`, ...notice, ...(notice.summary === undefined ? {} : { summary: notice.summary(locale) }) }) await recordEmailDelivery(db, { provider: result.provider || 'nodemailer', messageId: result.message_id || null, purpose, toEmail: owner.email, status: 'sent', template: 'voko-notice' }) } const ownerNotifier = { /** * 认领确认。 * @param {object} _ctx * @param {{ user_id: string, agent_name: string }} input */ async sendAgentClaimed(_ctx, { user_id, agent_name }) { try { await sendOwnerNotice(user_id, 'agent_claimed', { kind: 'claimed', path: '/agents', agent: agent_name }) } catch (err) { // 外部 IO 的最小包裹:认领已提交,确认邮件失败只记录,不影响认领结果。 logger.error('认领确认邮件投递失败', { user_id, error: err instanceof Error ? err.message : String(err) }) } }, /** * 求助通知。由 worker 的 job 调用,失败直接抛出,由 job 退避重试。 * @param {object} _ctx * @param {{ user_id: string, agent_name: string, task_title: string, question: string }} input */ async sendHelp(_ctx, { user_id, agent_name, task_title, question }) { await sendOwnerNotice(user_id, 'help_requested', { kind: 'help', path: '/help', agent: agent_name, task: task_title, question }) }, /** * 日报。由 worker 的 job 调用,失败直接抛出,由 job 退避重试。 * @param {object} _ctx * @param {{ user_id: string, digest: { open_help: number, agents: Array<{ name: string, level: number, settled: number, points: number, active_tasks: number }> } }} input */ async sendDigest(_ctx, { user_id, digest }) { await sendOwnerNotice(user_id, 'daily_digest', { kind: 'digest', path: '/agents', summary: (locale) => [ ...digest.agents.map((agent) => (locale === 'zh' ? `${agent.name}(${agent.level} 级):结算 ${agent.settled} 笔,赚取 ${agent.points} 积分,在手任务 ${agent.active_tasks} 个` : `${agent.name} (level ${agent.level}): ${agent.settled} payouts, ${agent.points} points earned, ${agent.active_tasks} tasks in hand`)), locale === 'zh' ? `等你回复的求助:${digest.open_help} 条` : `Help requests waiting for you: ${digest.open_help}`, ].join('\n'), }) }, } return { authEmail, accessNotifier, ownerNotifier } } /** * @param {unknown} value * @returns {string} */ function emailLocale(value) { return EMAIL_LOCALES.includes(/** @type {string} */ (value)) ? /** @type {string} */ (value) : 'en' }