邮件模板微信化 + 评论老友自动填充 + CF 后台国内线路一键跳

- 邮件模板重做成微信聊天式:灰底、自己评论绿色气泡靠右、对方白色气泡靠左、
  40px 圆角方头像(weavatar,邮箱 md5 不外泄)、时间居中、#07C160 主按钮;
  布局全嵌套表格(Outlook 稳),表情内联 max-height:28px(修掉「表情太大」)、
  配图限宽 100%;管理员主题从「新的评论待审」改成「您有一个新的评论」
- 老友自动填充:新增 /comments/lookup(只回昵称+网址,绝不含邮箱;IP 限速
  30 次/10 分钟)+ 主题 comment-autofill 模块(填昵称弹提示、填邮箱自动带出
  上次的昵称/网址,事件委托不怕 Artalk 重建编辑器)
- CF 后台新增「国内线路」一键跳:/editor/handoff 用共享令牌对时间戳 HMAC
  签名 → editor-api /admin/handoff 验签发会话 Cookie(2 分钟有效、防重放),
  晚高峰丢包时直达国内机后台免重新登录
This commit is contained in:
zqlit committed 2026-10-05 07:36:26 +08:00
1 parent 18a4882577
commit bcfa229105
10 files changed
+443 -87

No files matched your search

+140 -77
View File
@@ -107,115 +107,169 @@ export async function mailQuotaOk(env: Env, limit = 90): Promise<boolean> {
}
// ============================================================================
// 模板 —— 与 artalk.yml 里 mail_tpl / notify_tpl 指向的两个文件一致
// 改模板只需要动下面两段字符串
// 模板 —— 微信聊天式:灰底 #EDEDED、自己的评论绿色气泡(#95EC69)靠右、
// 对方的白色气泡靠左、圆角方头像、时间居中 —— 用大家天天在看的聊天直觉
// 来传达「谁说了什么」,一眼就懂,不需要解释。
//
// 风格:聊天对话式(自己的评论靠右绿色气泡,对方靠左白色气泡),
// 配色清新(浅薄荷底 + 白卡 + 鲜绿主色 #2fa872)。
// 以站点的老模板(notice.html / reply.html)为底改:头像(weavatar,邮箱
// md5,明文不出现在邮件里)、点气泡跳转评论页这些老设计都保留。
//
// 相比老模板的两处实质修复:
// 1. 表情包尺寸内联到位:老模板靠 head 里 `.comment_img img{width:100px}`
// 控制大小,而不少邮件客户端会把 head 的 <style> 整段丢掉;一丢,
// 表情图就按**原始像素**铺开(站里有几套是 400px+ 的大图),气泡被
// 撑爆 —— 这就是「表情太大看着奇怪」的真因。现在每个 <img> 都带
// 内联尺寸:表情 max-height 28px(微信小表情的体感),配图限宽 100%。
// 2. 布局全用嵌套 <table>(气泡本身就是一个表格,宽度随内容收缩、
// 上限 360px),不依赖 flex / float / inline-block —— Outlook 与
// 各家 webmail 对表格的支持是最稳的,图片限宽也不会算错。
//
// 另外管理员邮件主题由「新的评论待审」改成一句人话(见 ADMIN_SUBJECT)。
// ============================================================================
/** 取首字(码点安全,emoji/生僻字不会被截断),用作头像字 */
/** 取首字(码点安全,emoji/生僻字不会被截断) */
const firstChar = (v: unknown): string => {
const s = String(v ?? '').trim();
return [...s][0] || '?';
};
/** 回复提醒(原 reply.html) */
/** 气泡里的正文块(保留老模板的 .comment_img 类名,供 head 样式兜底) */
const BODY_OPEN = '<div class="comment_img">';
const BODY_CLOSE = '</div>';
/** 老模板 head 里的那点样式:整体 reset + 表情尺寸兜底(内联样式为主,这只是保险) */
const HEAD_STYLE = `<style>
*{margin:0;padding:0;box-sizing:border-box}
img[atk-emoticon]{max-height:28px;width:auto;vertical-align:middle}
</style>`;
/** 头像:40px 圆角方(微信样式),weavatar 按 Gravatar 规则用邮箱 md5 取图 */
const AVATAR = (prefix: string, nick: string) =>
`<img src="https://weavatar.com/avatar/{{${prefix}.email_encrypted}}" width="40" height="40" alt="${nick}" style="width:40px;height:40px;border-radius:6px;display:block;border:0">`;
/**
* 气泡:bg 为 #95ec69(自己)/ #ffffff(对方)。
* 气泡本身是一个嵌套 <table>(宽度随内容收缩、上限 360px),比 inline-block
* 在邮件客户端里稳得多 —— 图片的 max-width 百分比也能正确解析。
* align="right" 时气泡贴右(微信里自己说的话)。
*/
const BUBBLE = (bg: string, color: string, inner: string, align?: 'right') =>
`<table role="presentation" cellpadding="0" cellspacing="0" border="0"${align ? ` align="${align}"` : ''} style="max-width:360px">
<tr><td bgcolor="${bg}" style="background-color:${bg};border-radius:8px;padding:10px 12px;font-size:14px;color:${color};line-height:1.7;word-break:break-word;text-align:left">${inner}</td></tr>
</table>`;
/** 回复提醒:自己的评论(绿泡靠右)+ 对方的新回复(白泡靠左),微信式上下两条 */
const REPLY_TPL = `<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title></title>
${HEAD_STYLE}
</head>
<body style="margin:0;padding:0;background-color:#f3f9f5">
<div style="width:100%;max-width:560px;margin:0 auto;padding:24px 16px;box-sizing:border-box;font-family:'PingFang SC','Hiragino Sans GB','Microsoft YaHei',-apple-system,sans-serif">
<body style="margin:0;padding:0;background-color:#ededed">
<div style="width:100%;max-width:560px;margin:0 auto;background-color:#ededed;padding:20px 16px;box-sizing:border-box;font-family:'PingFang SC','Hiragino Sans GB','Microsoft YaHei',-apple-system,'Segoe UI',sans-serif">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
<span style="width:28px;height:28px;border-radius:8px;background-color:#2fa872;color:#ffffff;font-size:14px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{site_name_first}}</span>
<span style="font-size:15px;font-weight:600;color:#2b3a31">{{site_name}}</span>
<div style="text-align:center;font-size:15px;font-weight:600;color:#1a1a1a;line-height:1.6">你在<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="color:#07c160;text-decoration:none">《{{page_title}}》</a>的评论有回复</div>
<div style="text-align:center;font-size:12px;color:#b2b2b2;padding:12px 0 20px">{{parent_comment.datetime}}</div>
<div style="margin-bottom:18px">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="top" align="right" style="text-align:right;padding-right:10px">
<div style="font-size:12px;color:#9b9b9b;margin-bottom:5px">{{parent_comment.nick}}</div>
${BUBBLE('#95ec69', '#111111', `<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="text-decoration:none;color:#111111">${BODY_OPEN}{{parent_comment.content}}${BODY_CLOSE}</a>`, 'right')}
</td>
<td width="44" valign="top" style="width:44px">${AVATAR('parent_comment', '{{parent_comment.nick}}')}</td>
</tr>
</table>
</div>
<div style="background-color:#ffffff;border:1px solid #e6efe9;border-radius:12px;padding:18px">
<div style="background-color:#eef6f1;border-radius:12px;padding:16px 14px">
<div style="text-align:center;font-size:11px;color:#8fa096;margin-bottom:14px">《{{page_title}}》 · 对话</div>
<div style="text-align:center;font-size:12px;color:#b2b2b2;padding:2px 0 14px">{{comment.datetime}}</div>
<div style="display:flex;flex-direction:row-reverse;gap:8px;margin-bottom:14px">
<div style="flex:none;width:32px;height:32px;border-radius:50%;background-color:#2fa872;color:#ffffff;font-size:13px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{parent_comment.name_first}}</div>
<div style="max-width:82%">
<div style="font-size:11px;color:#8fa096;margin-bottom:4px;text-align:right">{{parent_comment.nick}} · {{parent_comment.datetime}}</div>
<div style="background-color:#d7f0e2;border-radius:12px 2px 12px 12px;padding:9px 12px;font-size:14px;color:#2b3a31;line-height:1.8;word-break:break-all"><div class="comment_img">{{parent_comment.content}}</div></div>
</div>
</div>
<div style="display:flex;gap:8px">
<div style="flex:none;width:32px;height:32px;border-radius:50%;background-color:#9cbfa9;color:#ffffff;font-size:13px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{comment.name_first}}</div>
<div style="max-width:82%">
<div style="font-size:11px;color:#8fa096;margin-bottom:4px">{{comment.nick}} · {{comment.datetime}}</div>
<div style="background-color:#ffffff;border:1px solid #e6efe9;border-radius:2px 12px 12px 12px;padding:9px 12px;font-size:14px;color:#2b3a31;line-height:1.8;word-break:break-all"><div class="comment_img">{{comment.content}}</div></div>
</div>
</div>
</div>
<a href="{{link_to_reply}}" target="_blank" rel="noopener"
style="display:block;margin-top:16px;background-color:#2fa872;color:#ffffff;text-align:center;padding:11px 0;border-radius:8px;font-size:14px;font-weight:500;text-decoration:none">
查看并回复
</a>
<div style="margin-bottom:8px">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td width="44" valign="top" style="width:44px">${AVATAR('comment', '{{comment.nick}}')}</td>
<td valign="top" style="padding-left:10px">
<div style="font-size:12px;color:#9b9b9b;margin-bottom:5px">{{comment.nick}}</div>
${BUBBLE('#ffffff', '#111111', `<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="text-decoration:none;color:#111111">${BODY_OPEN}{{comment.content}}${BODY_CLOSE}</a>`)}
</td>
</tr>
</table>
</div>
<div style="text-align:center;font-size:12px;color:#8fa096;margin-top:18px;line-height:1.8">
此邮件来自 <a href="{{site_url}}" target="_blank" rel="noopener" style="color:#2fa872;text-decoration:none">{{site_name}}</a>,请勿直接回复<br>
如果你不想再收到这类通知,可以在评论时关闭"接收邮件通知"
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td align="center" style="padding-top:26px">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr><td bgcolor="#07c160" style="background-color:#07c160;border-radius:8px">
<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="display:inline-block;padding:11px 34px;color:#ffffff;font-size:14px;font-weight:500;text-decoration:none">查看并回复</a>
</td></tr>
</table>
</td></tr>
</table>
<div style="text-align:center;font-size:12px;color:#9b9b9b;margin-top:26px;line-height:1.9">
此邮件来自<a href="{{site_url}}" target="_blank" rel="noopener" style="color:#07c160;text-decoration:none">{{site_name}}</a>,请勿直接回复<br>
不想再收到这类提醒,可以在评论时取消勾选「接收邮件通知」
</div>
</div>
</body>
</html>`;
/** 管理员通知(原 notice.html) */
/** 管理员通知:一条新评论(白泡靠左),微信式 */
const NOTICE_TPL = `<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title></title>
${HEAD_STYLE}
</head>
<body style="margin:0;padding:0;background-color:#f3f9f5">
<div style="width:100%;max-width:560px;margin:0 auto;padding:24px 16px;box-sizing:border-box;font-family:'PingFang SC','Hiragino Sans GB','Microsoft YaHei',-apple-system,sans-serif">
<body style="margin:0;padding:0;background-color:#ededed">
<div style="width:100%;max-width:560px;margin:0 auto;background-color:#ededed;padding:20px 16px;box-sizing:border-box;font-family:'PingFang SC','Hiragino Sans GB','Microsoft YaHei',-apple-system,'Segoe UI',sans-serif">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
<span style="width:28px;height:28px;border-radius:8px;background-color:#2fa872;color:#ffffff;font-size:14px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{site_name_first}}</span>
<span style="font-size:15px;font-weight:600;color:#2b3a31">{{site_name}}</span>
<div style="text-align:center;font-size:15px;font-weight:600;color:#1a1a1a;line-height:1.6"><a href="{{link_to_reply}}" target="_blank" rel="noopener" style="color:#07c160;text-decoration:none">《{{page_title}}》</a>有了新的评论</div>
<div style="text-align:center;font-size:12px;color:#b2b2b2;padding:12px 0 20px">{{comment.datetime}}</div>
<div style="margin-bottom:8px">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td width="44" valign="top" style="width:44px">${AVATAR('comment', '{{comment.nick}}')}</td>
<td valign="top" style="padding-left:10px">
<div style="font-size:12px;color:#9b9b9b;margin-bottom:5px">{{comment.nick}}</div>
${BUBBLE('#ffffff', '#111111', `<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="text-decoration:none;color:#111111">${BODY_OPEN}{{comment.content}}${BODY_CLOSE}</a>`)}
</td>
</tr>
</table>
</div>
<div style="background-color:#ffffff;border:1px solid #e6efe9;border-radius:12px;padding:18px">
<div style="background-color:#eef6f1;border-radius:12px;padding:16px 14px">
<div style="text-align:center;font-size:11px;color:#8fa096;margin-bottom:14px">《{{page_title}}》 · 新留言</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td align="center" style="padding-top:26px">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr><td bgcolor="#07c160" style="background-color:#07c160;border-radius:8px">
<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="display:inline-block;padding:11px 34px;color:#ffffff;font-size:14px;font-weight:500;text-decoration:none">前往查看</a>
</td></tr>
</table>
</td></tr>
</table>
<div style="display:flex;gap:8px">
<div style="flex:none;width:32px;height:32px;border-radius:50%;background-color:#9cbfa9;color:#ffffff;font-size:13px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{comment.name_first}}</div>
<div style="max-width:82%">
<div style="font-size:11px;color:#8fa096;margin-bottom:4px">{{comment.nick}} · {{comment.datetime}}</div>
<div style="background-color:#ffffff;border:1px solid #e6efe9;border-radius:2px 12px 12px 12px;padding:9px 12px;font-size:14px;color:#2b3a31;line-height:1.8;word-break:break-all"><div class="comment_img">{{comment.content}}</div></div>
</div>
</div>
</div>
<a href="{{link_to_reply}}" target="_blank" rel="noopener"
style="display:block;margin-top:16px;background-color:#2fa872;color:#ffffff;text-align:center;padding:11px 0;border-radius:8px;font-size:14px;font-weight:500;text-decoration:none">
前往查看
</a>
</div>
<div style="text-align:center;font-size:12px;color:#8fa096;margin-top:18px;line-height:1.8">
此邮件来自 <a href="{{site_url}}" target="_blank" rel="noopener" style="color:#2fa872;text-decoration:none">{{site_name}}</a>,请勿直接回复
<div style="text-align:center;font-size:12px;color:#9b9b9b;margin-top:26px;line-height:1.9">
此邮件来自<a href="{{site_url}}" target="_blank" rel="noopener" style="color:#07c160;text-decoration:none">{{site_name}}</a>博客,请勿直接回复
</div>
</div>
</body>
</html>`;
/** 主题沿用 artalk.yml 里的 mail_subject */
export const REPLY_SUBJECT = '{{site_name}} · 新的评论回复';
export const ADMIN_SUBJECT = '{{site_name}} · 新的评论待审';
/**
* 邮件主题。
* 回复:沿用老配置「新的评论回复」。
* 管理员:原来是「新的评论待审」——像系统告警,改成一句人话。
*/
export const REPLY_SUBJECT = '{{site_name}} · 您的评论收到了新回复';
export const ADMIN_SUBJECT = '{{site_name}} · 您有一个新的评论';
// ------------------------------------------------------------------ 渲染
@@ -226,18 +280,27 @@ const esc = (v: unknown): string =>
/**
* 评论正文 → 邮件里可用的 HTML。
* 先走 Markdown(保留正文里的原生 HTML),再把表情图加上内联宽度 ——
* 原模板靠 <style> 里的 `.comment_img img{width:100px}`,而多数邮件客户端会
* 丢掉 head 里的样式表,所以这里补一份内联的。
*
* 1. 先走 Markdown(保留正文里的裸 <img>,即表情包);
* 2. 再给**每个** <img> 补内联尺寸,因为邮件客户端普遍不加载 head 里的 <style>
* (老模板的 `.comment_img img{width:100px}` 就是这么失效的,一失效表情图
* 就按原始像素铺开 —— 站里有几套表情包是 400px+ 的大图)。
*
* 表情包(带 atk-emoticon 属性):max-height 28px、宽度按比例 —— 微信
* 小表情的体感,混在文字里不打断阅读。
* 普通配图(Markdown 图片或裸 <img>):限宽 100%,不撑破气泡。
*
* 想调表情大小只改 EMOTICON_STYLE 这一处。
*/
const EMOTICON_STYLE = 'max-height:28px;width:auto;vertical-align:middle;margin:0 1px';
const IMG_STYLE = 'max-width:100%;height:auto;border-radius:6px';
function contentHtml(raw: string): string {
return renderMarkdown(String(raw || '')).replace(
/<img\s+([^>]*?)>/gi,
(m, attrs: string) =>
/atk-emoticon=/i.test(attrs) && !/style=/i.test(attrs)
? `<img style="width:100px;height:auto;vertical-align:middle" ${attrs}>`
: m,
);
return renderMarkdown(String(raw || '')).replace(/<img\b[^>]*>/gi, (tag) => {
if (/\bstyle\s*=/i.test(tag)) return tag; // 作者自己写了样式就别覆盖
const style = /\batk-emoticon\s*=/i.test(tag) ? EMOTICON_STYLE : IMG_STYLE;
return tag.replace(/^<img/i, `<img style="${style}"`);
});
}
type Vars = Record<string, string>;