emailLayout.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. 'use strict'
  2. const BRAND = 'RunForge'
  3. /** 页脚固定说明(分段展示,避免单行过长) */
  4. const FOOTER_DISCLAIMER = {
  5. label: '本邮件由系统自动发送,请勿直接回复本邮件。',
  6. helpPrefix: '如需帮助,请访问 RunForge 网站提交工单,或发送邮件至',
  7. helpEmail: 'service@xxoo365.top'
  8. }
  9. /** 合并后的纯文本说明(供外部引用) */
  10. const AUTO_REPLY_NOTICE = [
  11. FOOTER_DISCLAIMER.label,
  12. `${FOOTER_DISCLAIMER.helpPrefix} ${FOOTER_DISCLAIMER.helpEmail}`
  13. ].join(' ')
  14. /**
  15. * Escape text for safe insertion into HTML email bodies.
  16. * @param {unknown} text
  17. * @returns {string}
  18. */
  19. function escapeHtml(text) {
  20. if (text === undefined || text === null) return ''
  21. return String(text)
  22. .replace(/&/g, '&')
  23. .replace(/</g, '&lt;')
  24. .replace(/>/g, '&gt;')
  25. .replace(/"/g, '&quot;')
  26. .replace(/'/g, '&#39;')
  27. }
  28. /**
  29. * Preserve line breaks as <br> after escaping (for plaintext工单内容等).
  30. * @param {unknown} text
  31. * @returns {string}
  32. */
  33. function escapeNl2br(text) {
  34. return escapeHtml(text).replace(/\r\n|\r|\n/g, '<br>')
  35. }
  36. const ACCENTS = {
  37. default: '#1e40af',
  38. success: '#047857',
  39. warning: '#b45309',
  40. danger: '#b91c1c'
  41. }
  42. /**
  43. * 页脚:可选补充说明 + 固定分段提示(左对齐,避免长句居中拥挤)
  44. * @param {string} footerExtra
  45. */
  46. function buildFooterHtml(footerExtra) {
  47. const ctx = footerExtra
  48. ? `<div class="footer-context">${escapeHtml(footerExtra)}</div>`
  49. : ''
  50. const mail = escapeHtml(FOOTER_DISCLAIMER.helpEmail)
  51. return `${ctx}
  52. <span class="footer-meta-label">${escapeHtml(FOOTER_DISCLAIMER.label)}</span>
  53. <ul class="footer-meta">
  54. <li>${escapeHtml(FOOTER_DISCLAIMER.helpPrefix)} <a href="mailto:${mail}">${mail}</a></li>
  55. </ul>`
  56. }
  57. /**
  58. * Wrap transactional email body in shared layout (single stylesheet + structure).
  59. * @param {object} opts
  60. * @param {string} opts.pageTitle
  61. * @param {string} opts.headline
  62. * @param {'default'|'success'|'warning'|'danger'} [opts.variant]
  63. * @param {string} opts.bodyHtml — trusted HTML fragments assembled by callers (dynamic strings must be escaped first).
  64. * @param {string} [opts.footerExtra] — shortPlaintext shown above copyright (will be escaped).
  65. * @param {string} [opts.headlineSuffixHtml] — small trusted HTML after headline inside H1 (e.g. badge span).
  66. */
  67. function renderEmail(opts) {
  68. const {
  69. pageTitle,
  70. headline,
  71. variant = 'default',
  72. bodyHtml,
  73. footerExtra = '',
  74. headlineSuffixHtml = ''
  75. } = opts
  76. const accent = ACCENTS[variant] || ACCENTS.default
  77. const year = new Date().getFullYear()
  78. const footerHtml = buildFooterHtml(footerExtra)
  79. return `<!DOCTYPE html>
  80. <html lang="zh-CN">
  81. <head>
  82. <meta charset="UTF-8">
  83. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  84. <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
  85. <title>${escapeHtml(pageTitle)}</title>
  86. <style>
  87. body { margin:0; padding:0; background:#f1f5f9; -webkit-font-smoothing:antialiased; }
  88. .wrapper { width:100%; padding:24px 12px; box-sizing:border-box; }
  89. .card {
  90. max-width:560px;
  91. margin:0 auto;
  92. background:#ffffff;
  93. border-radius:8px;
  94. border:1px solid #e2e8f0;
  95. overflow:hidden;
  96. box-shadow:0 1px 3px rgba(15,23,42,0.08);
  97. }
  98. .brand-bar { height:4px; background:${accent}; }
  99. .inner { padding:28px 28px 20px; }
  100. .brand { font-size:11px; letter-spacing:0.12em; text-transform:uppercase; color:#64748b; margin:0 0 8px; font-weight:600; }
  101. h1 { margin:0 0 20px; font-size:20px; line-height:1.35; color:#0f172a; font-weight:600; }
  102. .body-text { font-size:15px; line-height:1.65; color:#334155; margin:0 0 14px; }
  103. .body-text.last { margin-bottom:0; }
  104. .panel {
  105. background:#f8fafc;
  106. border:1px solid #e2e8f0;
  107. border-radius:6px;
  108. padding:16px 18px;
  109. margin:18px 0;
  110. }
  111. .panel p { margin:6px 0; font-size:14px; line-height:1.55; color:#334155; text-indent:0; }
  112. .panel p:first-child { margin-top:0; }
  113. .panel p:last-child { margin-bottom:0; }
  114. .notice {
  115. font-size:14px;
  116. line-height:1.55;
  117. color:#92400e;
  118. background:#fffbeb;
  119. border:1px solid #fde68a;
  120. border-radius:6px;
  121. padding:12px 14px;
  122. margin:14px 0 0;
  123. }
  124. .notice.danger { color:#991b1b; background:#fef2f2; border-color:#fecaca; }
  125. .notice.neutral { color:#475569; background:#f8fafc; border-color:#e2e8f0; }
  126. .code-wrap { text-align:center; margin:22px 0; }
  127. .code {
  128. display:inline-block;
  129. font-size:24px;
  130. letter-spacing:0.28em;
  131. font-weight:700;
  132. color:#0f172a;
  133. padding:14px 26px;
  134. background:#f1f5f9;
  135. border:1px dashed #94a3b8;
  136. border-radius:8px;
  137. font-family:Consolas,'Courier New',monospace;
  138. }
  139. .footer {
  140. padding:0;
  141. border-top:1px solid #e2e8f0;
  142. background:#f8fafc;
  143. }
  144. .footer-inner {
  145. max-width:504px;
  146. margin:0 auto;
  147. padding:18px 28px 20px;
  148. text-align:left;
  149. }
  150. .footer-context {
  151. margin:0 0 14px;
  152. padding:10px 12px;
  153. font-size:13px;
  154. line-height:1.55;
  155. color:#475569;
  156. background:#ffffff;
  157. border:1px solid #e2e8f0;
  158. border-radius:6px;
  159. }
  160. .footer-meta {
  161. margin:0;
  162. padding:0;
  163. list-style:none;
  164. font-size:12px;
  165. line-height:1.65;
  166. color:#64748b;
  167. }
  168. .footer-meta li { margin:0; padding:0; }
  169. .footer-meta li + li { margin-top:8px; }
  170. .footer-meta-label {
  171. display:block;
  172. font-size:11px;
  173. font-weight:600;
  174. letter-spacing:0.04em;
  175. color:#94a3b8;
  176. margin-bottom:8px;
  177. }
  178. .footer-meta a {
  179. color:#1e40af;
  180. text-decoration:none;
  181. word-break:break-all;
  182. }
  183. .footer-copyright {
  184. margin:14px 0 0;
  185. padding-top:14px;
  186. border-top:1px solid #e2e8f0;
  187. text-align:center;
  188. font-size:11px;
  189. line-height:1.4;
  190. color:#94a3b8;
  191. }
  192. .tag {
  193. display:inline-block;
  194. margin-left:10px;
  195. padding:2px 10px;
  196. font-size:11px;
  197. font-weight:600;
  198. color:#b91c1c;
  199. background:#fef2f2;
  200. border-radius:999px;
  201. vertical-align:middle;
  202. }
  203. </style>
  204. </head>
  205. <body>
  206. <div class="wrapper">
  207. <div class="card">
  208. <div class="brand-bar"></div>
  209. <div class="inner">
  210. <p class="brand">${BRAND} · 系统通知</p>
  211. <h1>${escapeHtml(headline)}${headlineSuffixHtml}</h1>
  212. ${bodyHtml}
  213. </div>
  214. <div class="footer">
  215. <div class="footer-inner">
  216. ${footerHtml}
  217. <p class="footer-copyright">© ${year} ${escapeHtml(BRAND)}</p>
  218. </div>
  219. </div>
  220. </div>
  221. </div>
  222. </body>
  223. </html>`
  224. }
  225. /** Paragraph with pre-escaped or trusted HTML */
  226. function p(html, options = {}) {
  227. const cls = options.last ? 'body-text last' : 'body-text'
  228. return `<p class="${cls}">${html}</p>`
  229. }
  230. /** Paragraph; escapes plain text */
  231. function pEsc(text, options = {}) {
  232. return p(escapeHtml(text), options)
  233. }
  234. /** Key-value row inside .panel */
  235. function kv(label, value) {
  236. const v = value === undefined || value === null || value === '' ? '—' : escapeHtml(value)
  237. return `<p><strong>${escapeHtml(label)}:</strong> ${v}</p>`
  238. }
  239. function panel(innerHtml) {
  240. return `<div class="panel">${innerHtml}</div>`
  241. }
  242. /**
  243. * @param {string} text — escaped or plain (will escape)
  244. * @param {'warning'|'danger'|'neutral'} [kind]
  245. */
  246. function notice(text, kind = 'warning') {
  247. const cls = kind === 'danger' ? 'notice danger' : kind === 'neutral' ? 'notice neutral' : 'notice'
  248. return `<div class="${cls}">${escapeHtml(text)}</div>`
  249. }
  250. function codeBox(code) {
  251. return `<div class="code-wrap"><span class="code">${escapeHtml(code)}</span></div>`
  252. }
  253. module.exports = {
  254. BRAND,
  255. AUTO_REPLY_NOTICE,
  256. escapeHtml,
  257. escapeNl2br,
  258. renderEmail,
  259. p,
  260. pEsc,
  261. kv,
  262. panel,
  263. notice,
  264. codeBox
  265. }