Browse Source

✨ feat: 统一邮件模板

Pchen0 1 month ago
parent
commit
32923cccb6
3 changed files with 540 additions and 1001 deletions
  1. 2 2
      apis/User/BindEmail.js
  2. 216 0
      plugin/Email/emailLayout.js
  3. 322 999
      plugin/Email/emailTemplate.js

+ 2 - 2
apis/User/BindEmail.js

@@ -2,7 +2,7 @@ const API = require("../../lib/API");
 const db = require("../../plugin/DataBase/db");
 const { BaseStdResponse } = require("../../BaseStdResponse");
 const Redis = require('../../plugin/DataBase/Redis');
-const sendEmail = require('../../plugin/Email/Email');
+const EmailTemplate = require('../../plugin/Email/emailTemplate');
 const AccessControl = require("../../lib/AccessControl");
 
 class BindEmail extends API {
@@ -55,7 +55,7 @@ class BindEmail extends API {
             res.json({
                 ...BaseStdResponse.OK
             });
-            await sendEmail(email, '换绑邮箱成功', `您的GitNexus账号换绑邮箱成功,操作时间:${new Date().toLocaleString()}`);
+            await EmailTemplate.bindEmailSuccess(email);
         } else {
             res.json({ ...BaseStdResponse.ERR, endpoint: 7894378, msg: '操作失败!' });
         }

+ 216 - 0
plugin/Email/emailLayout.js

@@ -0,0 +1,216 @@
+'use strict'
+
+const BRAND = 'RunForge'
+
+/** 所有经 renderEmail 输出的邮件页脚均附带此说明 */
+const AUTO_REPLY_NOTICE = '本邮件由系统自动发送,请勿直接回复'
+
+/**
+ * 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, '&lt;')
+        .replace(/>/g, '&gt;')
+        .replace(/"/g, '&quot;')
+        .replace(/'/g, '&#39;')
+}
+
+/**
+ * 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'
+}
+
+/**
+ * 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 footerNote = footerExtra
+        ? `<p class="fineprint">${escapeHtml(footerExtra)}</p>`
+        : ''
+
+    const autoReplyLine = `<p class="fineprint">${escapeHtml(AUTO_REPLY_NOTICE)}</p>`
+
+    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:16px 28px 22px;
+    border-top:1px solid #f1f5f9;
+    text-align:center;
+    font-size:12px;
+    line-height:1.5;
+    color:#94a3b8;
+  }
+  .fineprint { margin:0 0 8px; font-size:12px; color:#64748b; }
+  .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">
+      ${footerNote}
+      ${autoReplyLine}
+      <p style="margin:0;">© ${year} ${escapeHtml(BRAND)}</p>
+    </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
+}

+ 322 - 999
plugin/Email/emailTemplate.js

@@ -1,378 +1,135 @@
 const sendEmail = require('./Email')
+const {
+    renderEmail,
+    escapeNl2br,
+    pEsc,
+    kv,
+    panel,
+    notice,
+    codeBox
+} = require('./emailLayout')
 
 class emailTemplate {
     stramptoTime(time) {
         if (time < 10)
-            return '';
-        return new Date(+time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
+            return ''
+        return new Date(+time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
     }
 
     async checkEmail(email, code) {
-        const time = new Date().getTime()
-        await sendEmail(email, 'RunForge - 邮箱验证码',
-            `<html lang="zh-CN">
-
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>RunForge - 邮箱验证码</title>
-    <style>
-        body {
-            font-family: Arial, sans-serif;
-            background-color: #f4f4f4;
-            margin: 0;
-            padding: 0;
-        }
-
-        .container {
-            width: 80%;
-            margin: 20px auto;
-            background-color: #fff;
-            padding: 20px;
-            border-radius: 8px;
-            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
-        }
-
-        .head {
-            display: flex;
-            justify-content: center;
-            align-items: center;
-            gap:10px;
-            color: #2c3e50;
-        }
-
-        p {
-            font-size: 16px;
-            color: #34495e;
-            line-height: 1.6;
-            text-indent: 2em;
-        }
-
-        .code {
-            margin: 20px 0;
-            font-size: 1.5em;
-            text-align: center;
-            font-weight: bold;
-        }
-
-        .important {
-            color: #e74c3c;
-            font-weight: bold;
-        }
-
-        .footer {
-            font-size: 14px;
-            text-align: center;
-            color: #7f8c8d;
-            margin-top: 50px;
-        }
-    </style>
-</head>
-
-<body>
-    <div class="container">
-        <div class="head">
-            <h2>RunForge - 邮箱验证码</h2>
-        </div>
-        
-        <p>尊敬的用户:</p>
-        <p>您正在本站进行邮箱验证操作,如非您本人操作,请忽略此邮件。</p>
-        <p>您的验证码为:</p>
-        <div class="code">
-            ${code}
-        </div>
-        <p class="important">验证码5分钟内有效,超时请重新获取</p>
-        <p class="footer">Copyright © 2025 RunForge</p>
-    </div>
-</body>
+        const bodyHtml = [
+            pEsc('尊敬的用户:'),
+            pEsc('您正在进行邮箱验证。如非本人操作,请忽略本邮件。'),
+            pEsc('您的验证码为:'),
+            codeBox(code),
+            notice('验证码 5 分钟内有效,超时请重新获取。', 'danger')
+        ].join('')
+        await sendEmail(
+            email,
+            'RunForge|邮箱验证码',
+            renderEmail({
+                pageTitle: '邮箱验证码',
+                headline: '邮箱验证码',
+                variant: 'default',
+                bodyHtml,
+                footerExtra: '请勿向他人泄露验证码。'
+            })
+        )
+    }
 
-</html>`
+    async bindEmailSuccess(email) {
+        const time = this.stramptoTime(new Date().getTime())
+        const bodyHtml = [
+            pEsc('尊敬的用户:'),
+            pEsc('您的 RunForge 账号绑定邮箱已成功更新。'),
+            panel(kv('操作时间', time)),
+            pEsc('如非本人操作,请尽快联系客服处理。', { last: true })
+        ].join('')
+        await sendEmail(
+            email,
+            'RunForge|邮箱换绑成功',
+            renderEmail({
+                pageTitle: '邮箱换绑成功',
+                headline: '邮箱换绑成功',
+                variant: 'success',
+                bodyHtml
+            })
         )
     }
 
     async registerSuccess(email, username) {
         const time = new Date().getTime()
-        await sendEmail(email, '您已成功注册RunForge账号',
-            `<html lang="zh-CN">
-
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>RunForge - 注册成功提醒</title>
-    <style>
-        body {
-            font-family: Arial, sans-serif;
-            background-color: #f4f4f4;
-            margin: 0;
-            padding: 0;
-        }
-
-        .container {
-            width: 80%;
-            margin: 20px auto;
-            background-color: #fff;
-            padding: 20px;
-            border-radius: 8px;
-            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
-        }
-
-        .head {
-            display: flex;
-            justify-content: center;
-            align-items: center;
-            gap: 10px;
-            color: #2c3e50;
-        }
-
-        p {
-            font-size: 16px;
-            color: #34495e;
-            line-height: 1.6;
-            text-indent: 2em;
-        }
-
-        .info {
-            background-color: #ecf0f1;
-            padding: 15px;
-            border-radius: 5px;
-            margin: 20px 0;
-        }
-
-        .info p {
-            margin: 5px 0;
-        }
-
-        .important {
-            color: #e74c3c;
-            font-weight: bold;
-        }
-
-        .footer {
-            font-size: 14px;
-            text-align: center;
-            color: #7f8c8d;
-            margin-top: 50px;
-        }
-    </style>
-</head>
-
-<body>
-    <div class="container">
-        <div class="head">
-            <h2>RunForge - 注册成功提醒</h2>
-        </div>
-
-        <p>尊敬的 ${username}:</p>
-        <p>您已成功注册RunForge账号:</p>
-        <div class="info">
-            <p><strong>用户名:</strong> ${username}</p>
-            <p><strong>注册时间:</strong> ${this.stramptoTime(time)}</p>
-        </div>
-
-        <p class="footer">Copyright © 2025 RunForge</p>
-    </div>
-</body>
-
-</html>`
+        const bodyHtml = [
+            pEsc(`尊敬的 ${username}:`),
+            pEsc('您已成功注册 RunForge 账号,账号信息如下:'),
+            panel([
+                kv('用户名', username),
+                kv('注册时间', this.stramptoTime(time))
+            ].join('')),
+            pEsc('感谢您的使用。', { last: true })
+        ].join('')
+        await sendEmail(
+            email,
+            'RunForge|注册成功',
+            renderEmail({
+                pageTitle: '注册成功',
+                headline: '注册成功',
+                variant: 'success',
+                bodyHtml,
+                footerExtra: '如非本人注册,请尽快联系客服处理。'
+            })
         )
     }
 
     async updateSuccess(email, data) {
-        await sendEmail(email, '乐跑账号更新成功提醒',
-            `<html lang="zh-CN">
-            <head>
-                <meta charset="UTF-8">
-                <meta name="viewport" content="width=device-width, initial-scale=1.0">
-                <title>乐跑账号更新成功提醒</title>
-                <style>
-                    body {
-                        font-family: Arial, sans-serif;
-                        background-color: #f4f4f4;
-                        margin: 0;
-                        padding: 0;
-                    }
-
-                    .container {
-                        width: 80%;
-                        margin: 20px auto;
-                        background-color: #fff;
-                        padding: 20px;
-                        border-radius: 8px;
-                        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
-                    }
-
-                    .head {
-                        display: flex;
-                        justify-content: center;
-                        align-items: center;
-                        gap: 10px;
-                        color: #2c3e50;
-                    }
-
-                    p {
-                        font-size: 16px;
-                        color: #34495e;
-                        line-height: 1.6;
-                        text-indent: 2em;
-                    }
-
-                    .info {
-                        background-color: #ecf0f1;
-                        padding: 15px;
-                        border-radius: 5px;
-                        margin: 20px 0;
-                    }
-
-                    .info p {
-                        margin: 5px 0;
-                    }
-
-                    .important {
-                        color: #e74c3c;
-                        font-weight: bold;
-                    }
-
-                    .footer {
-                        font-size: 14px;
-                        text-align: center;
-                        color: #7f8c8d;
-                        margin-top: 50px;
-                    }
-                </style>
-            </head>
-
-            <body>
-            <div class="container">
-                <div class="head">
-                <h2>乐跑账号更新好啦,宝宝快看看~ 🎉💖✨</h2>
-                </div>
-
-                <p>亲爱的 ${data.name} 宝宝 🌸:</p>
-                <p>嘻嘻~ 你已经成功更新了乐跑账号的登录信息啦 🥰💌:</p>
-
-                <div class="info">
-                <p><strong>学号:</strong> ${data.account} 🎓</p>
-                <p><strong>年级:</strong> ${data.grade_id} 📚</p>
-                <p><strong>学院:</strong> ${data.academy_name} 🏫</p>
-                <p><strong>更新时间:</strong> ${this.stramptoTime(new Date().getTime())} ⏰</p>
-                </div>
-
-                <p class="important">
-                ${data.auto_run === 0
-                ? '现在还没有帮宝宝开启自动乐跑呢 🐾💦 如果想要开始跑跑的话,记得登录后去RunForge手动点一下哦~ 🌈💕'
-                : '已经为宝宝开启了自动乐跑啦 🏃‍♀️✨ 登录后系统会乖乖替你完成乐跑 💖 记得留意邮箱提醒哟~ 📬'}
-                </p>
-                <p class="important">宝宝可以随时在登录乐跑账号的设备上打开“智慧体育”小程序进行考试、查看跑步记录等操作,但记得不要在其他设备上登录“智慧体育”小程序哦 🚫📱,不然会失效,到时候又要重新登录啦 😢💦~</p>
-                <p class="important">有问题随时喊RunForge客服小可爱呀 💕💌 我们都会耐心陪宝宝解决的哟 ✨🥰</p>
-
-                <p class="footer">Copyright © 2025 RunForge 🌟</p>
-            </div>
-            </body>
-
-            </html>`
+        const autoNote = data.auto_run === 0
+            ? '当前未开启自动乐跑。如需跑步,请登录 RunForge 后手动发起。'
+            : '已为您开启自动乐跑,系统将按计划代为完成跑步任务,请留意后续邮件提醒。'
+
+        const bodyHtml = [
+            pEsc(`尊敬的 ${data.name}:`),
+            pEsc('您的乐跑账号登录信息已更新成功,详情如下:'),
+            panel([
+                kv('学号', data.account),
+                kv('年级', data.grade_id),
+                kv('学院', data.academy_name),
+                kv('更新时间', this.stramptoTime(new Date().getTime()))
+            ].join('')),
+            notice(autoNote, 'neutral'),
+            notice('请在当前更新乐跑账号信息的设备上使用「智慧体育」小程序;请勿在其他设备登录该小程序,以免登录状态失效并需重新绑定。', 'neutral'),
+            pEsc('如需帮助,请联系 RunForge 客服。', { last: true })
+        ].join('')
+
+        await sendEmail(
+            email,
+            'RunForge|乐跑账号更新成功',
+            renderEmail({
+                pageTitle: '乐跑账号更新成功',
+                headline: '乐跑账号信息已更新',
+                variant: 'success',
+                bodyHtml
+            })
         )
     }
 
     async lepaoOver(email, data) {
-        await sendEmail(email, '🎉乐跑完成祝贺',
-            `<!DOCTYPE html>
-                <html lang="zh-CN">
-                <head>
-                <meta charset="UTF-8">
-                <title>🎉 RunForge - 乐跑完成祝贺</title>
-                <style>
-                    body {
-                    background-color: #fffaf4;
-                    margin: 0;
-                    padding: 0;
-                    font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
-                    }
-                    .container {
-                    max-width: 620px;
-                    margin: 40px auto;
-                    padding: 30px;
-                    background-color: #fff;
-                    border-radius: 14px;
-                    box-shadow: 0 8px 25px rgba(255, 183, 77, 0.2);
-                    border: 2px dashed #ffe0b2;
-                    }
-                    .head {
-                    text-align: center;
-                    margin-bottom: 30px;
-                    }
-                    .head h2 {
-                    color: #ff7043;
-                    font-size: 26px;
-                    margin: 0;
-                    }
-                    p {
-                    color: #444;
-                    font-size: 16px;
-                    line-height: 1.8;
-                    }
-                    .info {
-                    background-color: #fff3e0;
-                    border-left: 5px solid #ffb74d;
-                    padding: 15px 20px;
-                    margin: 25px 0;
-                    border-radius: 10px;
-                    }
-                    .info p {
-                    margin: 8px 0;
-                    }
-                    .footer {
-                    text-align: center;
-                    color: #bbb;
-                    font-size: 14px;
-                    margin-top: 40px;
-                    }
-                    .highlight {
-                    color: #ff5722;
-                    font-weight: bold;
-                    }
-                </style>
-                </head>
-                <body>
-                <div class="container">
-                    <div class="head">
-                    <h2>乐跑目标完成🎉</h2>
-                    </div>
-
-                    <p>亲爱的 <span class="highlight">${data.name}</span> 宝宝 ꒰。•ᴗ•。꒱:</p>
-
-                    <p>
-                    ✨恭喜恭喜~你已经火力全开地完成了设定乐跑目标啦!🎊<br>
-                    不得不说,你就是我们心中的 <strong>小跑神</strong> 🌟💨,坚持到最后真的超级棒!💪( •̀ᄇ• ́)و
-                    </p>
-
-                    <p>
-                    跑过的风,晒过的阳,每一步都是你努力的见证!🌤️🍃<br>
-                    我们在远程为你打Call中 📣📣📣 ~真的好佩服佩服你!
-                    </p>
-
-                    <p>
-                    这一学期和你一起“并肩作战”,我们感到非常幸运!🧡<br>
-                    希望你收获了健康,也收获了快乐~o(≧▽≦)o
-                    </p>
-
-                    <p>
-                    📬 如果有任何问题,随时给我们戳小窗~我们在线等你哟 (´▽`)ノ♪
-                    </p>
-
-                    <p>
-                    🥳 祝你假期躺赢、学习加buff、每天都开心到冒泡泡!✧*。٩(ˊᗜˋ*)و✧*。<br>
-                    📚 新学期我们一起继续加速奔跑吧~Let's gooo!🔥🔥
-                    </p>
-
-                    <p>
-                    🧡我们已经为您关闭了自动乐跑功能,如您需要继续乐跑,还可前往RunForge自行开启哦!
-                    </p>
-
-                    <p class="footer">Copyright © 2025 RunForge</p>
-                </div>
-                </body>
-                </html>
-                `
+        const bodyHtml = [
+            pEsc(`尊敬的 ${data.name}:`),
+            pEsc('您在本学期设定的乐跑目标已全部完成,系统已为您关闭自动乐跑功能。'),
+            pEsc('感谢本学期以来的配合。若仍需跑步,可在 RunForge 中按需重新开启相关功能。'),
+            notice('如有疑问,请通过 RunForge 工单或客服渠道联系我们。', 'neutral'),
+            pEsc('祝您学习生活愉快。', { last: true })
+        ].join('')
+
+        await sendEmail(
+            email,
+            'RunForge|乐跑目标已完成',
+            renderEmail({
+                pageTitle: '乐跑目标已完成',
+                headline: '乐跑目标已完成',
+                variant: 'success',
+                bodyHtml
+            })
         )
     }
 
@@ -388,697 +145,263 @@ class emailTemplate {
                 : '—'
         const timeStr = timeSec > 0 ? this.formatSecondsToMinSec(timeSec) : '—'
 
-        let goalHtml = ''
+        let goalLines = ''
         if (target_count === 0) {
-            goalHtml = `
-                <p><strong>累计次数:</strong> ${total_num} 次 ✨</p>`
+            goalLines = kv('累计次数', `${total_num} 次`)
         } else {
-            const remain = Math.max(0, target_count - total_num)
-            const hitGoal = total_num >= target_count
-            goalHtml = `
-                <p><strong>目标次数:</strong> ${target_count} 次 🎯</p>
-                <p><strong>累计次数:</strong> ${total_num} 次 ✨</p>`
+            goalLines = [
+                kv('目标次数', `${target_count} 次`),
+                kv('累计次数', `${total_num} 次`)
+            ].join('')
         }
 
-        await sendEmail(email, '乐跑成功提醒',
-            `<html lang="zh-CN">
-            <head>
-                <meta charset="UTF-8">
-                <meta name="viewport" content="width=device-width, initial-scale=1.0">
-                <title>乐跑成功提醒</title>
-                <style>
-                    body {
-                        font-family: Arial, sans-serif;
-                        background-color: #f4f4f4;
-                        margin: 0;
-                        padding: 0;
-                    }
-
-                    .container {
-                        width: 80%;
-                        margin: 20px auto;
-                        background-color: #fff;
-                        padding: 20px;
-                        border-radius: 8px;
-                        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
-                    }
-
-                    .head {
-                        display: flex;
-                        justify-content: center;
-                        align-items: center;
-                        gap: 10px;
-                        color: #2c3e50;
-                    }
-
-                    p {
-                        font-size: 16px;
-                        color: #34495e;
-                        line-height: 1.6;
-                        text-indent: 2em;
-                    }
-
-                    .info {
-                        background-color: #ecf0f1;
-                        padding: 15px;
-                        border-radius: 5px;
-                        margin: 20px 0;
-                    }
-
-                    .info p {
-                        margin: 5px 0;
-                        text-indent: 0;
-                    }
-
-                    .important {
-                        color: #e74c3c;
-                        font-weight: bold;
-                    }
-
-                    .footer {
-                        font-size: 14px;
-                        text-align: center;
-                        color: #7f8c8d;
-                        margin-top: 50px;
-                    }
-                </style>
-            </head>
-
-            <body>
-            <div class="container">
-                <div class="head">
-                <h2>乐跑成功啦 🎉💖</h2>
-                </div>
-
-                <p>亲爱的 ${data.name} 宝宝:</p>
-                <p>耶耶耶~ 我们已经乖乖帮你完成了一次乐跑啦 ✨🏃‍♀️💨</p>
-
-                <div class="info">
-                <p><strong>学号:</strong> ${data.account}</p>
-                <p><strong>跑区:</strong> ${passTit} 🌈</p>
-                <p><strong>跑步时间:</strong> ${timeStr} ⏱️</p>
-                <p><strong>平均配速:</strong> ${paceStr} 🐇</p>
-                <p><strong>跑步距离:</strong> ${distanceKm || '—'} Km 💕</p>
-                ${goalHtml}
-                </div>
-
-                <p class="important">如果宝宝开启了自动乐跑,要记得不要在其他设备上登录“智慧体育”小程序哦 🚫📱,不然登录就会失效,要重新来一次啦~</p>
-                <p class="important">有问题随时找RunForge客服小可爱呀 💌 我们会陪你耐心解决的~</p>
-                <p class="footer">Copyright © 2025 RunForge ✨</p>
-            </div>
-            </body>
-
-            </html>`
+        const bodyHtml = [
+            pEsc(`尊敬的 ${data.name}:`),
+            pEsc('系统已代您完成一次乐跑,摘要如下:'),
+            panel([
+                kv('学号', data.account),
+                kv('跑区', passTit),
+                kv('用时', timeStr),
+                kv('平均配速', paceStr),
+                kv('距离', `${distanceKm || '—'} km`),
+                goalLines
+            ].join('')),
+            notice('若已开启自动乐跑,请勿在其他设备登录「智慧体育」小程序,以免登录状态失效。', 'neutral'),
+            pEsc('如需协助,请联系 RunForge 客服。', { last: true })
+        ].join('')
+
+        await sendEmail(
+            email,
+            'RunForge|乐跑成功通知',
+            renderEmail({
+                pageTitle: '乐跑成功',
+                headline: '乐跑已成功记录',
+                variant: 'success',
+                bodyHtml
+            })
         )
     }
 
     async lepaoFail(email, data) {
         const time = new Date().getTime()
-        await sendEmail(email, '乐跑失败提醒',
-            `<html lang="zh-CN">
-
-            <head>
-                <meta charset="UTF-8">
-                <meta name="viewport" content="width=device-width, initial-scale=1.0">
-                <title>乐跑失败提醒</title>
-                <style>
-                    body {
-                        font-family: Arial, sans-serif;
-                        background-color: #f4f4f4;
-                        margin: 0;
-                        padding: 0;
-                    }
-
-                    .container {
-                        width: 80%;
-                        margin: 20px auto;
-                        background-color: #fff;
-                        padding: 20px;
-                        border-radius: 8px;
-                        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
-                    }
-
-                    .head {
-                        display: flex;
-                        justify-content: center;
-                        align-items: center;
-                        gap: 10px;
-                        color: #2c3e50;
-                    }
-
-                    p {
-                        font-size: 16px;
-                        color: #34495e;
-                        line-height: 1.6;
-                        text-indent: 2em;
-                    }
-
-                    .info {
-                        background-color: #ecf0f1;
-                        padding: 15px;
-                        border-radius: 5px;
-                        margin: 20px 0;
-                    }
-
-                    .info p {
-                        margin: 5px 0;
-                    }
-
-                    .important {
-                        color: #e74c3c;
-                        font-weight: bold;
-                    }
-
-                    .footer {
-                        font-size: 14px;
-                        text-align: center;
-                        color: #7f8c8d;
-                        margin-top: 50px;
-                    }
-                </style>
-            </head>
-
-           <body>
-                <div class="container">
-                    <div class="head">
-                    <h2>乐跑没有成功呢 😢💦</h2>
-                    </div>
-
-                    <p>亲爱的 ${data.name ?? data.account} 宝宝 🌸:</p>
-                    <p>我们刚刚尝试帮你完成乐跑的时候,遇到了一点小意外呢 💔💦:</p>
-
-                    <div class="info">
-                    <p><strong>学号:</strong> ${data.account} 🎓</p>
-                    <p><strong>时间:</strong> ${this.stramptoTime(time)} ⏰</p>
-                    <p><strong>失败原因:</strong> ${data.reason} 😭</p>
-                    </div>
-
-                    <p class="important">宝宝如果是登录失效的话 🥺✨,要记得重新启动RunForge乐跑登录器,再登录“智慧体育”小程序就可以啦 💕</p>
-                    <p class="important">如果还是不懂,随时可以来找RunForge客服小可爱哟 💌 我们会耐心陪宝宝解决问题的 🌈🥰</p>
-
-                    <p class="footer">Copyright © 2025 RunForge 💖</p>
-                </div>
-                </body>
-
-            </html>`
+        const displayName = data.name ?? data.account
+
+        const bodyHtml = [
+            pEsc(`尊敬的 ${displayName}:`),
+            pEsc('系统在尝试执行乐跑任务时未成功,详情如下:'),
+            panel([
+                kv('学号', data.account),
+                kv('时间', this.stramptoTime(time)),
+                kv('原因', data.reason)
+            ].join('')),
+            notice('若为登录失效,请在 RunForge 乐跑登录器中重新完成登录后再试。', 'warning'),
+            pEsc('若问题持续存在,请联系 RunForge 客服并说明学号与失败时间,便于排查。', { last: true })
+        ].join('')
+
+        await sendEmail(
+            email,
+            'RunForge|乐跑失败通知',
+            renderEmail({
+                pageTitle: '乐跑失败',
+                headline: '乐跑未能完成',
+                variant: 'danger',
+                bodyHtml
+            })
         )
     }
 
     async orderNewReply(email, data) {
-        await sendEmail(email, 'RunForge - 工单状态更新提醒',
-            `<html lang="zh-CN">
-            <head>
-                <meta charset="UTF-8">
-                <meta name="viewport" content="width=device-width, initial-scale=1.0">
-                <title>工单状态更新提醒</title>
-                <style>
-                    body {
-                        font-family: Arial, sans-serif;
-                        background-color: #f4f4f4;
-                        margin: 0;
-                        padding: 0;
-                    }
-
-                    .container {
-                        width: 80%;
-                        margin: 20px auto;
-                        background-color: #fff;
-                        padding: 20px;
-                        border-radius: 8px;
-                        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
-                    }
-
-                    .head {
-                        display: flex;
-                        justify-content: center;
-                        align-items: center;
-                        gap: 10px;
-                        color: #2c3e50;
-                    }
-
-                    p {
-                        font-size: 16px;
-                        color: #34495e;
-                        line-height: 1.6;
-                        text-indent: 2em;
-                    }
-
-                    .info {
-                        background-color: #ecf0f1;
-                        padding: 15px;
-                        border-radius: 5px;
-                        margin: 20px 0;
-                    }
-
-                    .info p {
-                        margin: 5px 0;
-                    }
-
-                    .important {
-                        color: #e74c3c;
-                        font-weight: bold;
-                    }
-
-                    .footer {
-                        font-size: 14px;
-                        text-align: center;
-                        color: #7f8c8d;
-                        margin-top: 50px;
-                    }
-                </style>
-            </head>
-
-            <body>
-                <div class="container">
-                    <div class="head">
-                        <h2>工单状态更新提醒</h2>
-                    </div>
-
-                    <p>尊敬的用户:</p>
-                    <p>您编号为${data.id}的工单有新的回复:</p>
-                    <div class="info">
-                        <p><strong>回复内容:</strong> ${data.content}</p>
-                        <p><strong>回复时间:</strong> ${this.stramptoTime(new Date().getTime())}</p>
-                    </div>
-                    <p class="important">${data.files.length > 0 ? '当前回复内含有附件,前往RunForge官网内查看' : ''}</p>
-                    <p class="important">请前往RunForge官网回复工单,请勿直接回复邮件。</p>
-                    <p class="footer">Copyright © 2025 RunForge</p>
-                </div>
-            </body>
-
-            </html>`
+        const files = Array.isArray(data.files) ? data.files : []
+        const filesNote = files.length > 0
+            ? notice('本回复包含附件,请登录 RunForge 官网,在工单详情中查看。', 'neutral')
+            : ''
+
+        const bodyHtml = [
+            pEsc('尊敬的用户:'),
+            pEsc(`您提交的工单(编号 ${data.id})有新的回复:`),
+            panel([
+                '<p><strong>回复内容:</strong></p>',
+                `<p style="margin-top:8px;line-height:1.55;">${escapeNl2br(data.content)}</p>`,
+                kv('回复时间', this.stramptoTime(new Date().getTime()))
+            ].join('')),
+            filesNote,
+            notice('请登录 RunForge 官网,在工单详情中回复。', 'warning'),
+            pEsc('感谢您的理解与配合。', { last: true })
+        ].join('')
+
+        await sendEmail(
+            email,
+            'RunForge|工单新回复',
+            renderEmail({
+                pageTitle: '工单状态更新',
+                headline: '工单有新回复',
+                variant: 'default',
+                bodyHtml
+            })
         )
     }
 
     async sendCountRequestNotifyAdmins(email, data) {
-        await sendEmail(email, 'RunForge - 赠送次数待审核提醒',
-            `<html lang="zh-CN">
-            <head>
-                <meta charset="UTF-8">
-                <meta name="viewport" content="width=device-width, initial-scale=1.0">
-                <title>赠送次数待审核提醒</title>
-                <style>
-                    body {
-                        font-family: Arial, sans-serif;
-                        background-color: #f4f4f4;
-                        margin: 0;
-                        padding: 0;
-                    }
-
-                    .container {
-                        width: 80%;
-                        margin: 20px auto;
-                        background-color: #fff;
-                        padding: 20px;
-                        border-radius: 8px;
-                        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
-                    }
-
-                    .head {
-                        display: flex;
-                        justify-content: center;
-                        align-items: center;
-                        gap: 10px;
-                        color: #2c3e50;
-                    }
-
-                    p {
-                        font-size: 16px;
-                        color: #34495e;
-                        line-height: 1.6;
-                        text-indent: 2em;
-                    }
-
-                    .info {
-                        background-color: #ecf0f1;
-                        padding: 15px;
-                        border-radius: 5px;
-                        margin: 20px 0;
-                    }
-
-                    .info p {
-                        margin: 5px 0;
-                        text-indent: 0;
-                    }
-
-                    .important {
-                        color: #e74c3c;
-                        font-weight: bold;
-                    }
-
-                    .footer {
-                        font-size: 14px;
-                        text-align: center;
-                        color: #7f8c8d;
-                        margin-top: 50px;
-                    }
-                </style>
-            </head>
-
-            <body>
-                <div class="container">
-                    <div class="head">
-                        <h2>赠送次数待审核提醒</h2>
-                    </div>
-
-                    <p>尊敬的管理员:</p>
-                    <p>系统收到一条新的赠送次数申请,请及时审核处理:</p>
-                    <div class="info">
-                        <p><strong>申请ID:</strong> ${data.requestId}</p>
-                        <p><strong>赠送人:</strong> ${data.senderUsername}</p>
-                        <p><strong>接收人:</strong> ${data.receiverUsername}</p>
-                        <p><strong>赠送次数:</strong> ${data.count}</p>
-                        <p><strong>申请时间:</strong> ${this.stramptoTime(data.createTime)}</p>
-                    </div>
-                    <p class="important">请前往 RunForge 管理后台进行审核,请勿直接回复邮件。</p>
-                    <p class="footer">Copyright © 2025 RunForge</p>
-                </div>
-            </body>
-
-            </html>`
+        const bodyHtml = [
+            pEsc('尊敬的管理员:'),
+            pEsc('系统收到一条新的「赠送次数」申请,请及时登录管理后台审核处理。'),
+            panel([
+                kv('申请 ID', String(data.requestId)),
+                kv('赠送人', data.senderUsername),
+                kv('接收人', data.receiverUsername),
+                kv('赠送次数', String(data.count)),
+                kv('申请时间', this.stramptoTime(data.createTime))
+            ].join('')),
+            notice('请前往 RunForge 管理后台完成审核。', 'warning'),
+            pEsc('谢谢。', { last: true })
+        ].join('')
+
+        await sendEmail(
+            email,
+            'RunForge|赠送次数待审核',
+            renderEmail({
+                pageTitle: '赠送次数待审核',
+                headline: '赠送次数待审核',
+                variant: 'warning',
+                bodyHtml
+            })
         )
     }
 
     async lepaoAdminWarning(email, data) {
         const time = new Date().getTime()
         const safe = (v) => (v === undefined || v === null) ? '' : String(v)
-        const subject = `RunForge - 乐跑异常告警`
-        await sendEmail(email, subject,
-            `<html lang="zh-CN">
-            <head>
-                <meta charset="UTF-8">
-                <meta name="viewport" content="width=device-width, initial-scale=1.0">
-                <title>RunForge - 乐跑异常告警</title>
-                <style>
-                    body { font-family: Arial, sans-serif; background-color: #f4f4f4; margin:0; padding:0; }
-                    .container { width: 86%; max-width: 760px; margin: 20px auto; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.08); }
-                    .head { text-align:center; color:#2c3e50; }
-                    .info { background-color:#ecf0f1; padding: 14px 16px; border-radius: 6px; margin: 18px 0; }
-                    .info p { margin: 8px 0; text-indent: 0; word-break: break-all; }
-                    .tag { display:inline-block; background:#ffe8e6; color:#c0392b; padding: 2px 8px; border-radius: 999px; font-size: 12px; }
-                    .footer { font-size: 12px; text-align:center; color:#7f8c8d; margin-top: 30px; }
-                </style>
-            </head>
-            <body>
-                <div class="container">
-                    <div class="head">
-                        <h2>乐跑异常告警 <span class="tag">非常见错误</span></h2>
-                    </div>
-                    <p>尊敬的管理员:</p>
-                    <p>系统在自动乐跑流程中捕获到一条非常见错误。请关注是否为接口变更、代理异常或程序缺陷。</p>
-                    <div class="info">
-                        <p><strong>账号:</strong> ${safe(data.account)}</p>
-                        <p><strong>姓名:</strong> ${safe(data.name)}</p>
-                        <p><strong>任务:</strong> ${safe(data.taskType)}</p>
-                        <p><strong>TraceId:</strong> ${safe(data.traceId)}</p>
-                        <p><strong>时间:</strong> ${this.stramptoTime(time)}</p>
-                        <p><strong>错误信息:</strong> ${safe(data.reason)}</p>
-                        <p><strong>错误码:</strong> ${safe(data.code)}</p>
-                    </div>
-                    <p class="footer">Copyright © 2025 RunForge</p>
-                </div>
-            </body>
-            </html>`
+        const subject = 'RunForge|乐跑异常告警'
+
+        const bodyHtml = [
+            pEsc('尊敬的管理员:'),
+            pEsc('自动乐跑流程中出现非常见错误,请关注是否为接口变更、网络异常或程序缺陷,并及时跟进。'),
+            panel([
+                kv('账号', safe(data.account)),
+                kv('姓名', safe(data.name)),
+                kv('任务', safe(data.taskType)),
+                kv('TraceId', safe(data.traceId)),
+                kv('时间', this.stramptoTime(time)),
+                kv('错误信息', safe(data.reason)),
+                kv('错误码', safe(data.code))
+            ].join('')),
+            pEsc('以上为系统自动告警,如需更多信息请结合服务端日志进一步排查。', { last: true })
+        ].join('')
+
+        await sendEmail(
+            email,
+            subject,
+            renderEmail({
+                pageTitle: '乐跑异常告警',
+                headline: '乐跑异常告警',
+                headlineSuffixHtml: '<span class="tag">非常见错误</span>',
+                variant: 'danger',
+                bodyHtml
+            })
         )
     }
 
     async sendCountRequestApproved(email, data) {
-        await sendEmail(email, 'RunForge - 赠送次数审核通过提醒',
-            `<html lang="zh-CN">
-            <head>
-                <meta charset="UTF-8">
-                <meta name="viewport" content="width=device-width, initial-scale=1.0">
-                <title>赠送次数审核通过提醒</title>
-                <style>
-                    body {
-                        font-family: Arial, sans-serif;
-                        background-color: #f4f4f4;
-                        margin: 0;
-                        padding: 0;
-                    }
-
-                    .container {
-                        width: 80%;
-                        margin: 20px auto;
-                        background-color: #fff;
-                        padding: 20px;
-                        border-radius: 8px;
-                        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
-                    }
-
-                    .head {
-                        display: flex;
-                        justify-content: center;
-                        align-items: center;
-                        gap: 10px;
-                        color: #2c3e50;
-                    }
-
-                    p {
-                        font-size: 16px;
-                        color: #34495e;
-                        line-height: 1.6;
-                        text-indent: 2em;
-                    }
-
-                    .info {
-                        background-color: #ecf0f1;
-                        padding: 15px;
-                        border-radius: 5px;
-                        margin: 20px 0;
-                    }
-
-                    .info p {
-                        margin: 5px 0;
-                        text-indent: 0;
-                    }
-
-                    .footer {
-                        font-size: 14px;
-                        text-align: center;
-                        color: #7f8c8d;
-                        margin-top: 50px;
-                    }
-                </style>
-            </head>
-
-            <body>
-                <div class="container">
-                    <div class="head">
-                        <h2>赠送次数审核通过提醒</h2>
-                    </div>
-
-                    <p>尊敬的用户:</p>
-                    <p>您收到的一笔赠送次数申请已审核通过,次数已到账:</p>
-                    <div class="info">
-                        <p><strong>赠送人:</strong> ${data.senderUsername}</p>
-                        <p><strong>到账次数:</strong> ${data.count}</p>
-                        <p><strong>审核时间:</strong> ${this.stramptoTime(data.reviewTime)}</p>
-                    </div>
-                    <p class="footer">Copyright © 2025 RunForge</p>
-                </div>
-            </body>
-
-            </html>`
+        const bodyHtml = [
+            pEsc('尊敬的用户:'),
+            pEsc('您收到的一笔赠送次数申请已通过审核,次数已到账:'),
+            panel([
+                kv('赠送人', data.senderUsername),
+                kv('到账次数', String(data.count)),
+                kv('审核时间', this.stramptoTime(data.reviewTime))
+            ].join('')),
+            pEsc('您可在 RunForge 中查看账户余额与明细。', { last: true })
+        ].join('')
+
+        await sendEmail(
+            email,
+            'RunForge|赠送次数审核通过',
+            renderEmail({
+                pageTitle: '赠送次数审核通过',
+                headline: '赠送次数已到账',
+                variant: 'success',
+                bodyHtml
+            })
         )
     }
 
     async sendCountRequestRejected(email, data) {
-        await sendEmail(email, 'RunForge - 赠送次数审核失败提醒',
-            `<html lang="zh-CN">
-            <head>
-                <meta charset="UTF-8">
-                <meta name="viewport" content="width=device-width, initial-scale=1.0">
-                <title>赠送次数审核失败提醒</title>
-                <style>
-                    body {
-                        font-family: Arial, sans-serif;
-                        background-color: #f4f4f4;
-                        margin: 0;
-                        padding: 0;
-                    }
-
-                    .container {
-                        width: 80%;
-                        margin: 20px auto;
-                        background-color: #fff;
-                        padding: 20px;
-                        border-radius: 8px;
-                        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
-                    }
-
-                    .head {
-                        display: flex;
-                        justify-content: center;
-                        align-items: center;
-                        gap: 10px;
-                        color: #2c3e50;
-                    }
-
-                    p {
-                        font-size: 16px;
-                        color: #34495e;
-                        line-height: 1.6;
-                        text-indent: 2em;
-                    }
-
-                    .info {
-                        background-color: #ecf0f1;
-                        padding: 15px;
-                        border-radius: 5px;
-                        margin: 20px 0;
-                    }
-
-                    .info p {
-                        margin: 5px 0;
-                        text-indent: 0;
-                    }
-
-                    .important {
-                        color: #e74c3c;
-                        font-weight: bold;
-                    }
-
-                    .footer {
-                        font-size: 14px;
-                        text-align: center;
-                        color: #7f8c8d;
-                        margin-top: 50px;
-                    }
-                </style>
-            </head>
-
-            <body>
-                <div class="container">
-                    <div class="head">
-                        <h2>赠送次数审核失败提醒</h2>
-                    </div>
-
-                    <p>尊敬的用户:</p>
-                    <p>您发起的一笔赠送次数申请未通过审核,已将次数退回到您的账户:</p>
-                    <div class="info">
-                        <p><strong>接收人:</strong> ${data.receiverUsername}</p>
-                        <p><strong>退回次数:</strong> ${data.count}</p>
-                        <p><strong>审核时间:</strong> ${this.stramptoTime(data.reviewTime)}</p>
-                        <p><strong>拒绝原因:</strong> ${data.rejectReason}</p>
-                    </div>
-                    <p class="important">如有疑问,请前往 RunForge 联系客服处理。</p>
-                    <p class="footer">Copyright © 2025 RunForge</p>
-                </div>
-            </body>
-
-            </html>`
+        const bodyHtml = [
+            pEsc('尊敬的用户:'),
+            pEsc('您发起的一笔赠送次数申请未通过审核,相关次数已退回至您的账户:'),
+            panel([
+                kv('接收人', data.receiverUsername),
+                kv('退回次数', String(data.count)),
+                kv('审核时间', this.stramptoTime(data.reviewTime)),
+                kv('拒绝原因', data.rejectReason)
+            ].join('')),
+            notice('如有疑问,请通过 RunForge 联系客服处理。', 'neutral'),
+            pEsc('感谢您的理解。', { last: true })
+        ].join('')
+
+        await sendEmail(
+            email,
+            'RunForge|赠送次数审核未通过',
+            renderEmail({
+                pageTitle: '赠送次数审核未通过',
+                headline: '赠送次数审核未通过',
+                variant: 'warning',
+                bodyHtml
+            })
         )
     }
 
     async powerCheck(email, data) {
-        await sendEmail(email, '宿舍电费提醒',
-            `<html lang="zh-CN">
-            <head>
-                <meta charset="UTF-8">
-                <meta name="viewport" content="width=device-width, initial-scale=1.0">
-                <title>宿舍电费提醒</title>
-                <style>
-                    body {
-                        font-family: Arial, sans-serif;
-                        background-color: #f4f4f4;
-                        margin: 0;
-                        padding: 0;
-                    }
-
-                    .container {
-                        width: 80%;
-                        margin: 20px auto;
-                        background-color: #fff;
-                        padding: 20px;
-                        border-radius: 8px;
-                        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
-                    }
-
-                    .head {
-                        display: flex;
-                        justify-content: center;
-                        align-items: center;
-                        gap: 10px;
-                        color: #2c3e50;
-                    }
-
-                    p {
-                        font-size: 16px;
-                        color: #34495e;
-                        line-height: 1.6;
-                        text-indent: 2em;
-                    }
-
-                    .info {
-                        background-color: #ecf0f1;
-                        padding: 15px;
-                        border-radius: 5px;
-                        margin: 20px 0;
-                    }
-
-                    .info p {
-                        margin: 5px 0;
-                    }
-
-                    .important {
-                        color: #e74c3c;
-                        font-weight: bold;
-                    }
-
-                    .footer {
-                        font-size: 14px;
-                        text-align: center;
-                        color: #7f8c8d;
-                        margin-top: 50px;
-                    }
-                </style>
-            </head>
-
-            <body>
-            <div class="container">
-                <div class="head">
-                <h2>宿舍电费提醒</h2>
-                </div>
-
-                <p>尊敬的用户:</p>
-                <p>${data.building}${data.room}宿舍电费已低于预设提醒阈值,请留意:</p>
-
-                <div class="info">
-                <p><strong>校区:</strong> ${data.area}</p>
-                <p><strong>楼栋:</strong> ${data.building}</p>
-                <p><strong>寝室号:</strong> ${data.room}</p>
-                <p><strong>当前余额:</strong> ¥${data.now_balance}</p>
-                <p><strong>提醒阈值:</strong> ¥${data.lowest}</p>
-                <p><strong>扣费时间:</strong> ${data.now_change_time}</p>
-                </div>
-
-                <p class="important">当前电费已低于预设提醒阈值${Number(data.lowest - data.now_balance).toFixed(2)}元,请及时充值</p>
-                <p class="footer">Copyright © 2025 RunForge ✨</p>
-            </div>
-            </body>
-
-            </html>`
+        const gap = Number(data.lowest) - Number(data.now_balance)
+        const gapStr = Number.isFinite(gap) ? gap.toFixed(2) : ''
+
+        const bodyHtml = [
+            pEsc('尊敬的用户:'),
+            pEsc(`${data.building}${data.room} 宿舍电费已低于您设定的提醒阈值,请关注余额并及时充值。`),
+            panel([
+                kv('校区', data.area),
+                kv('楼栋', data.building),
+                kv('寝室号', data.room),
+                kv('当前余额', `¥${data.now_balance}`),
+                kv('提醒阈值', `¥${data.lowest}`),
+                kv('最近扣费时间', data.now_change_time)
+            ].join('')),
+            notice(
+                gapStr !== ''
+                    ? `当前余额已低于提醒阈值 ${gapStr} 元,建议尽快充值以免影响用电。`
+                    : '当前余额已低于提醒阈值,建议尽快充值以免影响用电。',
+                'warning'
+            ),
+            pEsc('您可在 RunForge 中调整提醒阈值或管理电费订阅。', { last: true })
+        ].join('')
+
+        await sendEmail(
+            email,
+            'RunForge|宿舍电费提醒',
+            renderEmail({
+                pageTitle: '宿舍电费提醒',
+                headline: '宿舍电费余额提醒',
+                variant: 'warning',
+                bodyHtml
+            })
         )
     }
 
-    // 时长计算
     formatSecondsToMinSec(totalSeconds) {
-        const minutes = Math.floor(totalSeconds / 60);
-        const seconds = totalSeconds % 60;
+        const minutes = Math.floor(totalSeconds / 60)
+        const seconds = totalSeconds % 60
 
-        return `${minutes}分${seconds.toString().padStart(2, '0')}秒`;
+        return `${minutes}分${seconds.toString().padStart(2, '0')}秒`
     }
 
-    // 配速计算
     calculatePace(seconds, kilometers) {
-        const paceInSeconds = seconds / kilometers;
-        const minutes = Math.floor(paceInSeconds / 60);
-        const remainingSeconds = Math.round(paceInSeconds % 60);
+        const paceInSeconds = seconds / kilometers
+        const minutes = Math.floor(paceInSeconds / 60)
+        const remainingSeconds = Math.round(paceInSeconds % 60)
 
-        return `${minutes}'${remainingSeconds.toString().padStart(2, '0')}''`;
+        return `${minutes}'${remainingSeconds.toString().padStart(2, '0')}''`
     }
 }
 
 const EmailTemplate = new emailTemplate()
-module.exports = EmailTemplate
+module.exports = EmailTemplate