Files
blog/blog-admin/src/lib/admin-notify.ts
T
zqlit a74b3c7127
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
归档 artalk-cf 评论后端 + rss-robot 到 blog-admin(含技术选型/模块分布 README)
2026-10-04 08:45:40 +08:00

86 lines
3.0 KiB
TypeScript

// 给管理员发提醒邮件(带节流)。
//
// 用途:定时任务(证书/代理体检、用量告警等)需要给站长发提醒,
// 但又不该每天重复刷屏。收件人固定为 MAIL_ADMIN,发件人默认「小赵」。
//
// 节流:同一 dedupeKey 在 minGapHours 内只发一次,时间戳存 KV `notify:last:<key>`。
import type { Env } from '../types';
import { sendMail } from './mail';
export interface AdminNotifyInput {
subject: string;
text: string;
/** 同一 key 在 minGapHours 内只发一次;不传则不节流 */
dedupeKey?: string;
minGapHours?: number;
fromName?: string;
}
export interface AdminNotifyResult {
ok: boolean;
sent: boolean;
throttled?: boolean;
lastAt?: string;
error?: string;
}
function escapeHtml(x: string): string {
return x.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export function textToHtml(subject: string, text: string): string {
const paras = text
.split('\n')
.map((l) =>
l.trim()
? `<p style="margin:0 0 8px">${escapeHtml(l)}</p>`
: '<p style="margin:0 0 8px">&nbsp;</p>',
)
.join('');
return (
`<div style="max-width:560px;margin:0 auto;padding:20px;font-family:'PingFang SC','Microsoft YaHei',sans-serif;color:#253830">` +
`<div style="font-size:15px;font-weight:600;color:#2f9e63;margin-bottom:12px">${escapeHtml(subject)}</div>` +
`<div style="background:#fff;border:1px solid #e7e9ee;border-radius:10px;padding:16px;font-size:14px;line-height:1.7">${paras}</div>` +
`<div style="font-size:12px;color:#8a94a6;margin-top:12px">来自 artalk-cf 定时提醒</div></div>`
);
}
export async function notifyAdmin(env: Env, input: AdminNotifyInput): Promise<AdminNotifyResult> {
const to = (env.MAIL_ADMIN || '').trim();
if (!to) return { ok: false, sent: false, error: 'MAIL_ADMIN not configured' };
const subject = String(input.subject || '').trim().slice(0, 160) || '提醒';
const text = String(input.text || '').trim().slice(0, 8000);
if (!text) return { ok: false, sent: false, error: 'text required' };
const dedupeKey = String(input.dedupeKey || '').trim().slice(0, 60);
const minGapHours = Math.min(Math.max(input.minGapHours || 0, 0), 24 * 30);
const throttleKey = dedupeKey ? `notify:last:${dedupeKey}` : '';
if (throttleKey && minGapHours > 0) {
const last = await env.RSS_KV.get(throttleKey);
const lastAt = last ? parseInt(last, 10) : 0;
if (lastAt && Date.now() - lastAt < minGapHours * 3600 * 1000) {
return { ok: true, sent: false, throttled: true, lastAt: new Date(lastAt).toISOString() };
}
}
const sent = await sendMail(env, {
to,
subject,
html: textToHtml(subject, text),
text,
fromName: input.fromName || '小赵',
});
if (sent && throttleKey) {
try {
await env.RSS_KV.put(throttleKey, String(Date.now()), { expirationTtl: 60 * 60 * 24 * 90 });
} catch {
/* 节流时间戳写失败不影响已发出的邮件 */
}
}
return { ok: sent, sent };
}