443 lines
17 KiB
TypeScript
443 lines
17 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; // 计数本身失败就别拦着发信
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// 模板 —— 与 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) =>
|
|||
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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);
|
|||
|
|
}
|
|||
|
|
}
|