email.js 3.66 KB
/**
 * createEmail —— 编排层。
 * 职责:按 provider 选实现、send 前校验、sendTemplate = 渲染 + send、把厂商错误归一为 EmailError。
 * 不碰具体投递协议(那在 providers/<vendor>.js),不生成/校验验证码(那是 @drama/auth)。
 */

import { EmailError, mapError } from './map-error.js'
import { assertSendable } from './validate.js'
import { renderTemplate } from './templates/index.js'
import { createNodemailerProvider } from './providers/nodemailer.js'
import { createSesProvider } from './providers/ses.js'

/** provider 名 → 工厂。加厂商 = 加 providers/<vendor>.js + 这里登记一行。 */
const PROVIDERS = {
  nodemailer: createNodemailerProvider,
  ses: createSesProvider,
}

/**
 * @typedef {Object} Email
 * @property {(msg: import('./index.js').SendMessage) => Promise<import('./index.js').SendResult>} send
 * @property {(name: string, data: object) => Promise<import('./index.js').SendResult>} sendTemplate
 */

/**
 * 唯一入口工厂。SMTP/SES 凭据与发件域由调用方(composition root)注入,包内不读 env。
 *
 * @param {Object} [opts]
 * @param {string} [opts.provider]  'nodemailer' | 'ses'(缺省 'nodemailer')
 * @param {any} [opts.credentials]
 * @param {import('./index.js').Provider} [opts.providerImpl]  直接注入 provider(测试用,绕过内建表)
 * @returns {Email}
 */
export function createEmail({ provider = 'nodemailer', credentials, providerImpl } = {}) {
  const impl = resolveProvider({ provider, credentials, providerImpl })
  const providerName = provider

  /** @type {Email['send']} */
  async function send(msg) {
    assertSendable(msg, providerName)
    try {
      const result = await impl.send(msg)
      return result
    } catch (err) {
      throw mapError(err, providerName)
    }
  }

  /**
   * @param {string} name
   * @param {any} data
   * @returns {Promise<import('./index.js').SendResult>}
   */
  async function sendTemplate(name, data) {
    if (!data || typeof data !== 'object') {
      throw new EmailError({ provider: providerName, reason: 'badTemplate', retryable: false, message: `template "${name}": data required` })
    }
    // 渲染是纯函数,badTemplate / 缺字段在此抛出(早于任何投递)。
    const rendered = renderTemplate(name, data)
    return send({
      to: data.to,
      subject: rendered.subject,
      html: rendered.html,
      text: rendered.text,
      from: data.from,
      reply_to: data.reply_to,
      cc: data.cc,
      bcc: data.bcc,
    })
  }

  return { send, sendTemplate }
}

/**
 * @param {{ provider: string, credentials: any, providerImpl?: import('./index.js').Provider }} args
 * @returns {import('./index.js').Provider}
 */
function resolveProvider({ provider, credentials, providerImpl }) {
  if (providerImpl) {
    if (typeof providerImpl.send !== 'function') {
      throw new EmailError({ provider: 'unknown', reason: 'unknown', retryable: false, message: 'providerImpl must expose send()' })
    }
    return providerImpl
  }

  const factory = PROVIDERS[provider]
  if (!factory) {
    const supported = Object.keys(PROVIDERS).join(', ')
    throw new EmailError({
      provider: String(provider),
      reason: 'unknown',
      retryable: false,
      message: `unknown provider "${provider}". supported: ${supported}`,
    })
  }
  if (!credentials || typeof credentials !== 'object') {
    throw new EmailError({
      provider,
      reason: 'auth',
      retryable: false,
      message: `provider "${provider}" requires credentials`,
    })
  }
  return factory(credentials)
}

/** @returns {string[]} 支持的内建 provider 名 */
export function supportedProviders() {
  return Object.keys(PROVIDERS)
}