/** * 通知发送:邮件(手写 SMTP,零依赖)+ MeoW 推送 * * 为什么手写 SMTP:本项目刻意保持零运行时依赖(Node 内置模块够用), * 引入 nodemailer 会带来一串传递依赖,对一个自用面板不划算。 * 只实现发信必需的流程:EHLO → (STARTTLS) → AUTH → MAIL/RCPT/DATA → QUIT。 * * MeoW 接口要点(据 https://www.chuckfang.com/MeoW/api_doc.html ): * - 无需 token,昵称即寻址:POST https://api.chuckfang.com/{昵称} * - HTTP 状态恒为 200,成功与否看响应体的 status 字段 * - msgType / htmlHeight 是查询参数,不是 body 参数 * - 非会员限流:3 秒 1 条、每分钟 15 条、每小时 60 条 * - 官方提醒:不要用多人共享的出口 IP(CI 环境建议只用邮件) */ import net from 'node:net'; import tls from 'node:tls'; const CRLF = '\r\n'; /* ---------- 邮件 ---------- */ function encodeHeader(value) { // 非 ASCII 主题按 RFC 2047 base64 编码,避免中文主题乱码 return /^[\x20-\x7E]*$/.test(value) ? value : `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`; } function base64Body(text) { // 正文用 base64:既免去 quoted-printable 的编码复杂度,也不会出现以 "." 开头的行(无需 dot-stuffing) return (Buffer.from(text, 'utf8').toString('base64').match(/.{1,76}/g) ?? []).join(CRLF); } function buildMessage({ from, to, subject, text }) { return [ `From: ${from}`, `To: ${to.join(', ')}`, `Subject: ${encodeHeader(subject)}`, `Date: ${new Date().toUTCString()}`, 'MIME-Version: 1.0', 'Content-Type: text/plain; charset=UTF-8', 'Content-Transfer-Encoding: base64', '', base64Body(text), '', ].join(CRLF); } /** 极简 SMTP 会话:读 3 位状态码响应(支持多行 `250-` 连续响应) */ function createSession(socket, timeoutMs) { let buffer = ''; let waiter = null; socket.setEncoding('utf8'); socket.on('data', (chunk) => { buffer += chunk; if (!waiter) return; // 一条完整响应以 `\d{3} `(空格结尾)结束 const lines = buffer.split(CRLF); const complete = lines.some((l) => /^\d{3} /.test(l)); if (complete) { const reply = buffer; buffer = ''; const w = waiter; waiter = null; w.resolve(reply); } }); const fail = (err) => { if (waiter) { const w = waiter; waiter = null; w.reject(err); } }; socket.on('error', fail); socket.on('close', () => fail(new Error('SMTP 连接被关闭'))); // 空闲超时必须主动断开:否则服务器不响应时读响应会一直挂着,把探针循环卡死 socket.on('timeout', () => { socket.destroy(); fail(new Error(`SMTP 响应超时(${timeoutMs}ms)`)); }); return { read: () => new Promise((resolve, reject) => { const timer = setTimeout(() => { waiter = null; reject(new Error(`SMTP 响应超时(${timeoutMs}ms)`)); }, timeoutMs); waiter = { resolve: (v) => { clearTimeout(timer); resolve(v); }, reject: (e) => { clearTimeout(timer); reject(e); }, }; }), write: (line) => socket.write(line + CRLF), code: (reply) => Number(reply.slice(0, 3)), raw: () => socket, }; } async function connect(socket) { await new Promise((resolve, reject) => { socket.once('connect', resolve); socket.once('secureConnect', resolve); socket.once('error', reject); }); return socket; } export async function sendEmail(cfg, { subject, text }, { timeoutMs = 15000 } = {}) { const host = cfg.host; const port = Number(cfg.port) || 465; if (!host) throw new Error('邮件通道缺少 host'); if (!cfg.from) throw new Error('邮件通道缺少发件人 from'); const to = Array.isArray(cfg.to) ? cfg.to.filter(Boolean) : [cfg.to].filter(Boolean); if (!to.length) throw new Error('邮件通道缺少收件人 to'); // 465 走隐式 TLS;587/25 先明文再 STARTTLS const implicitTls = port === 465 || cfg.secure === true; let socket = implicitTls ? tls.connect({ host, port, servername: cfg.servername || host, rejectUnauthorized: cfg.rejectUnauthorized !== false }) : net.connect({ host, port }); socket.setTimeout(timeoutMs); await connect(socket); let session = createSession(socket, timeoutMs); const expect = async (wanted) => { const reply = await session.read(); const code = session.code(reply); if (code !== wanted) throw new Error(`SMTP 期望 ${wanted} 但收到:${reply.trim().split(CRLF).pop()}`); return reply; }; await expect(220); session.write(`EHLO ${cfg.helo || 'shiroko-monitor'}`); let caps = await expect(250); if (!implicitTls) { if (/STARTTLS/i.test(caps)) { session.write('STARTTLS'); await expect(220); socket = await new Promise((resolve, reject) => { const upgraded = tls.connect({ socket, servername: cfg.servername || host, rejectUnauthorized: cfg.rejectUnauthorized !== false }, () => resolve(upgraded)); upgraded.once('error', reject); }); socket.setTimeout(timeoutMs); session = createSession(socket, timeoutMs); session.write(`EHLO ${cfg.helo || 'shiroko-monitor'}`); caps = await expect(250); } else if (cfg.allowInsecure !== true) { // 默认拒绝明文发信(凭据会裸奔);仅测试或本机可信中继场景下显式开启 throw new Error('SMTP 服务器不支持 STARTTLS,已中止(如需明文传输请显式设置 allowInsecure: true)'); } } if (cfg.user) { if (/AUTH[ =-].*PLAIN/i.test(caps) && !/AUTH[ =-].*LOGIN/i.test(caps)) { session.write(`AUTH PLAIN ${Buffer.from(`\0${cfg.user}\0${cfg.pass ?? ''}`, 'utf8').toString('base64')}`); } else { session.write('AUTH LOGIN'); await expect(334); session.write(Buffer.from(cfg.user, 'utf8').toString('base64')); await expect(334); session.write(Buffer.from(cfg.pass ?? '', 'utf8').toString('base64')); } await expect(235); } session.write(`MAIL FROM:<${cfg.from}>`); await expect(250); for (const rcpt of to) { session.write(`RCPT TO:<${rcpt}>`); const reply = await session.read(); if (![250, 251].includes(session.code(reply))) throw new Error(`收件人被拒(${rcpt}):${reply.trim()}`); } session.write('DATA'); await expect(354); socket.write(buildMessage({ from: cfg.from, to, subject, text }) + CRLF + '.' + CRLF); await expect(250); session.write('QUIT'); socket.end(); return { channel: 'email', ok: true, detail: `已投递至 ${to.join(', ')}` }; } /* ---------- MeoW ---------- */ export async function sendMeoW(cfg, { title, text }, { timeoutMs = 12000, fetchImpl = fetch } = {}) { const nickname = cfg.nickname; if (!nickname) throw new Error('MeoW 通道缺少 nickname'); if (nickname.includes('/')) throw new Error('MeoW 昵称不允许包含斜杠'); const base = (cfg.baseUrl || 'https://api.chuckfang.com').replace(/\/+$/, ''); const params = new URLSearchParams(); if (cfg.msgType && cfg.msgType !== 'text') { params.set('msgType', cfg.msgType); if (cfg.htmlHeight) params.set('htmlHeight', String(cfg.htmlHeight)); } if (cfg.url) params.set('url', cfg.url); const query = params.toString() ? `?${params}` : ''; const res = await fetchImpl(`${base}/${encodeURIComponent(nickname)}${query}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title, msg: text }), signal: AbortSignal.timeout(timeoutMs), }); let payload = null; try { payload = await res.json(); } catch { throw new Error(`MeoW 响应不是 JSON(HTTP ${res.status})`); } // 注意:HTTP 状态恒为 200,必须以响应体的 status 为准 const status = Number(payload?.status); if (status !== 200) { const reason = payload?.message || payload?.msg || '未知错误'; const hint = status === 429 ? '(触发频率限制:3 秒 1 条 / 15 每分钟 / 60 每小时)' : status === 404 ? '(昵称未注册)' : status === 403 ? '(内容被策略拦截)' : ''; throw new Error(`MeoW 推送失败 status=${status} ${reason}${hint}`); } return { channel: 'meow', ok: true, detail: payload.message || payload.msg || '推送成功' }; } /* ---------- 调度 ---------- */ export function parseQuietHours(spec) { if (!spec) return null; const m = /^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/.exec(String(spec).trim()); if (!m) return null; const [from, to] = [Number(m[1]) * 60 + Number(m[2]), Number(m[3]) * 60 + Number(m[4])]; return { from, to }; } export function inQuietHours(spec, date = new Date()) { const range = parseQuietHours(spec); if (!range) return false; const now = date.getHours() * 60 + date.getMinutes(); // 跨零点(如 23:00-07:00) return range.from <= range.to ? now >= range.from && now < range.to : now >= range.from || now < range.to; } /** MeoW 客户端限流:保守遵守 3 秒 1 条 / 每分钟 15 条 / 每小时 60 条 */ export function createMeoWLimiter(now = () => Date.now()) { let history = []; return { allow() { const t = now(); history = history.filter((x) => t - x < 3600_000); const last = history.length ? history.at(-1) : Number.NEGATIVE_INFINITY; // 空历史不能当成 0,否则首条必被拦 const inMinute = history.filter((x) => t - x < 60_000).length; if (t - last < 3000 || inMinute >= 15 || history.length >= 60) return false; return true; }, mark() { history.push(now()); }, state: () => history.length, }; } const meowLimiter = createMeoWLimiter(); /** * 按配置把一条通知发到所有启用的渠道。 * 静默时段内不发送,但返回 suppressed 标记(调用方仍需记录事件)。 */ export async function notifyAll(cfg, message, { logger = console, fetchImpl = fetch, now = () => Date.now() } = {}) { if (!cfg?.enabled) return { suppressed: false, skipped: 'notify-disabled', results: [] }; if (inQuietHours(cfg.quietHours)) { logger.log?.(`[notify] 静默时段(${cfg.quietHours}),本条不发送:${message.subject}`); return { suppressed: true, results: [] }; } const results = []; const channels = cfg.channels ?? {}; if (channels.email?.enabled) { try { results.push(await sendEmail(channels.email, message)); } catch (err) { results.push({ channel: 'email', ok: false, error: err.message }); } } if (channels.meow?.enabled) { if (!meowLimiter.allow()) { results.push({ channel: 'meow', ok: false, error: '本地限流:为避免触发 MeoW 频率限制,本条已跳过', rateLimited: true }); } else { try { meowLimiter.mark(); results.push(await sendMeoW(channels.meow, message, { fetchImpl })); } catch (err) { results.push({ channel: 'meow', ok: false, error: err.message }); } } } const failed = results.filter((r) => !r.ok); if (failed.length) logger.error?.(`[notify] 部分渠道发送失败:${failed.map((f) => `${f.channel}: ${f.error}`).join(' | ')}`); if (!failed.length && results.length) logger.log?.(`[notify] 已发送:${message.subject}`); return { suppressed: false, results }; } /** 自检:给每个启用的渠道发一条测试消息(不经过静默时段判定) */ export async function sendTestNotifications(cfg, { logger = console, fetchImpl = fetch } = {}) { const channels = cfg?.channels ?? {}; const subject = 'shiroko-monitor 通知自检'; const text = `这是一条通道自检消息,收到即表示该渠道配置可用。\n时间:${new Date().toLocaleString('zh-CN')}`; const out = []; if (channels.email?.enabled) { try { out.push(await sendEmail(channels.email, { subject, text })); } catch (err) { out.push({ channel: 'email', ok: false, error: err.message }); } } else { out.push({ channel: 'email', ok: false, error: '未启用(settings.notify.channels.email.enabled = false)' }); } if (channels.meow?.enabled) { try { out.push(await sendMeoW(channels.meow, { title: subject, text }, { fetchImpl })); } catch (err) { out.push({ channel: 'meow', ok: false, error: err.message }); } } else { out.push({ channel: 'meow', ok: false, error: '未启用(settings.notify.channels.meow.enabled = false)' }); } for (const r of out) logger.log?.(` ${r.ok ? '✓' : '✗'} ${r.channel}:${r.ok ? r.detail : r.error}`); return out; }