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; // 计数本身失败就别拦着发信 } } // ============================================================================ // 模板 —— 以站点的原版模板(notice.html / reply.html)为底,UI 完全保持 // 原设计:#f4f5f8 灰底卡片、居中标题、深色聊天气泡 #373737 + 三角箭头、 // 35px 圆头像(weavatar,邮箱 md5,明文不出现在邮件里)、点气泡跳转评论页。 // // 相比原版只动逻辑层,UI 一像素不差之外的三处细节打磨: // 1. 表情包尺寸内联到位:原版靠 head 里 `.comment_img img{width:100px}` // 控制大小,而不少邮件客户端会把 head 的 `; /** 原版卡片外框(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) => `${nick}`; /** * 原版深色气泡 #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 `
{{${prefix}.nick}}
${AVATAR(prefix, nick)}
`; }; /** 原版结尾:「温馨提示」+ 来源声明 */ const FOOTER = `
温馨提示:点击聊天气泡跳转评论页
此邮件来自{{site_name}}博客,请勿直接回复,谢谢合作!
`; /** 回复提醒:原版结构 —— 收信人自己的评论(气泡靠右)在上,新回复(靠左)在下 */ const REPLY_TPL = ` ${HEAD_STYLE}
你在《{{page_title}}》的评论有回复
{{parent_comment.datetime}}
${BUBBLE_ROW('right', 'parent_comment', '{{parent_comment.nick}}')}
{{comment.datetime}}
${BUBBLE_ROW('left', 'comment', '{{comment.nick}}')}
${FOOTER}
`; /** 管理员通知:原版结构 —— 一条新评论(气泡靠左) */ const NOTICE_TPL = ` ${HEAD_STYLE}
《{{page_title}}》页面有了新的评论
{{comment.datetime}}
${BUBBLE_ROW('left', 'comment', '{{comment.nick}}')}
${FOOTER}
`; // ------------------------------------------------------------------ 渲染 const esc = (v: unknown): string => String(v ?? '').replace(/[&<>"']/g, (m) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[m] as string, ); /** * 评论正文 → 邮件里可用的 HTML。 * * 1. 先走 Markdown(保留正文里的裸 ,即表情包); * 2. 再给**每个** 补内联尺寸,因为邮件客户端普遍不加载 head 里的