| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285 |
- 'use strict'
- const BRAND = 'RunForge'
- /** 页脚固定说明(分段展示,避免单行过长) */
- const FOOTER_DISCLAIMER = {
- label: '本邮件由系统自动发送,请勿直接回复本邮件。',
- helpPrefix: '如需帮助,请访问 RunForge 网站提交工单,或发送邮件至',
- helpEmail: 'service@xxoo365.top'
- }
- /** 合并后的纯文本说明(供外部引用) */
- const AUTO_REPLY_NOTICE = [
- FOOTER_DISCLAIMER.label,
- `${FOOTER_DISCLAIMER.helpPrefix} ${FOOTER_DISCLAIMER.helpEmail}`
- ].join(' ')
- /**
- * Escape text for safe insertion into HTML email bodies.
- * @param {unknown} text
- * @returns {string}
- */
- function escapeHtml(text) {
- if (text === undefined || text === null) return ''
- return String(text)
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
- }
- /**
- * Preserve line breaks as <br> after escaping (for plaintext工单内容等).
- * @param {unknown} text
- * @returns {string}
- */
- function escapeNl2br(text) {
- return escapeHtml(text).replace(/\r\n|\r|\n/g, '<br>')
- }
- const ACCENTS = {
- default: '#1e40af',
- success: '#047857',
- warning: '#b45309',
- danger: '#b91c1c'
- }
- /**
- * 页脚:可选补充说明 + 固定分段提示(左对齐,避免长句居中拥挤)
- * @param {string} footerExtra
- */
- function buildFooterHtml(footerExtra) {
- const ctx = footerExtra
- ? `<div class="footer-context">${escapeHtml(footerExtra)}</div>`
- : ''
- const mail = escapeHtml(FOOTER_DISCLAIMER.helpEmail)
- return `${ctx}
- <span class="footer-meta-label">${escapeHtml(FOOTER_DISCLAIMER.label)}</span>
- <ul class="footer-meta">
- <li>${escapeHtml(FOOTER_DISCLAIMER.helpPrefix)} <a href="mailto:${mail}">${mail}</a></li>
- </ul>`
- }
- /**
- * Wrap transactional email body in shared layout (single stylesheet + structure).
- * @param {object} opts
- * @param {string} opts.pageTitle
- * @param {string} opts.headline
- * @param {'default'|'success'|'warning'|'danger'} [opts.variant]
- * @param {string} opts.bodyHtml — trusted HTML fragments assembled by callers (dynamic strings must be escaped first).
- * @param {string} [opts.footerExtra] — shortPlaintext shown above copyright (will be escaped).
- * @param {string} [opts.headlineSuffixHtml] — small trusted HTML after headline inside H1 (e.g. badge span).
- */
- function renderEmail(opts) {
- const {
- pageTitle,
- headline,
- variant = 'default',
- bodyHtml,
- footerExtra = '',
- headlineSuffixHtml = ''
- } = opts
- const accent = ACCENTS[variant] || ACCENTS.default
- const year = new Date().getFullYear()
- const footerHtml = buildFooterHtml(footerExtra)
- return `<!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
- <title>${escapeHtml(pageTitle)}</title>
- <style>
- body { margin:0; padding:0; background:#f1f5f9; -webkit-font-smoothing:antialiased; }
- .wrapper { width:100%; padding:24px 12px; box-sizing:border-box; }
- .card {
- max-width:560px;
- margin:0 auto;
- background:#ffffff;
- border-radius:8px;
- border:1px solid #e2e8f0;
- overflow:hidden;
- box-shadow:0 1px 3px rgba(15,23,42,0.08);
- }
- .brand-bar { height:4px; background:${accent}; }
- .inner { padding:28px 28px 20px; }
- .brand { font-size:11px; letter-spacing:0.12em; text-transform:uppercase; color:#64748b; margin:0 0 8px; font-weight:600; }
- h1 { margin:0 0 20px; font-size:20px; line-height:1.35; color:#0f172a; font-weight:600; }
- .body-text { font-size:15px; line-height:1.65; color:#334155; margin:0 0 14px; }
- .body-text.last { margin-bottom:0; }
- .panel {
- background:#f8fafc;
- border:1px solid #e2e8f0;
- border-radius:6px;
- padding:16px 18px;
- margin:18px 0;
- }
- .panel p { margin:6px 0; font-size:14px; line-height:1.55; color:#334155; text-indent:0; }
- .panel p:first-child { margin-top:0; }
- .panel p:last-child { margin-bottom:0; }
- .notice {
- font-size:14px;
- line-height:1.55;
- color:#92400e;
- background:#fffbeb;
- border:1px solid #fde68a;
- border-radius:6px;
- padding:12px 14px;
- margin:14px 0 0;
- }
- .notice.danger { color:#991b1b; background:#fef2f2; border-color:#fecaca; }
- .notice.neutral { color:#475569; background:#f8fafc; border-color:#e2e8f0; }
- .code-wrap { text-align:center; margin:22px 0; }
- .code {
- display:inline-block;
- font-size:24px;
- letter-spacing:0.28em;
- font-weight:700;
- color:#0f172a;
- padding:14px 26px;
- background:#f1f5f9;
- border:1px dashed #94a3b8;
- border-radius:8px;
- font-family:Consolas,'Courier New',monospace;
- }
- .footer {
- padding:0;
- border-top:1px solid #e2e8f0;
- background:#f8fafc;
- }
- .footer-inner {
- max-width:504px;
- margin:0 auto;
- padding:18px 28px 20px;
- text-align:left;
- }
- .footer-context {
- margin:0 0 14px;
- padding:10px 12px;
- font-size:13px;
- line-height:1.55;
- color:#475569;
- background:#ffffff;
- border:1px solid #e2e8f0;
- border-radius:6px;
- }
- .footer-meta {
- margin:0;
- padding:0;
- list-style:none;
- font-size:12px;
- line-height:1.65;
- color:#64748b;
- }
- .footer-meta li { margin:0; padding:0; }
- .footer-meta li + li { margin-top:8px; }
- .footer-meta-label {
- display:block;
- font-size:11px;
- font-weight:600;
- letter-spacing:0.04em;
- color:#94a3b8;
- margin-bottom:8px;
- }
- .footer-meta a {
- color:#1e40af;
- text-decoration:none;
- word-break:break-all;
- }
- .footer-copyright {
- margin:14px 0 0;
- padding-top:14px;
- border-top:1px solid #e2e8f0;
- text-align:center;
- font-size:11px;
- line-height:1.4;
- color:#94a3b8;
- }
- .tag {
- display:inline-block;
- margin-left:10px;
- padding:2px 10px;
- font-size:11px;
- font-weight:600;
- color:#b91c1c;
- background:#fef2f2;
- border-radius:999px;
- vertical-align:middle;
- }
- </style>
- </head>
- <body>
- <div class="wrapper">
- <div class="card">
- <div class="brand-bar"></div>
- <div class="inner">
- <p class="brand">${BRAND} · 系统通知</p>
- <h1>${escapeHtml(headline)}${headlineSuffixHtml}</h1>
- ${bodyHtml}
- </div>
- <div class="footer">
- <div class="footer-inner">
- ${footerHtml}
- <p class="footer-copyright">© ${year} ${escapeHtml(BRAND)}</p>
- </div>
- </div>
- </div>
- </div>
- </body>
- </html>`
- }
- /** Paragraph with pre-escaped or trusted HTML */
- function p(html, options = {}) {
- const cls = options.last ? 'body-text last' : 'body-text'
- return `<p class="${cls}">${html}</p>`
- }
- /** Paragraph; escapes plain text */
- function pEsc(text, options = {}) {
- return p(escapeHtml(text), options)
- }
- /** Key-value row inside .panel */
- function kv(label, value) {
- const v = value === undefined || value === null || value === '' ? '—' : escapeHtml(value)
- return `<p><strong>${escapeHtml(label)}:</strong> ${v}</p>`
- }
- function panel(innerHtml) {
- return `<div class="panel">${innerHtml}</div>`
- }
- /**
- * @param {string} text — escaped or plain (will escape)
- * @param {'warning'|'danger'|'neutral'} [kind]
- */
- function notice(text, kind = 'warning') {
- const cls = kind === 'danger' ? 'notice danger' : kind === 'neutral' ? 'notice neutral' : 'notice'
- return `<div class="${cls}">${escapeHtml(text)}</div>`
- }
- function codeBox(code) {
- return `<div class="code-wrap"><span class="code">${escapeHtml(code)}</span></div>`
- }
- module.exports = {
- BRAND,
- AUTO_REPLY_NOTICE,
- escapeHtml,
- escapeNl2br,
- renderEmail,
- p,
- pEsc,
- kv,
- panel,
- notice,
- codeBox
- }
|