- 悄悄话:头像/昵称/头衔/等级/IP归属照常显示,仅内容对非管理员请求剥空 (F12/网络面板拿不到原文),博主后台/登录态/邮件看全文 - 开关挪进底栏 .atk-bottom-left、表情/预览按钮右边,移动端不挤; 回复悄悄话勾不勾随自己(去掉自动勾选) - 前端识别:服务端剥空后 .atk-content 为空即悄悄话(空评论会被后端拒绝、 纯表情评论有 img 不误判),无需解析 API - 邮件页面标题书名号《》保留(按用户要求) - PJAX 偶发「连不上服务器」:GET /api/v2 网络错误或 5xx 静默重试 2 次 (700ms/1800ms 退避),POST 不重试,被 abort 的请求不续命
487 lines
19 KiB
TypeScript
487 lines
19 KiB
TypeScript
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; // 计数本身失败就别拦着发信
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 模板 —— 以站点的原版模板(notice.html / reply.html)为底,UI 完全保持
|
||
// 原设计:#f4f5f8 灰底卡片、居中标题、深色聊天气泡 #373737 + 三角箭头、
|
||
// 35px 圆头像(weavatar,邮箱 md5,明文不出现在邮件里)、点气泡跳转评论页。
|
||
//
|
||
// 相比原版只动逻辑层,UI 一像素不差之外的三处细节打磨:
|
||
// 1. 表情包尺寸内联到位:原版靠 head 里 `.comment_img img{width:100px}`
|
||
// 控制大小,而不少邮件客户端会把 head 的 <style> 整段丢掉;一丢,
|
||
// 表情图就按**原始像素**铺开(站里有几套是 400px+ 的大图),气泡被
|
||
// 撑爆 —— 这就是「表情太大看着奇怪」的真因。现在每个 <img> 都带
|
||
// 内联尺寸:表情 max-height 28px,配图限宽 100%(不撑破气泡)。
|
||
// 2. 邮件主题改成一句人话(见 ADMIN_SUBJECT / REPLY_SUBJECT)。
|
||
// 3. 字体栈、头像 alt 等无障碍小细节。
|
||
// ============================================================================
|
||
|
||
/**
|
||
* 邮件主题。
|
||
* 回复:沿用老配置「新的评论回复」。
|
||
* 管理员:原来是「新的评论待审」——像系统告警,改成一句人话。
|
||
*/
|
||
export const REPLY_SUBJECT = '{{site_name}} · 您的评论收到了新回复';
|
||
export const ADMIN_SUBJECT = '{{site_name}} · 您有一个新的评论';
|
||
|
||
// ------------------------------------------------------------------ 模板本体
|
||
|
||
/** 取首字(码点安全,emoji/生僻字不会被截断) */
|
||
const firstChar = (v: unknown): string => {
|
||
const s = String(v ?? '').trim();
|
||
return [...s][0] || '?';
|
||
};
|
||
|
||
/** 原版 head 里的样式:整体 reset + 表情尺寸兜底(客户端丢 head 时由内联样式接管) */
|
||
const HEAD_STYLE = `<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box
|
||
}
|
||
.comment_img img{
|
||
width: 100px;
|
||
}
|
||
</style>`;
|
||
|
||
/** 原版卡片外框(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) =>
|
||
`<img src="https://weavatar.com/avatar/{{${prefix}.email_encrypted}}" alt="${nick}" style="width: 35px;height: 35px;border-radius: 50%;">`;
|
||
|
||
/**
|
||
* 原版深色气泡 #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 ` <div style="${nickStyle}">{{${prefix}.nick}}</div>
|
||
<div style="${rowStyle}">
|
||
<div style="${avatarCell}">${AVATAR(prefix, nick)}</div>
|
||
<div style="${bubbleCell}"><span
|
||
style="${arrow}"></span>
|
||
<div
|
||
style="background-color: #373737;padding: 10px;border-radius: 9px;margin-bottom: 3px;font-size: 13px;color: #ffffff;word-break: break-all;"><a href="{{link_to_reply}}" target="_blank" rel="noopener"
|
||
style="text-decoration: none;color:#ffffff;"><div class="comment_img">{{${prefix}.content}}</div></a></div>
|
||
</div>
|
||
</div>`;
|
||
};
|
||
|
||
/** 原版结尾:「温馨提示」+ 来源声明 */
|
||
const FOOTER = ` <div style="text-align: center;padding: 3rem 0;font-size: 12px;"></div>
|
||
<div style="text-align: center;color: #555555;font-size: 12px;">温馨提示:点击聊天气泡跳转评论页</div>
|
||
<div style="text-align: center;color: #555555;font-size: 12px;">此邮件来自<a href="{{site_url}}" target="_blank" rel="noopener" style="text-decoration: none;color:#373737;">{{site_name}}</a>博客,请勿直接回复,谢谢合作!</div>`;
|
||
|
||
/** 回复提醒:原版结构 —— 收信人自己的评论(气泡靠右)在上,新回复(靠左)在下 */
|
||
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>
|
||
<div style="${WRAP_STYLE}">
|
||
<div style="font-size: 16px;line-height: 1.5;font-weight: bold;">
|
||
<div style="text-align: center;">你在<a href="{{link_to_reply}}" target="_blank" rel="noopener"
|
||
style="text-decoration: none;color:#373737;">《{{page_title}}》</a>的评论有回复</div>
|
||
</div><!--!!!-->
|
||
<div style="text-align: center;font-size: 12px;color: #c8c8c8;padding: 1rem 0;">{{parent_comment.datetime}}</div>
|
||
<div style="display: flex;flex-direction:column;margin-bottom: 3rem;">
|
||
${BUBBLE_ROW('right', 'parent_comment', '{{parent_comment.nick}}')}
|
||
</div><!--!!!--><!--!!!-->
|
||
<div style="text-align: center;font-size: 12px;color: #c8c8c8;padding: 1rem 0;">{{comment.datetime}}</div>
|
||
<div style="display: flex;flex-direction:column;">
|
||
${BUBBLE_ROW('left', 'comment', '{{comment.nick}}')}
|
||
</div><!--!!!-->
|
||
${FOOTER}
|
||
</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>
|
||
<div style="${WRAP_STYLE}">
|
||
<div style="font-size: 16px;line-height: 1.5;font-weight: bold;">
|
||
<div style="text-align: center;"><a href="{{link_to_reply}}" target="_blank" rel="noopener"
|
||
style="text-decoration: none;color:#373737;">《{{page_title}}》</a>页面有了新的评论</div>
|
||
</div><!--!!!-->
|
||
<div style="text-align: center;font-size: 12px;color: #c8c8c8;padding: 1rem 0;">{{comment.datetime}}</div>
|
||
<div style="display: flex;flex-direction:column;">
|
||
${BUBBLE_ROW('left', 'comment', '{{comment.nick}}')}
|
||
</div><!--!!!-->
|
||
${FOOTER}
|
||
</div>
|
||
</body>
|
||
|
||
</html>`;
|
||
|
||
// ------------------------------------------------------------------ 渲染
|
||
|
||
const esc = (v: unknown): string =>
|
||
String(v ?? '').replace(/[&<>"']/g, (m) =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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);
|
||
}
|
||
}
|