Files
blog/blog-admin/src/lib/mail.ts
T
zqlit bcfa229105 邮件模板微信化 + 评论老友自动填充 + 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 分钟有效、防重放),
  晚高峰丢包时直达国内机后台免重新登录
2026-10-05 07:36:26 +08:00

507 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Env, CommentRow, UserRow } from '../types';
import { formatDateCN } from './util';
import { md5Lower } from './md5';
import { renderMarkdown } from './md';
/**
* 邮件通知 —— 走 Resend 的 HTTP API。
*
* 为什么不用 SMTP:**Cloudflare Workers 没有 TCP socket**,连不上 SMTP 服务器。
* (Cloudflare 2026-04 推出的原生 Email Service 需要 Workers Paid,
* 本项目跑在免费版上,所以走第三方 HTTP API。)
*
* 没配 RESEND_API_KEY 时全部静默跳过,不影响评论提交。
*/
const RESEND_ENDPOINT = 'https://api.resend.com/emails';
const DEFAULT_FROM = '小赵 <noreply@mail.200181.xyz>';
export function mailEnabled(env: Env): boolean {
return !!env.RESEND_API_KEY;
}
export interface MailInput {
to: string;
subject: string;
html: string;
text?: string;
replyTo?: string;
/** 覆盖发件人显示名(对应旧配置里的 email.send_name) */
fromName?: string;
}
/** 从 `名字 <地址>` 或裸地址里取出纯地址 */
function addrOf(from: string): string {
const m = /<([^>]+)>/.exec(from || '');
return (m ? m[1] : from || '').trim();
}
/** 发一封。失败只记日志,不抛异常 —— 邮件不该影响评论主流程。 */
export async function sendMail(env: Env, mail: MailInput): Promise<boolean> {
if (!env.RESEND_API_KEY) return false;
try {
const baseFrom = env.MAIL_FROM || DEFAULT_FROM;
const from = mail.fromName
? `${mail.fromName} <${addrOf(baseFrom)}>`
: baseFrom;
const res = await fetch(RESEND_ENDPOINT, {
method: 'POST',
headers: {
Authorization: `Bearer ${env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from,
to: [mail.to],
subject: mail.subject,
html: mail.html,
...(mail.text ? { text: mail.text } : {}),
...(mail.replyTo ? { reply_to: mail.replyTo } : {}),
}),
});
if (!res.ok) {
const body = await res.text();
console.error('[mail] resend 返回', res.status, body.slice(0, 300));
return false;
}
return true;
} catch (e) {
console.error('[mail] 请求失败', e instanceof Error ? e.message : e);
return false;
}
}
/**
* 每日发信配额保护。
* Resend 免费版是 **100 封/天**,评论突然变多不能把额度打爆。
* 复用已有的 rate_limits 表当计数器(bucket = "mail:2026-10-02")。
*/
export async function mailQuotaOk(env: Env, limit = 90): Promise<boolean> {
const day = new Date().toISOString().slice(0, 10);
const bucket = `mail:${day}`;
const t = Date.now();
try {
const row = await env.DB.prepare('SELECT count, expires_at FROM rate_limits WHERE bucket = ?')
.bind(bucket)
.first<{ count: number; expires_at: number }>();
if (row && row.expires_at > t && row.count >= limit) return false;
if (!row || row.expires_at <= t) {
await env.DB.prepare(
`INSERT INTO rate_limits (bucket, count, expires_at) VALUES (?, 1, ?)
ON CONFLICT(bucket) DO UPDATE SET count = 1, expires_at = excluded.expires_at`,
)
.bind(bucket, t + 86_400_000)
.run();
} else {
await env.DB.prepare('UPDATE rate_limits SET count = count + 1 WHERE bucket = ?')
.bind(bucket)
.run();
}
return true;
} catch {
return true; // 计数本身失败就别拦着发信
}
}
// ============================================================================
// 模板 —— 微信聊天式:灰底 #EDEDED、自己的评论绿色气泡(#95EC69)靠右、
// 对方的白色气泡靠左、圆角方头像、时间居中 —— 用大家天天在看的聊天直觉
// 来传达「谁说了什么」,一眼就懂,不需要解释。
//
// 以站点的老模板(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/生僻字不会被截断) */
const firstChar = (v: unknown): string => {
const s = String(v ?? '').trim();
return [...s][0] || '?';
};
/** 气泡里的正文块(保留老模板的 .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:#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="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="text-align:center;font-size:12px;color:#b2b2b2;padding:2px 0 14px">{{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>
<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>`;
/** 管理员通知:一条新评论(白泡靠左),微信式 */
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:#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="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>
<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>博客,请勿直接回复
</div>
</div>
</body>
</html>`;
/**
* 邮件主题。
* 回复:沿用老配置「新的评论回复」。
* 管理员:原来是「新的评论待审」——像系统告警,改成一句人话。
*/
export const REPLY_SUBJECT = '{{site_name}} · 您的评论收到了新回复';
export const ADMIN_SUBJECT = '{{site_name}} · 您有一个新的评论';
// ------------------------------------------------------------------ 渲染
const esc = (v: unknown): string =>
String(v ?? '').replace(/[&<>"']/g, (m) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[m] as string,
);
/**
* 评论正文 → 邮件里可用的 HTML。
*
* 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\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>;
function commentVars(prefix: string, c: CommentRow, nick: string, email: string): Vars {
const d = formatDateCN(c.created_at);
return {
[`${prefix}.id`]: esc(c.id),
[`${prefix}.nick`]: esc(nick),
[`${prefix}.name_first`]: esc(firstChar(nick)),
[`${prefix}.email_encrypted`]: md5Lower(email || ''),
[`${prefix}.email`]: esc(email),
[`${prefix}.datetime`]: esc(d),
[`${prefix}.date`]: esc(d.slice(0, 10)),
[`${prefix}.time`]: esc(d.slice(11)),
// 正文与链接是白名单不转义的字段(与原实现 getPurifiedValue 一致)
[`${prefix}.content`]: contentHtml(c.content),
[`${prefix}.content_marked`]: contentHtml(c.content),
[`${prefix}.content_raw`]: esc(c.content),
[`${prefix}.page_key`]: esc(c.page_key),
};
}
/** 页面可访问链接 + 定位到某条评论(对应官方 GetLinkToReplyByComment) */
function linkToReply(pageUrl: string, commentId: number): string {
const base = String(pageUrl || '').trim();
if (!base) return '';
const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}atk_comment=${commentId}`;
}
function renderTpl(tpl: string, vars: Vars): string {
return tpl.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (m, k: string) => (k in vars ? vars[k] : m));
}
export interface ReplyMailInput {
siteName: string;
siteUrl: string;
pageTitle: string;
pageUrl: string;
/** 被回复者(收信人)的评论 */
parentComment: CommentRow;
parentNick: string;
parentEmail: string;
/** 新评论(回复方) */
comment: CommentRow;
commenterNick: string;
commenterEmail: string;
/** 收信人昵称,用于 {{nick}} */
toNick: string;
}
export function replyMailHtml(o: ReplyMailInput): string {
const ltr = linkToReply(o.pageUrl, o.comment.id);
const vars: Vars = {
site_name: esc(o.siteName),
site_name_first: esc(firstChar(o.siteName)),
site_url: esc(o.siteUrl),
page_title: esc(o.pageTitle),
page_url: esc(o.pageUrl),
link_to_reply: ltr, // 白名单:不转义
nick: esc(o.toNick),
reply_nick: esc(o.commenterNick),
reply_content: o.comment.content,
...commentVars('comment', o.comment, o.commenterNick, o.commenterEmail),
...commentVars('from', o.comment, o.commenterNick, o.commenterEmail),
...commentVars('parent_comment', o.parentComment, o.parentNick, o.parentEmail),
...commentVars('to', o.parentComment, o.parentNick, o.parentEmail),
};
return renderTpl(REPLY_TPL, vars);
}
export function replyMailSubject(o: ReplyMailInput): string {
return renderTpl(REPLY_SUBJECT, {
site_name: o.siteName,
reply_nick: o.commenterNick,
page_title: o.pageTitle,
});
}
export interface AdminMailInput {
siteName: string;
siteUrl: string;
pageTitle: string;
pageUrl: string;
comment: CommentRow;
commenterNick: string;
commenterEmail: string;
adminNick: string;
}
export function adminMailHtml(o: AdminMailInput): string {
const vars: Vars = {
site_name: esc(o.siteName),
site_name_first: esc(firstChar(o.siteName)),
site_url: esc(o.siteUrl),
page_title: esc(o.pageTitle),
page_url: esc(o.pageUrl),
link_to_reply: linkToReply(o.pageUrl, o.comment.id),
nick: esc(o.adminNick),
reply_nick: esc(o.commenterNick),
reply_content: o.comment.content,
...commentVars('comment', o.comment, o.commenterNick, o.commenterEmail),
...commentVars('from', o.comment, o.commenterNick, o.commenterEmail),
};
return renderTpl(NOTICE_TPL, vars);
}
export function adminMailSubject(o: AdminMailInput): string {
return renderTpl(ADMIN_SUBJECT, { site_name: o.siteName, page_title: o.pageTitle });
}
// ------------------------------------------------------------------ 触发
export interface NotifyCtx {
newComment: CommentRow;
author: UserRow;
/** 被回复的评论(顶层评论则没有) */
parent: CommentRow | null;
parentAuthor: UserRow | null;
siteName: string;
siteUrl: string;
pageTitle: string;
pageUrl: string;
}
/**
* 发评论之后的邮件通知。整体容错:任何一步失败都不影响评论已提交的事实。
*
* 两个场景:
* 1. 回复别人的评论 → 通知被回复者(尊重其 receive_email 设置)
* 2. 新评论(顶层)→ 通知管理员(MAIL_ADMIN)
*/
export async function notifyByEmail(env: Env, c: NotifyCtx): Promise<void> {
if (!mailEnabled(env)) return;
const targets: { to: string; subject: string; html: string; fromName?: string }[] = [];
const fromName = (env.MAIL_FROM_NAME || '').trim() || undefined;
// --- 被回复者
if (c.parent && c.parentAuthor) {
const pa = c.parentAuthor;
const selfReply = pa.id === c.author.id;
if (!selfReply && pa.receive_email && pa.email && pa.email.includes('@')) {
const input: ReplyMailInput = {
siteName: c.siteName,
siteUrl: c.siteUrl,
pageTitle: c.pageTitle,
pageUrl: c.pageUrl,
parentComment: c.parent,
parentNick: pa.name,
parentEmail: pa.email,
comment: c.newComment,
commenterNick: c.author.name,
commenterEmail: c.author.email,
toNick: pa.name,
};
targets.push({
to: pa.email,
subject: replyMailSubject(input),
html: replyMailHtml(input),
fromName,
});
}
}
// --- 管理员(用 MAIL_ADMIN,不复用 ADMIN_EMAIL)
const adminTo = (env.MAIL_ADMIN || '').trim();
if (
adminTo.includes('@') &&
adminTo.toLowerCase() !== c.author.email.toLowerCase() &&
(!c.parentAuthor || c.parentAuthor.email.toLowerCase() !== adminTo.toLowerCase())
) {
const input: AdminMailInput = {
siteName: c.siteName,
siteUrl: c.siteUrl,
pageTitle: c.pageTitle,
pageUrl: c.pageUrl,
comment: c.newComment,
commenterNick: c.author.name,
commenterEmail: c.author.email,
adminNick: '博主',
};
targets.push({
to: adminTo,
subject: adminMailSubject(input),
html: adminMailHtml(input),
fromName,
});
}
if (!targets.length) return;
for (const t of targets) {
if (!(await mailQuotaOk(env))) {
console.warn('[mail] 已达当日发信上限,跳过');
return;
}
console.log('[mail] 发送中 →', t.to, '|', t.subject);
const okSent = await sendMail(env, t);
if (!okSent) console.warn('[mail] 发送失败 →', t.to);
}
}