2026-10-04 08:45:40 +08:00
|
|
|
|
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; // 计数本身失败就别拦着发信
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
2026-10-05 07:36:26 +08:00
|
|
|
|
// 模板 —— 微信聊天式:灰底 #EDEDED、自己的评论绿色气泡(#95EC69)靠右、
|
|
|
|
|
|
// 对方的白色气泡靠左、圆角方头像、时间居中 —— 用大家天天在看的聊天直觉
|
|
|
|
|
|
// 来传达「谁说了什么」,一眼就懂,不需要解释。
|
2026-10-04 08:45:40 +08:00
|
|
|
|
//
|
2026-10-05 07:36:26 +08:00
|
|
|
|
// 以站点的老模板(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)。
|
2026-10-04 08:45:40 +08:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
2026-10-05 07:36:26 +08:00
|
|
|
|
/** 取首字(码点安全,emoji/生僻字不会被截断) */
|
2026-10-04 08:45:40 +08:00
|
|
|
|
const firstChar = (v: unknown): string => {
|
|
|
|
|
|
const s = String(v ?? '').trim();
|
|
|
|
|
|
return [...s][0] || '?';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-05 07:36:26 +08:00
|
|
|
|
/** 气泡里的正文块(保留老模板的 .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>`;
|
|
|
|
|
|
|
|
|
|
|
|
/** 回复提醒:自己的评论(绿泡靠右)+ 对方的新回复(白泡靠左),微信式上下两条 */
|
2026-10-04 08:45:40 +08:00
|
|
|
|
const REPLY_TPL = `<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
2026-10-05 07:36:26 +08:00
|
|
|
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
2026-10-04 08:45:40 +08:00
|
|
|
|
<title></title>
|
2026-10-05 07:36:26 +08:00
|
|
|
|
${HEAD_STYLE}
|
2026-10-04 08:45:40 +08:00
|
|
|
|
</head>
|
2026-10-05 07:36:26 +08:00
|
|
|
|
<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">
|
2026-10-04 08:45:40 +08:00
|
|
|
|
|
2026-10-05 07:36:26 +08:00
|
|
|
|
<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>
|
2026-10-04 08:45:40 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-10-05 07:36:26 +08:00
|
|
|
|
<div style="text-align:center;font-size:12px;color:#b2b2b2;padding:2px 0 14px">{{comment.datetime}}</div>
|
2026-10-04 08:45:40 +08:00
|
|
|
|
|
2026-10-05 07:36:26 +08:00
|
|
|
|
<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>
|
2026-10-04 08:45:40 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-10-05 07:36:26 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
不想再收到这类提醒,可以在评论时取消勾选「接收邮件通知」
|
2026-10-04 08:45:40 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>`;
|
|
|
|
|
|
|
2026-10-05 07:36:26 +08:00
|
|
|
|
/** 管理员通知:一条新评论(白泡靠左),微信式 */
|
2026-10-04 08:45:40 +08:00
|
|
|
|
const NOTICE_TPL = `<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
2026-10-05 07:36:26 +08:00
|
|
|
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
2026-10-04 08:45:40 +08:00
|
|
|
|
<title></title>
|
2026-10-05 07:36:26 +08:00
|
|
|
|
${HEAD_STYLE}
|
2026-10-04 08:45:40 +08:00
|
|
|
|
</head>
|
2026-10-05 07:36:26 +08:00
|
|
|
|
<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">
|
2026-10-04 08:45:40 +08:00
|
|
|
|
|
2026-10-05 07:36:26 +08:00
|
|
|
|
<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>
|
2026-10-04 08:45:40 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-10-05 07:36:26 +08:00
|
|
|
|
<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>
|
2026-10-04 08:45:40 +08:00
|
|
|
|
|
2026-10-05 07:36:26 +08:00
|
|
|
|
<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>博客,请勿直接回复
|
2026-10-04 08:45:40 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>`;
|
|
|
|
|
|
|
2026-10-05 07:36:26 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 邮件主题。
|
|
|
|
|
|
* 回复:沿用老配置「新的评论回复」。
|
|
|
|
|
|
* 管理员:原来是「新的评论待审」——像系统告警,改成一句人话。
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const REPLY_SUBJECT = '{{site_name}} · 您的评论收到了新回复';
|
|
|
|
|
|
export const ADMIN_SUBJECT = '{{site_name}} · 您有一个新的评论';
|
2026-10-04 08:45:40 +08:00
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ 渲染
|
|
|
|
|
|
|
|
|
|
|
|
const esc = (v: unknown): string =>
|
|
|
|
|
|
String(v ?? '').replace(/[&<>"']/g, (m) =>
|
|
|
|
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[m] as string,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 评论正文 → 邮件里可用的 HTML。
|
2026-10-05 07:36:26 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 1. 先走 Markdown(保留正文里的裸 <img>,即表情包);
|
|
|
|
|
|
* 2. 再给**每个** <img> 补内联尺寸,因为邮件客户端普遍不加载 head 里的 <style>
|
|
|
|
|
|
* (老模板的 `.comment_img img{width:100px}` 就是这么失效的,一失效表情图
|
|
|
|
|
|
* 就按原始像素铺开 —— 站里有几套表情包是 400px+ 的大图)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 表情包(带 atk-emoticon 属性):max-height 28px、宽度按比例 —— 微信
|
|
|
|
|
|
* 小表情的体感,混在文字里不打断阅读。
|
|
|
|
|
|
* 普通配图(Markdown 图片或裸 <img>):限宽 100%,不撑破气泡。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 想调表情大小只改 EMOTICON_STYLE 这一处。
|
2026-10-04 08:45:40 +08:00
|
|
|
|
*/
|
2026-10-05 07:36:26 +08:00
|
|
|
|
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';
|
|
|
|
|
|
|
2026-10-04 08:45:40 +08:00
|
|
|
|
function contentHtml(raw: string): string {
|
2026-10-05 07:36:26 +08:00
|
|
|
|
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}"`);
|
|
|
|
|
|
});
|
2026-10-04 08:45:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|