email.js 4.91 KB
/**
 * 发信端口:验证码(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', path: string, agent: string, task?: string, question?: string }} notice
   */
  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 result = await email.sendTemplate('voko-notice', { to: owner.email, from, locale: String(owner.locale).startsWith('zh') ? 'zh' : 'en', url: `${env.publicUrl}${path}`, ...notice })
    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 })
    },
  }

  return { authEmail, accessNotifier, ownerNotifier }
}

/**
 * @param {unknown} value
 * @returns {string}
 */
function emailLocale(value) {
  return EMAIL_LOCALES.includes(/** @type {string} */ (value)) ? /** @type {string} */ (value) : 'en'
}