Files
blog/blog-admin/src/lib/mail.ts
T
zqlit 4fa592c11c feat(comments): 匿名评论升级为「悄悄话」——只藏内容不藏身份;fix: PJAX 瞬断自动重试
- 悄悄话:头像/昵称/头衔/等级/IP归属照常显示,仅内容对非管理员请求剥空
  (F12/网络面板拿不到原文),博主后台/登录态/邮件看全文
- 开关挪进底栏 .atk-bottom-left、表情/预览按钮右边,移动端不挤;
  回复悄悄话勾不勾随自己(去掉自动勾选)
- 前端识别:服务端剥空后 .atk-content 为空即悄悄话(空评论会被后端拒绝、
  纯表情评论有 img 不误判),无需解析 API
- 邮件页面标题书名号《》保留(按用户要求)
- PJAX 偶发「连不上服务器」:GET /api/v2 网络错误或 5xx 静默重试 2 次
  (700ms/1800ms 退避),POST 不重试,被 abort 的请求不续命
2026-10-05 10:05:21 +08:00

487 lines
19 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; // 计数本身失败就别拦着发信
}
}
// ============================================================================
// 模板 —— 以站点的原版模板(notice.html / reply.html)为底,UI 完全保持
// 原设计:#f4f5f8 灰底卡片、居中标题、深色聊天气泡 #373737 + 三角箭头、
// 35px 圆头像(weavatar,邮箱 md5,明文不出现在邮件里)、点气泡跳转评论页。
//
// 相比原版只动逻辑层,UI 一像素不差之外的三处细节打磨:
// 1. 表情包尺寸内联到位:原版靠 head 里 `.comment_img img{width:100px}`
// 控制大小,而不少邮件客户端会把 head 的 <style> 整段丢掉;一丢,
// 表情图就按**原始像素**铺开(站里有几套是 400px+ 的大图),气泡被
// 撑爆 —— 这就是「表情太大看着奇怪」的真因。现在每个 <img> 都带
// 内联尺寸:表情 max-height 28px,配图限宽 100%(不撑破气泡)。
// 2. 邮件主题改成一句人话(见 ADMIN_SUBJECT / REPLY_SUBJECT)。
// 3. 字体栈、头像 alt 等无障碍小细节。
// ============================================================================
/**
* 邮件主题。
* 回复:沿用老配置「新的评论回复」。
* 管理员:原来是「新的评论待审」——像系统告警,改成一句人话。
*/
export const REPLY_SUBJECT = '{{site_name}} · 您的评论收到了新回复';
export const ADMIN_SUBJECT = '{{site_name}} · 您有一个新的评论';
// ------------------------------------------------------------------ 模板本体
/** 取首字(码点安全,emoji/生僻字不会被截断) */
const firstChar = (v: unknown): string => {
const s = String(v ?? '').trim();
return [...s][0] || '?';
};
/** 原版 head 里的样式:整体 reset + 表情尺寸兜底(客户端丢 head 时由内联样式接管) */
const HEAD_STYLE = `<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box
}
.comment_img img{
width: 100px;
}
</style>`;
/** 原版卡片外框(550px 灰底卡片),font-family 属于细节打磨,不影响版式 */
const WRAP_STYLE =
'width: 100%;max-width: 550px;margin:1rem auto;background-color: #f4f5f8;padding: 1rem;box-sizing: border-box;' +
"font-family:-apple-system,'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif";
/** 原版 35px 圆头像(weavatar 按 Gravatar 规则用邮箱 md5 取图,明文不出现在邮件里) */
const AVATAR = (prefix: string, nick: string) =>
`<img src="https://weavatar.com/avatar/{{${prefix}.email_encrypted}}" alt="${nick}" style="width: 35px;height: 35px;border-radius: 50%;">`;
/**
* 原版深色气泡 #373737 + 三角箭头,正文 13px 白字,点气泡跳转评论页。
* dir=left:箭头在左(对方说的话,头像在左);dir=right:镜像,头像在右。
*/
const BUBBLE_ROW = (dir: 'left' | 'right', prefix: string, nick: string) => {
const right = dir === 'right';
const rowStyle = right ? 'display: flex;justify-content: flex-end;margin-bottom: 5px' : 'display: flex;margin-bottom: 5px';
const avatarCell = right ? 'width: auto;flex: none;order: 2' : 'width: auto;flex: none';
const bubbleCell = right ? 'position: relative;margin-right: 20px' : 'position: relative;margin-left: 20px';
const arrow = right
? 'width: 0;height: 0;border-top:8px solid transparent;border-bottom:8px solid transparent;border-left:8px solid;border-left-color:#373737;border-right:0;border-right-color:transparent;right: -8px;left: auto;top: 12px;position: absolute'
: 'width: 0;height: 0;border-top: 8px solid transparent;border-bottom: 8px solid transparent;border-right: 8px solid;border-right-color: #373737;left: -8px;right: auto;top: 12px;position: absolute';
const nickStyle = right
? 'text-align: right;line-height: 2rem;font-size: 13px;font-weight: bold;margin-right: 5px;'
: 'line-height: 2rem;font-size: 13px;font-weight: bold;margin-left: 5px;';
return ` <div style="${nickStyle}">{{${prefix}.nick}}</div>
<div style="${rowStyle}">
<div style="${avatarCell}">${AVATAR(prefix, nick)}</div>
<div style="${bubbleCell}"><span
style="${arrow}"></span>
<div
style="background-color: #373737;padding: 10px;border-radius: 9px;margin-bottom: 3px;font-size: 13px;color: #ffffff;word-break: break-all;"><a href="{{link_to_reply}}" target="_blank" rel="noopener"
style="text-decoration: none;color:#ffffff;"><div class="comment_img">{{${prefix}.content}}</div></a></div>
</div>
</div>`;
};
/** 原版结尾:「温馨提示」+ 来源声明 */
const FOOTER = ` <div style="text-align: center;padding: 3rem 0;font-size: 12px;"></div>
<div style="text-align: center;color: #555555;font-size: 12px;">温馨提示:点击聊天气泡跳转评论页</div>
<div style="text-align: center;color: #555555;font-size: 12px;">此邮件来自<a href="{{site_url}}" target="_blank" rel="noopener" style="text-decoration: none;color:#373737;">{{site_name}}</a>博客,请勿直接回复,谢谢合作!</div>`;
/** 回复提醒:原版结构 —— 收信人自己的评论(气泡靠右)在上,新回复(靠左)在下 */
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>
<div style="${WRAP_STYLE}">
<div style="font-size: 16px;line-height: 1.5;font-weight: bold;">
<div style="text-align: center;">你在<a href="{{link_to_reply}}" target="_blank" rel="noopener"
style="text-decoration: none;color:#373737;">《{{page_title}}》</a>的评论有回复</div>
</div><!--!!!-->
<div style="text-align: center;font-size: 12px;color: #c8c8c8;padding: 1rem 0;">{{parent_comment.datetime}}</div>
<div style="display: flex;flex-direction:column;margin-bottom: 3rem;">
${BUBBLE_ROW('right', 'parent_comment', '{{parent_comment.nick}}')}
</div><!--!!!--><!--!!!-->
<div style="text-align: center;font-size: 12px;color: #c8c8c8;padding: 1rem 0;">{{comment.datetime}}</div>
<div style="display: flex;flex-direction:column;">
${BUBBLE_ROW('left', 'comment', '{{comment.nick}}')}
</div><!--!!!-->
${FOOTER}
</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>
<div style="${WRAP_STYLE}">
<div style="font-size: 16px;line-height: 1.5;font-weight: bold;">
<div style="text-align: center;"><a href="{{link_to_reply}}" target="_blank" rel="noopener"
style="text-decoration: none;color:#373737;">《{{page_title}}》</a>页面有了新的评论</div>
</div><!--!!!-->
<div style="text-align: center;font-size: 12px;color: #c8c8c8;padding: 1rem 0;">{{comment.datetime}}</div>
<div style="display: flex;flex-direction:column;">
${BUBBLE_ROW('left', 'comment', '{{comment.nick}}')}
</div><!--!!!-->
${FOOTER}
</div>
</body>
</html>`;
// ------------------------------------------------------------------ 渲染
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);
}
}