verify-code.js 4.39 KB
/**
 * 验证码模板(纯函数):data → { subject, html, text }。
 * 本模板不生成、不校验验证码——只渲染 auth 传入的 `code` 与 `ttlMin`。
 *
 * @typedef {Object} VerifyCodeData
 * @property {string} code         验证码(auth 生成)
 * @property {number} ttlMin       有效期(分钟)
 * @property {'en'|'zh'|'ja'|'ko'|'es'|'pt'} locale
 */

import { EmailError } from '../map-error.js'

const COPY = {
  en: {
    subject: (code) => `VOKO verification code: ${code}`,
    heading: 'Verify your VOKO email',
    intro: 'Use the code below to continue. Do not share it with anyone.',
    ttl: (min) => `This code expires in ${min} minute${min === 1 ? '' : 's'}.`,
    ignore: 'If you did not request this, you can safely ignore this email.',
  },
  zh: {
    subject: (code) => `VOKO 验证码:${code}`,
    heading: '验证您的 VOKO 邮箱',
    intro: '请使用下方验证码继续操作,请勿向任何人泄露。',
    ttl: (min) => `该验证码将在 ${min} 分钟后失效。`,
    ignore: '如果这不是您本人的操作,请忽略本邮件。',
  },
  ja: {
    subject: (code) => `VOKO認証コード:${code}`,
    heading: 'VOKOのメールアドレスを認証',
    intro: '続行するには、以下のコードを使用してください。他の人と共有しないでください。',
    ttl: (min) => `このコードは${min}分後に無効になります。`,
    ignore: 'このリクエストに心当たりがない場合は、このメールを無視してください。',
  },
  ko: {
    subject: (code) => `VOKO 인증 코드: ${code}`,
    heading: 'VOKO 이메일 인증',
    intro: '계속하려면 아래 코드를 사용하세요. 다른 사람과 공유하지 마세요.',
    ttl: (min) => `이 코드는 ${min}분 후에 만료됩니다.`,
    ignore: '본인이 요청하지 않았다면 이 이메일을 무시하세요.',
  },
  es: {
    subject: (code) => `Código de verificación de VOKO: ${code}`,
    heading: 'Verifica tu correo de VOKO',
    intro: 'Usa el siguiente código para continuar. No lo compartas con nadie.',
    ttl: (min) => `Este código caduca en ${min} minuto${min === 1 ? '' : 's'}.`,
    ignore: 'Si no solicitaste este código, puedes ignorar este correo.',
  },
  pt: {
    subject: (code) => `Código de verificação do VOKO: ${code}`,
    heading: 'Verifique seu e-mail do VOKO',
    intro: 'Use o código abaixo para continuar. Não o compartilhe com ninguém.',
    ttl: (min) => `Este código expira em ${min} minuto${min === 1 ? '' : 's'}.`,
    ignore: 'Se você não solicitou este código, ignore este e-mail.',
  },
}

/**
 * @param {VerifyCodeData} data
 * @returns {{ subject: string, html: string, text: string }}
 */
export function render(data) {
  if (!data || typeof data !== 'object') {
    throw new EmailError({ provider: 'template', reason: 'badTemplate', retryable: false, message: 'verify-code: data required' })
  }
  const { code, ttlMin } = data
  if (typeof code !== 'string' || code.trim() === '') {
    throw new EmailError({ provider: 'template', reason: 'badTemplate', retryable: false, message: 'verify-code: missing field "code"' })
  }
  if (typeof ttlMin !== 'number' || !Number.isFinite(ttlMin) || ttlMin <= 0) {
    throw new EmailError({ provider: 'template', reason: 'badTemplate', retryable: false, message: 'verify-code: missing or invalid field "ttlMin"' })
  }

  const c = COPY[data.locale]
  if (!c) {
    throw new EmailError({ provider: 'template', reason: 'badTemplate', retryable: false, message: `verify-code: unsupported locale: ${String(data.locale)}` })
  }

  const subject = c.subject(code)
  const ttlLine = c.ttl(ttlMin)

  const text = [c.heading, '', c.intro, '', code, '', ttlLine, '', c.ignore].join('\n')

  const html = [
    '<!doctype html><html><body style="font-family:Arial,Helvetica,sans-serif;color:#1a1a1a;">',
    `<h2 style="margin:0 0 16px;">${esc(c.heading)}</h2>`,
    `<p style="margin:0 0 16px;">${esc(c.intro)}</p>`,
    `<p style="font-size:32px;font-weight:700;letter-spacing:6px;margin:0 0 16px;">${esc(code)}</p>`,
    `<p style="margin:0 0 16px;color:#555;">${esc(ttlLine)}</p>`,
    `<p style="margin:0;color:#999;font-size:12px;">${esc(c.ignore)}</p>`,
    '</body></html>',
  ].join('')

  return { subject, html, text }
}

function esc(s) {
  return String(s)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
}