email.js 2.95 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,
        })
      }
    },
  }

  return { authEmail, accessNotifier }
}

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