emailLayout.js 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. 'use strict'
  2. const BRAND = 'RunForge'
  3. /** 所有经 renderEmail 输出的邮件页脚均附带此说明 */
  4. const AUTO_REPLY_NOTICE = '本邮件由系统自动发送,请勿直接回复'
  5. /**
  6. * Escape text for safe insertion into HTML email bodies.
  7. * @param {unknown} text
  8. * @returns {string}
  9. */
  10. function escapeHtml(text) {
  11. if (text === undefined || text === null) return ''
  12. return String(text)
  13. .replace(/&/g, '&')
  14. .replace(/</g, '&lt;')
  15. .replace(/>/g, '&gt;')
  16. .replace(/"/g, '&quot;')
  17. .replace(/'/g, '&#39;')
  18. }
  19. /**
  20. * Preserve line breaks as <br> after escaping (for plaintext工单内容等).
  21. * @param {unknown} text
  22. * @returns {string}
  23. */
  24. function escapeNl2br(text) {
  25. return escapeHtml(text).replace(/\r\n|\r|\n/g, '<br>')
  26. }
  27. const ACCENTS = {
  28. default: '#1e40af',
  29. success: '#047857',
  30. warning: '#b45309',
  31. danger: '#b91c1c'
  32. }
  33. /**
  34. * Wrap transactional email body in shared layout (single stylesheet + structure).
  35. * @param {object} opts
  36. * @param {string} opts.pageTitle
  37. * @param {string} opts.headline
  38. * @param {'default'|'success'|'warning'|'danger'} [opts.variant]
  39. * @param {string} opts.bodyHtml — trusted HTML fragments assembled by callers (dynamic strings must be escaped first).
  40. * @param {string} [opts.footerExtra] — shortPlaintext shown above copyright (will be escaped).
  41. * @param {string} [opts.headlineSuffixHtml] — small trusted HTML after headline inside H1 (e.g. badge span).
  42. */
  43. function renderEmail(opts) {
  44. const {
  45. pageTitle,
  46. headline,
  47. variant = 'default',
  48. bodyHtml,
  49. footerExtra = '',
  50. headlineSuffixHtml = ''
  51. } = opts
  52. const accent = ACCENTS[variant] || ACCENTS.default
  53. const year = new Date().getFullYear()
  54. const footerNote = footerExtra
  55. ? `<p class="fineprint">${escapeHtml(footerExtra)}</p>`
  56. : ''
  57. const autoReplyLine = `<p class="fineprint">${escapeHtml(AUTO_REPLY_NOTICE)}</p>`
  58. return `<!DOCTYPE html>
  59. <html lang="zh-CN">
  60. <head>
  61. <meta charset="UTF-8">
  62. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  63. <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
  64. <title>${escapeHtml(pageTitle)}</title>
  65. <style>
  66. body { margin:0; padding:0; background:#f1f5f9; -webkit-font-smoothing:antialiased; }
  67. .wrapper { width:100%; padding:24px 12px; box-sizing:border-box; }
  68. .card {
  69. max-width:560px;
  70. margin:0 auto;
  71. background:#ffffff;
  72. border-radius:8px;
  73. border:1px solid #e2e8f0;
  74. overflow:hidden;
  75. box-shadow:0 1px 3px rgba(15,23,42,0.08);
  76. }
  77. .brand-bar { height:4px; background:${accent}; }
  78. .inner { padding:28px 28px 20px; }
  79. .brand { font-size:11px; letter-spacing:0.12em; text-transform:uppercase; color:#64748b; margin:0 0 8px; font-weight:600; }
  80. h1 { margin:0 0 20px; font-size:20px; line-height:1.35; color:#0f172a; font-weight:600; }
  81. .body-text { font-size:15px; line-height:1.65; color:#334155; margin:0 0 14px; }
  82. .body-text.last { margin-bottom:0; }
  83. .panel {
  84. background:#f8fafc;
  85. border:1px solid #e2e8f0;
  86. border-radius:6px;
  87. padding:16px 18px;
  88. margin:18px 0;
  89. }
  90. .panel p { margin:6px 0; font-size:14px; line-height:1.55; color:#334155; text-indent:0; }
  91. .panel p:first-child { margin-top:0; }
  92. .panel p:last-child { margin-bottom:0; }
  93. .notice {
  94. font-size:14px;
  95. line-height:1.55;
  96. color:#92400e;
  97. background:#fffbeb;
  98. border:1px solid #fde68a;
  99. border-radius:6px;
  100. padding:12px 14px;
  101. margin:14px 0 0;
  102. }
  103. .notice.danger { color:#991b1b; background:#fef2f2; border-color:#fecaca; }
  104. .notice.neutral { color:#475569; background:#f8fafc; border-color:#e2e8f0; }
  105. .code-wrap { text-align:center; margin:22px 0; }
  106. .code {
  107. display:inline-block;
  108. font-size:24px;
  109. letter-spacing:0.28em;
  110. font-weight:700;
  111. color:#0f172a;
  112. padding:14px 26px;
  113. background:#f1f5f9;
  114. border:1px dashed #94a3b8;
  115. border-radius:8px;
  116. font-family:Consolas,'Courier New',monospace;
  117. }
  118. .footer {
  119. padding:16px 28px 22px;
  120. border-top:1px solid #f1f5f9;
  121. text-align:center;
  122. font-size:12px;
  123. line-height:1.5;
  124. color:#94a3b8;
  125. }
  126. .fineprint { margin:0 0 8px; font-size:12px; color:#64748b; }
  127. .tag {
  128. display:inline-block;
  129. margin-left:10px;
  130. padding:2px 10px;
  131. font-size:11px;
  132. font-weight:600;
  133. color:#b91c1c;
  134. background:#fef2f2;
  135. border-radius:999px;
  136. vertical-align:middle;
  137. }
  138. </style>
  139. </head>
  140. <body>
  141. <div class="wrapper">
  142. <div class="card">
  143. <div class="brand-bar"></div>
  144. <div class="inner">
  145. <p class="brand">${BRAND} · 系统通知</p>
  146. <h1>${escapeHtml(headline)}${headlineSuffixHtml}</h1>
  147. ${bodyHtml}
  148. </div>
  149. <div class="footer">
  150. ${footerNote}
  151. ${autoReplyLine}
  152. <p style="margin:0;">© ${year} ${escapeHtml(BRAND)}</p>
  153. </div>
  154. </div>
  155. </div>
  156. </body>
  157. </html>`
  158. }
  159. /** Paragraph with pre-escaped or trusted HTML */
  160. function p(html, options = {}) {
  161. const cls = options.last ? 'body-text last' : 'body-text'
  162. return `<p class="${cls}">${html}</p>`
  163. }
  164. /** Paragraph; escapes plain text */
  165. function pEsc(text, options = {}) {
  166. return p(escapeHtml(text), options)
  167. }
  168. /** Key-value row inside .panel */
  169. function kv(label, value) {
  170. const v = value === undefined || value === null || value === '' ? '—' : escapeHtml(value)
  171. return `<p><strong>${escapeHtml(label)}:</strong> ${v}</p>`
  172. }
  173. function panel(innerHtml) {
  174. return `<div class="panel">${innerHtml}</div>`
  175. }
  176. /**
  177. * @param {string} text — escaped or plain (will escape)
  178. * @param {'warning'|'danger'|'neutral'} [kind]
  179. */
  180. function notice(text, kind = 'warning') {
  181. const cls = kind === 'danger' ? 'notice danger' : kind === 'neutral' ? 'notice neutral' : 'notice'
  182. return `<div class="${cls}">${escapeHtml(text)}</div>`
  183. }
  184. function codeBox(code) {
  185. return `<div class="code-wrap"><span class="code">${escapeHtml(code)}</span></div>`
  186. }
  187. module.exports = {
  188. BRAND,
  189. AUTO_REPLY_NOTICE,
  190. escapeHtml,
  191. escapeNl2br,
  192. renderEmail,
  193. p,
  194. pEsc,
  195. kv,
  196. panel,
  197. notice,
  198. codeBox
  199. }