归档 artalk-cf 评论后端 + rss-robot 到 blog-admin(含技术选型/模块分布 README)
Deploy to Production / pre-check (push) Successful in 58s
Deploy to Production / build (push) Successful in 4m3s
Deploy to Production / deploy-edgeone (push) Successful in 3m48s
Deploy to Production / finalize (push) Successful in 26s
Deploy to Production / notify-failure (push) Skipped

This commit is contained in:
zqlit committed 2026-10-04 08:45:40 +08:00
1 parent 855a001046
commit a74b3c7127
98 files changed
+15657

No files matched your search

+443
View File
@@ -0,0 +1,443 @@
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; // 计数本身失败就别拦着发信
}
}
// ============================================================================
// 模板 —— 与 artalk.yml 里 mail_tpl / notify_tpl 指向的两个文件一致
// 改模板只需要动下面两段字符串
//
// 风格:聊天对话式(自己的评论靠右绿色气泡,对方靠左白色气泡),
// 配色清新(浅薄荷底 + 白卡 + 鲜绿主色 #2fa872)。
// ============================================================================
/** 取首字(码点安全,emoji/生僻字不会被截断),用作头像字 */
const firstChar = (v: unknown): string => {
const s = String(v ?? '').trim();
return [...s][0] || '?';
};
/** 回复提醒(原 reply.html) */
const REPLY_TPL = `<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
</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">
<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>
<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="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>
<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>
如果你不想再收到这类通知,可以在评论时关闭"接收邮件通知"
</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">
<title></title>
</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">
<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>
<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="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>
</div>
</body>
</html>`;
/** 主题沿用 artalk.yml 里的 mail_subject */
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。
* 先走 Markdown(保留正文里的原生 HTML),再把表情图加上内联宽度 ——
* 原模板靠 <style> 里的 `.comment_img img{width:100px}`,而多数邮件客户端会
* 丢掉 head 里的样式表,所以这里补一份内联的。
*/
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,
);
}
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);
}
}