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 = '小赵 '; 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 { 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 { 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 的 `; /** 头像:40px 圆角方(微信样式),weavatar 按 Gravatar 规则用邮箱 md5 取图 */ const AVATAR = (prefix: string, nick: string) => `${nick}`; /** * 气泡:bg 为 #95ec69(自己)/ #ffffff(对方)。 * 气泡本身是一个嵌套 (宽度随内容收缩、上限 360px),比 inline-block * 在邮件客户端里稳得多 —— 图片的 max-width 百分比也能正确解析。 * align="right" 时气泡贴右(微信里自己说的话)。 */ const BUBBLE = (bg: string, color: string, inner: string, align?: 'right') => `
${inner}
`; /** 回复提醒:自己的评论(绿泡靠右)+ 对方的新回复(白泡靠左),微信式上下两条 */ const REPLY_TPL = ` ${HEAD_STYLE}
你在《{{page_title}}》的评论有回复
{{parent_comment.datetime}}
{{parent_comment.nick}}
${BUBBLE('#95ec69', '#111111', `${BODY_OPEN}{{parent_comment.content}}${BODY_CLOSE}`, 'right')}
${AVATAR('parent_comment', '{{parent_comment.nick}}')}
{{comment.datetime}}
${AVATAR('comment', '{{comment.nick}}')}
{{comment.nick}}
${BUBBLE('#ffffff', '#111111', `${BODY_OPEN}{{comment.content}}${BODY_CLOSE}`)}
查看并回复
此邮件来自{{site_name}},请勿直接回复
不想再收到这类提醒,可以在评论时取消勾选「接收邮件通知」
`; /** 管理员通知:一条新评论(白泡靠左),微信式 */ const NOTICE_TPL = ` ${HEAD_STYLE}
《{{page_title}}》有了新的评论
{{comment.datetime}}
${AVATAR('comment', '{{comment.nick}}')}
{{comment.nick}}
${BUBBLE('#ffffff', '#111111', `${BODY_OPEN}{{comment.content}}${BODY_CLOSE}`)}
前往查看
此邮件来自{{site_name}}博客,请勿直接回复
`; /** * 邮件主题。 * 回复:沿用老配置「新的评论回复」。 * 管理员:原来是「新的评论待审」——像系统告警,改成一句人话。 */ export const REPLY_SUBJECT = '{{site_name}} · 您的评论收到了新回复'; export const ADMIN_SUBJECT = '{{site_name}} · 您有一个新的评论'; // ------------------------------------------------------------------ 渲染 const esc = (v: unknown): string => String(v ?? '').replace(/[&<>"']/g, (m) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[m] as string, ); /** * 评论正文 → 邮件里可用的 HTML。 * * 1. 先走 Markdown(保留正文里的裸 ,即表情包); * 2. 再给**每个** 补内联尺寸,因为邮件客户端普遍不加载 head 里的