Files
blog/blog-admin/src/lib/mail.ts
T

506 lines
20 KiB
TypeScript
Raw Normal View History

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);
}
}