emailLayout.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  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. .inner-stack {
  101. display:flex;
  102. flex-direction:column;
  103. }
  104. .inner-stack .brand {
  105. order:-1;
  106. margin:0 0 8px;
  107. font-size:11px;
  108. letter-spacing:0.12em;
  109. text-transform:uppercase;
  110. color:#64748b;
  111. font-weight:600;
  112. }
  113. .inner-stack .email-headline { margin:0 0 20px; }
  114. h1 { margin:0 0 20px; font-size:20px; line-height:1.35; color:#0f172a; font-weight:600; }
  115. .body-text { font-size:15px; line-height:1.65; color:#334155; margin:0 0 14px; }
  116. .body-text.last { margin-bottom:0; }
  117. .panel {
  118. background:#f8fafc;
  119. border:1px solid #e2e8f0;
  120. border-radius:6px;
  121. padding:16px 18px;
  122. margin:18px 0;
  123. }
  124. .panel p { margin:6px 0; font-size:14px; line-height:1.55; color:#334155; text-indent:0; }
  125. .panel p:first-child { margin-top:0; }
  126. .panel p:last-child { margin-bottom:0; }
  127. .notice {
  128. font-size:14px;
  129. line-height:1.55;
  130. color:#92400e;
  131. background:#fffbeb;
  132. border:1px solid #fde68a;
  133. border-radius:6px;
  134. padding:12px 14px;
  135. margin:14px 0 0;
  136. }
  137. .notice.danger { color:#991b1b; background:#fef2f2; border-color:#fecaca; }
  138. .notice.neutral { color:#475569; background:#f8fafc; border-color:#e2e8f0; }
  139. .code-wrap { text-align:center; margin:22px 0; }
  140. .code {
  141. display:inline-block;
  142. font-size:24px;
  143. letter-spacing:0.28em;
  144. font-weight:700;
  145. color:#0f172a;
  146. padding:14px 26px;
  147. background:#f1f5f9;
  148. border:1px dashed #94a3b8;
  149. border-radius:8px;
  150. font-family:Consolas,'Courier New',monospace;
  151. }
  152. .footer {
  153. padding:0;
  154. border-top:1px solid #e2e8f0;
  155. background:#f8fafc;
  156. }
  157. .footer-inner {
  158. max-width:504px;
  159. margin:0 auto;
  160. padding:18px 28px 20px;
  161. text-align:left;
  162. }
  163. .footer-context {
  164. margin:0 0 14px;
  165. padding:10px 12px;
  166. font-size:13px;
  167. line-height:1.55;
  168. color:#475569;
  169. background:#ffffff;
  170. border:1px solid #e2e8f0;
  171. border-radius:6px;
  172. }
  173. .footer-meta {
  174. margin:0;
  175. padding:0;
  176. list-style:none;
  177. font-size:12px;
  178. line-height:1.65;
  179. color:#64748b;
  180. }
  181. .footer-meta li { margin:0; padding:0; }
  182. .footer-meta li + li { margin-top:8px; }
  183. .footer-meta-label {
  184. display:block;
  185. font-size:11px;
  186. font-weight:600;
  187. letter-spacing:0.04em;
  188. color:#94a3b8;
  189. margin-bottom:8px;
  190. }
  191. .footer-meta a {
  192. color:#1e40af;
  193. text-decoration:none;
  194. word-break:break-all;
  195. }
  196. .footer-copyright {
  197. margin:14px 0 0;
  198. padding-top:14px;
  199. border-top:1px solid #e2e8f0;
  200. text-align:center;
  201. font-size:11px;
  202. line-height:1.4;
  203. color:#94a3b8;
  204. }
  205. .tag {
  206. display:inline-block;
  207. margin-left:10px;
  208. padding:2px 10px;
  209. font-size:11px;
  210. font-weight:600;
  211. color:#b91c1c;
  212. background:#fef2f2;
  213. border-radius:999px;
  214. vertical-align:middle;
  215. }
  216. </style>
  217. </head>
  218. <body>
  219. <div class="wrapper">
  220. <div class="card">
  221. <div class="brand-bar"></div>
  222. <div class="inner inner-stack">
  223. <h1 class="email-headline">${escapeHtml(headline)}${headlineSuffixHtml}</h1>
  224. <p class="brand">${BRAND} · 系统通知</p>
  225. <div class="email-body">${bodyHtml}</div>
  226. </div>
  227. <div class="footer">
  228. <div class="footer-inner">
  229. ${footerHtml}
  230. <p class="footer-copyright">© ${year} ${escapeHtml(BRAND)}</p>
  231. </div>
  232. </div>
  233. </div>
  234. </div>
  235. </body>
  236. </html>`
  237. }
  238. /** Paragraph with pre-escaped or trusted HTML */
  239. function p(html, options = {}) {
  240. const cls = options.last ? 'body-text last' : 'body-text'
  241. return `<p class="${cls}">${html}</p>`
  242. }
  243. /** Paragraph; escapes plain text */
  244. function pEsc(text, options = {}) {
  245. return p(escapeHtml(text), options)
  246. }
  247. /** Key-value row inside .panel */
  248. function kv(label, value) {
  249. const v = value === undefined || value === null || value === '' ? '—' : escapeHtml(value)
  250. return `<p><strong>${escapeHtml(label)}:</strong> ${v}</p>`
  251. }
  252. function panel(innerHtml) {
  253. return `<div class="panel">${innerHtml}</div>`
  254. }
  255. /**
  256. * @param {string} text — escaped or plain (will escape)
  257. * @param {'warning'|'danger'|'neutral'} [kind]
  258. */
  259. function notice(text, kind = 'warning') {
  260. const cls = kind === 'danger' ? 'notice danger' : kind === 'neutral' ? 'notice neutral' : 'notice'
  261. return `<div class="${cls}">${escapeHtml(text)}</div>`
  262. }
  263. function codeBox(code) {
  264. return `<div class="code-wrap"><span class="code">${escapeHtml(code)}</span></div>`
  265. }
  266. module.exports = {
  267. BRAND,
  268. AUTO_REPLY_NOTICE,
  269. escapeHtml,
  270. escapeNl2br,
  271. renderEmail,
  272. p,
  273. pEsc,
  274. kv,
  275. panel,
  276. notice,
  277. codeBox
  278. }