码桶

发现社区成员的开源项目

main
NodeNanny/core/notify.js
notify.js27.4 KB
'use strict';

const nodemailer = require('nodemailer');
const store = require('./store');

let cachedTransporter = null;

function getTransporter(smtpConfig) {
  if (cachedTransporter) return cachedTransporter;
  cachedTransporter = nodemailer.createTransport({
    host: smtpConfig.host,
    port: smtpConfig.port,
    secure: smtpConfig.secure,
    auth: {
      user: smtpConfig.user,
      pass: smtpConfig.pass
    }
  });
  return cachedTransporter;
}

// SMTP 是否已实际配置(跳过时 host/user/pass 是占位值或空,不应该尝试发信)
function smtpConfigured(smtpConfig) {
  if (!smtpConfig) return false;
  const { host, user, pass } = smtpConfig;
  // 任何一个关键字段为空、或者还是 example.com 占位域名,视为未配置
  if (!host || !user || !pass) return false;
  if (host === 'smtp.example.com') return false;
  if (user === '[email protected]') return false;
  // 值里含中文字符说明还是写-config.js 的说明性占位文字,还没换成真实值
  if (/[\u4e00-\u9fff]/.test(pass) || /[\u4e00-\u9fff]/.test(user)) return false;
  return true;
}

// 冷却时间内不重复发信,避免节点在阈值边缘反复抖动时邮箱被刷屏
function withinCooldown(cooldownMinutes) {
  const state = store.getState();
  if (!state.lastNotifyAt) return false;
  const elapsedMs = Date.now() - new Date(state.lastNotifyAt).getTime();
  return elapsedMs < cooldownMinutes * 60 * 1000;
}

async function send(smtpConfig, subject, bodyText) {
  const transporter = getTransporter(smtpConfig);
  await transporter.sendMail({
    from: smtpConfig.from,
    to: smtpConfig.to,
    subject,
    text: bodyText
  });
  // 真实bug修复(本轮复查发现):这里此前无条件更新lastNotifyAt,但lastNotifyAt
  // 是notifyDown专用的冷却计时字段(withinCooldown只给notifyDown用)。send()是四种
  // 邮件(down/recovered/diagnosis/usability建议)共用的底层发信函数,如果recovered/
  // diagnosis/usability建议邮件也顺手把lastNotifyAt刷新了,会导致:这几类邮件发出
  // 后的冷却时间内(默认30分钟)如果节点真的又挂了,notifyDown会误判"还在冷却期"而
  // 跳过本该发送的异常通知——用户可能因此完全收不到"节点down了"这封最重要的邮件。
  // 这跟notifyUsabilitySuggestion那边"故意不复用notifyDown冷却状态"的注释初衷矛盾
  // (意图是对的,但因为共用send(),实际代码没做到隔离)。改成lastNotifyAt只由
  // notifyDown自己在发信成功后更新,send()变成纯粹的发信函数,不再动任何冷却状态。
}

// 五语言邮件模板。ai.language 决定诊断邮件语言,node 通知邮件跟随同一个语言设置。
// 如果 config.ai.language 未设,回退英文(国际通用,安全的默认值)。
const TEMPLATES = {
  zh: {
    downSubject: (name) => `【NodeNanny】${name} 节点异常`,
    downBody: (name, host, port, time) => [
      `${name} 检测到异常,NodeNanny 已经在尝试自动重启。`,
      '',
      `检测时间:${time}`,
      `检测地址:${host}:${port}`,
      '',
      '如果之后收到"已恢复"的邮件,说明已经自动修好了,不用管。',
      '如果一直没收到恢复邮件,可能需要你登录服务器看一下。'
    ].join('\n'),
    recoveredSubject: (name) => `【NodeNanny】${name} 节点已恢复`,
    recoveredBody: (name, time, downMinutes) => [
      `${name} 已经恢复正常,NodeNanny 继续在后台盯着,你不用做任何事。`,
      '',
      `恢复时间:${time}`,
      downMinutes !== null ? `本次异常持续约 ${downMinutes} 分钟。` : ''
    ].join('\n'),
    diagSubject: (name) => `【NodeNanny】${name} 的 AI 故障诊断建议`,
    diagBody: (name, text, time) => [
      `${name} 持续异常了一段时间,这是 AI 给出的诊断建议(仅供参考,不会自动执行任何操作):`,
      '',
      text,
      '',
      `生成时间:${time}`,
      '',
      '你也可以随时登录面板查看这条建议。'
    ].join('\n'),
    // 本轮新增:二层可用性检测连续N次失败的"仅建议"通知,跟downSubject/downBody
    // 明确区分开——这里不会触发重启(重启对"隧道通了但请求没拿到预期结果"这种
    // 情况大概率没用),纯粹是提醒你去看一眼,附带知识库匹配到的建议(如果有的话)。
    usabilitySuggestSubject: (name) => `【NodeNanny】${name} 二层可用性检测连续异常(仅提醒,未触发重启)`,
    usabilitySuggestBody: (name, code, detail, kbText, time) => [
      `${name} 的二层可用性检测(真实代理请求,而不只是端口探测)连续多次没有通过。`,
      '这不会触发自动重启——隧道本身能建立,但请求没拿到预期结果,重启代理进程大概率无济于事,需要人工看一眼(比如是否被针对性干扰、路由是否有问题)。',
      '',
      `具体现象:${detail || code || '未知'}`,
      kbText ? '\n知识库里匹配到的相关建议:\n' + kbText : '',
      '',
      `检测时间:${time}`,
      '',
      '你也可以随时登录面板查看这条提醒。'
    ].join('\n'),
    // v36新增(founder拍板):二层检测连续更多次(比"仅建议"的阈值更高)判定为down时,
    // 真的调用node.restartCommand重启本地代理一次,并单独发一封信告知——跟上面
    // usabilitySuggest的"我们判断重启大概率没用"那句话正好相反的场景说明,这里
    // 要如实说明"已经自动重启过一次了",避免用户以为NodeNanny没反应。
    usabilityAutoRestartSubject: (name) => `【NodeNanny】${name} 二层可用性检测持续异常,已自动重启一次`,
    usabilityAutoRestartBody: (name, code, detail, kbText, restartOk, time) => [
      `${name} 的二层可用性检测(真实代理请求)连续多次没有通过,NodeNanny 已经自动重启了一次本地代理进程。`,
      restartOk
        ? '重启命令已成功执行。如果之后收到"已恢复"的邮件,说明重启起作用了;如果没有,可能是端口层面还检测不到变化,或者问题不是重启能解决的(比如被针对性干扰),建议登录看一下。'
        : '重启命令执行失败了(服务器上可能有权限问题或者restartCommand配置有误),需要你登录服务器手动看一下。',
      '',
      `具体现象:${detail || code || '未知'}`,
      kbText ? '\n知识库里匹配到的相关建议:\n' + kbText : '',
      '',
      `触发时间:${time}`,
      '',
      '这次发作期内不会重复自动重启,如果问题持续存在,请手动排查。'
    ].join('\n')
  },
  en: {
    downSubject: (name) => `[NodeNanny] ${name} node is down`,
    downBody: (name, host, port, time) => [
      `${name} is unreachable. NodeNanny has already attempted an automatic restart.`,
      '',
      `Detected at: ${time}`,
      `Target: ${host}:${port}`,
      '',
      'If you later receive a "recovered" email, it fixed itself automatically — nothing to do.',
      'If no recovery email arrives, you may need to log in to the server and investigate.'
    ].join('\n'),
    recoveredSubject: (name) => `[NodeNanny] ${name} node recovered`,
    recoveredBody: (name, time, downMinutes) => [
      `${name} is back online. NodeNanny is watching in the background — nothing you need to do.`,
      '',
      `Recovered at: ${time}`,
      downMinutes !== null ? `Total downtime: ~${downMinutes} minute(s).` : ''
    ].join('\n'),
    diagSubject: (name) => `[NodeNanny] AI diagnosis suggestion for ${name}`,
    diagBody: (name, text, time) => [
      `${name} has been down for a while. Here is the AI diagnosis (advisory only — nothing is executed automatically):`,
      '',
      text,
      '',
      `Generated at: ${time}`,
      '',
      'You can also view this suggestion any time in the panel.'
    ].join('\n'),
    usabilitySuggestSubject: (name) => `[NodeNanny] ${name}: repeated layer-2 usability failures (advisory only, no restart)`,
    usabilitySuggestBody: (name, code, detail, kbText, time) => [
      `${name}'s layer-2 usability check (a real proxy request, not just a port probe) has failed several times in a row.`,
      "This will not trigger an automatic restart — the tunnel itself connects fine, but the request didn't get the expected result, so restarting the proxy process is unlikely to help. Worth a manual look (possible targeted interference, routing issue, etc.).",
      '',
      `Detail: ${detail || code || 'unknown'}`,
      kbText ? '\nRelated knowledge-base suggestions:\n' + kbText : '',
      '',
      `Detected at: ${time}`,
      '',
      'You can also view this notice any time in the panel.'
    ].join('\n'),
    usabilityAutoRestartSubject: (name) => `[NodeNanny] ${name}: persistent layer-2 usability failures, auto-restarted once`,
    usabilityAutoRestartBody: (name, code, detail, kbText, restartOk, time) => [
      `${name}'s layer-2 usability check (a real proxy request) has failed repeatedly, so NodeNanny automatically restarted the local proxy process once.`,
      restartOk
        ? 'The restart command ran successfully. If a "recovered" email follows, the restart fixed it; if not, the issue may not be something a restart can fix (e.g. targeted interference) — worth a manual look.'
        : 'The restart command failed to run (possibly a permissions issue on the server, or a misconfigured restartCommand). Please log in and check manually.',
      '',
      `Detail: ${detail || code || 'unknown'}`,
      kbText ? '\nRelated knowledge-base suggestions:\n' + kbText : '',
      '',
      `Triggered at: ${time}`,
      '',
      "NodeNanny won't auto-restart again during this same incident. If the issue persists, please investigate manually."
    ].join('\n')
  },
  ja: {
    downSubject: (name) => `【NodeNanny】${name} ノードに障害が発生しました`,
    downBody: (name, host, port, time) => [
      `${name} に接続できません。NodeNanny は自動再起動を試みました。`,
      '',
      `検出時刻:${time}`,
      `対象アドレス:${host}:${port}`,
      '',
      'その後「復旧しました」というメールが届いた場合、自動修復されています。対応は不要です。',
      '復旧メールが届かない場合は、サーバーにログインして確認してください。'
    ].join('\n'),
    recoveredSubject: (name) => `【NodeNanny】${name} ノードが復旧しました`,
    recoveredBody: (name, time, downMinutes) => [
      `${name} が正常に復旧しました。NodeNanny がバックグラウンドで引き続き監視します。対応は不要です。`,
      '',
      `復旧時刻:${time}`,
      downMinutes !== null ? `今回の障害時間:約 ${downMinutes} 分。` : ''
    ].join('\n'),
    diagSubject: (name) => `【NodeNanny】${name} の AI 故障診断レポート`,
    diagBody: (name, text, time) => [
      `${name} がしばらく停止しています。以下は AI による診断提案です(参考のみ、自動実行は一切しません):`,
      '',
      text,
      '',
      `生成時刻:${time}`,
      '',
      'パネルからいつでもこの提案を確認できます。'
    ].join('\n'),
    usabilitySuggestSubject: (name) => `【NodeNanny】${name} 二層可用性チェックが連続して失敗(通知のみ、再起動なし)`,
    usabilitySuggestBody: (name, code, detail, kbText, time) => [
      `${name} の二層可用性チェック(実際のプロキシリクエスト、ポート探査だけではない)が連続して失敗しています。`,
      'これにより自動再起動は行われません——トンネル自体は確立できていますが、リクエストが期待した結果を得られていないため、プロセスの再起動はほとんど役に立ちません。手動での確認をおすすめします(意図的な妨害やルーティングの問題などの可能性)。',
      '',
      `詳細:${detail || code || '不明'}`,
      kbText ? '\n知識ベースの関連する提案:\n' + kbText : '',
      '',
      `検出時刻:${time}`,
      '',
      'パネルからいつでもこの通知を確認できます。'
    ].join('\n'),
    usabilityAutoRestartSubject: (name) => `【NodeNanny】${name} 二層可用性チェックが持続的に異常、自動的に一度再起動しました`,
    usabilityAutoRestartBody: (name, code, detail, kbText, restartOk, time) => [
      `${name} の二層可用性チェック(実際のプロキシリクエスト)が連続して失敗したため、NodeNanny はローカルのプロキシプロセスを自動的に一度再起動しました。`,
      restartOk
        ? '再起動コマンドは正常に実行されました。この後「復旧しました」というメールが届けば再起動で直ったということです。届かない場合は再起動では解決しない問題(意図的な妨害など)の可能性があるため、手動での確認をおすすめします。'
        : '再起動コマンドの実行に失敗しました(サーバー側の権限の問題、またはrestartCommandの設定ミスの可能性があります)。サーバーにログインして手動で確認してください。',
      '',
      `詳細:${detail || code || '不明'}`,
      kbText ? '\n知識ベースの関連する提案:\n' + kbText : '',
      '',
      `発生時刻:${time}`,
      '',
      '今回の発作期間中は再度の自動再起動は行いません。問題が続く場合は手動で調査してください。'
    ].join('\n')
  },
  de: {
    downSubject: (name) => `[NodeNanny] ${name} Node ausgefallen`,
    downBody: (name, host, port, time) => [
      `${name} ist nicht erreichbar. NodeNanny hat bereits einen automatischen Neustart versucht.`,
      '',
      `Erkannt um: ${time}`,
      `Ziel: ${host}:${port}`,
      '',
      'Falls du später eine "Wiederhergestellt"-E-Mail erhältst, hat es sich automatisch repariert — nichts zu tun.',
      'Wenn keine Wiederherstellungs-E-Mail ankommt, musst du dich möglicherweise am Server einloggen und nachsehen.'
    ].join('\n'),
    recoveredSubject: (name) => `[NodeNanny] ${name} Node wiederhergestellt`,
    recoveredBody: (name, time, downMinutes) => [
      `${name} ist wieder online. NodeNanny überwacht weiter im Hintergrund — du musst nichts tun.`,
      '',
      `Wiederhergestellt um: ${time}`,
      downMinutes !== null ? `Gesamtausfallzeit: ca. ${downMinutes} Minute(n).` : ''
    ].join('\n'),
    diagSubject: (name) => `[NodeNanny] KI-Diagnosevorschlag für ${name}`,
    diagBody: (name, text, time) => [
      `${name} ist schon eine Weile ausgefallen. Hier ist die KI-Diagnose (nur Hinweis — es wird nichts automatisch ausgeführt):`,
      '',
      text,
      '',
      `Erstellt um: ${time}`,
      '',
      'Du kannst diesen Vorschlag jederzeit im Panel ansehen.'
    ].join('\n'),
    usabilitySuggestSubject: (name) => `[NodeNanny] ${name}: wiederholte Layer-2-Nutzbarkeitsfehler (nur Hinweis, kein Neustart)`,
    usabilitySuggestBody: (name, code, detail, kbText, time) => [
      `Die Layer-2-Nutzbarkeitsprüfung von ${name} (eine echte Proxy-Anfrage, nicht nur ein Port-Test) ist mehrmals hintereinander fehlgeschlagen.`,
      'Das löst keinen automatischen Neustart aus — der Tunnel selbst steht, aber die Anfrage lieferte nicht das erwartete Ergebnis, ein Neustart des Prozesses würde hier wahrscheinlich nichts bringen. Ein manueller Blick lohnt sich (mögliche gezielte Störung, Routing-Problem usw.).',
      '',
      `Details: ${detail || code || 'unbekannt'}`,
      kbText ? '\nZugehörige Vorschläge aus der Wissensdatenbank:\n' + kbText : '',
      '',
      `Erkannt um: ${time}`,
      '',
      'Du kannst diesen Hinweis jederzeit im Panel ansehen.'
    ].join('\n'),
    usabilityAutoRestartSubject: (name) => `[NodeNanny] ${name}: anhaltende Layer-2-Nutzbarkeitsfehler, einmal automatisch neu gestartet`,
    usabilityAutoRestartBody: (name, code, detail, kbText, restartOk, time) => [
      `Die Layer-2-Nutzbarkeitsprüfung von ${name} (eine echte Proxy-Anfrage) ist wiederholt fehlgeschlagen, daher hat NodeNanny den lokalen Proxy-Prozess automatisch einmal neu gestartet.`,
      restartOk
        ? 'Der Neustart-Befehl wurde erfolgreich ausgeführt. Wenn danach eine "Wiederhergestellt"-E-Mail kommt, hat der Neustart geholfen; wenn nicht, liegt das Problem möglicherweise woanders (z. B. gezielte Störung) — ein manueller Blick lohnt sich.'
        : 'Der Neustart-Befehl konnte nicht ausgeführt werden (möglicherweise ein Berechtigungsproblem auf dem Server oder ein falsch konfigurierter restartCommand). Bitte logg dich ein und prüfe manuell.',
      '',
      `Details: ${detail || code || 'unbekannt'}`,
      kbText ? '\nZugehörige Vorschläge aus der Wissensdatenbank:\n' + kbText : '',
      '',
      `Ausgelöst um: ${time}`,
      '',
      'NodeNanny wird während dieses Vorfalls nicht erneut automatisch neu starten. Bitte bei anhaltendem Problem manuell prüfen.'
    ].join('\n')
  },
  ru: {
    downSubject: (name) => `[NodeNanny] Узел ${name} недоступен`,
    downBody: (name, host, port, time) => [
      `${name} недоступен. NodeNanny уже попытался выполнить автоматический перезапуск.`,
      '',
      `Обнаружено в: ${time}`,
      `Адрес: ${host}:${port}`,
      '',
      'Если позже придёт письмо "восстановлен" — всё починилось автоматически, делать ничего не нужно.',
      'Если письмо о восстановлении не пришло — возможно, нужно войти на сервер и разобраться.'
    ].join('\n'),
    recoveredSubject: (name) => `[NodeNanny] Узел ${name} восстановлен`,
    recoveredBody: (name, time, downMinutes) => [
      `${name} снова работает. NodeNanny продолжает наблюдение в фоне — ничего делать не нужно.`,
      '',
      `Восстановлен в: ${time}`,
      downMinutes !== null ? `Общее время простоя: ~${downMinutes} мин.` : ''
    ].join('\n'),
    diagSubject: (name) => `[NodeNanny] Рекомендация ИИ-диагностики для ${name}`,
    diagBody: (name, text, time) => [
      `${name} недоступен уже некоторое время. Вот рекомендация ИИ-диагностики (только совет — ничего не выполняется автоматически):`,
      '',
      text,
      '',
      `Создано в: ${time}`,
      '',
      'Вы также можете посмотреть эту рекомендацию в панели в любое время.'
    ].join('\n'),
    usabilitySuggestSubject: (name) => `[NodeNanny] ${name}: повторные сбои проверки реальной доступности (только уведомление, без перезапуска)`,
    usabilitySuggestBody: (name, code, detail, kbText, time) => [
      `Проверка реальной доступности (layer-2) узла ${name} — настоящий запрос через прокси, а не просто проверка порта — несколько раз подряд не пройдена.`,
      'Это не вызовет автоматический перезапуск: туннель устанавливается нормально, но запрос не дал ожидаемого результата, поэтому перезапуск процесса вряд ли поможет. Стоит проверить вручную (возможна целевая блокировка, проблема с маршрутизацией и т.п.).',
      '',
      `Подробности: ${detail || code || 'неизвестно'}`,
      kbText ? '\nСвязанные рекомендации из базы знаний:\n' + kbText : '',
      '',
      `Обнаружено в: ${time}`,
      '',
      'Вы также можете посмотреть это уведомление в панели в любое время.'
    ].join('\n'),
    usabilityAutoRestartSubject: (name) => `[NodeNanny] ${name}: постоянные сбои проверки реальной доступности, выполнен автоматический перезапуск`,
    usabilityAutoRestartBody: (name, code, detail, kbText, restartOk, time) => [
      `Проверка реальной доступности (layer-2) узла ${name} (настоящий запрос через прокси) неоднократно не проходила, поэтому NodeNanny автоматически перезапустил локальный процесс прокси один раз.`,
      restartOk
        ? 'Команда перезапуска выполнена успешно. Если после этого придёт письмо "восстановлен" — перезапуск помог; если нет, проблема может быть не в том, что решается перезапуском (например, целевая блокировка) — стоит проверить вручную.'
        : 'Команда перезапуска не выполнилась (возможна проблема с правами на сервере или неверно настроен restartCommand). Пожалуйста, войдите на сервер и проверьте вручную.',
      '',
      `Подробности: ${detail || code || 'неизвестно'}`,
      kbText ? '\nСвязанные рекомендации из базы знаний:\n' + kbText : '',
      '',
      `Обнаружено в: ${time}`,
      '',
      'В течение этого инцидента повторный автоматический перезапуск выполняться не будет. Если проблема сохраняется, проверьте вручную.'
    ].join('\n')
  }
};

function getTpl(config) {
  const lang = (config.ai && config.ai.language) || 'en';
  return TEMPLATES[lang] || TEMPLATES.en;
}

function nowStr(config) {
  const lang = (config.ai && config.ai.language) || 'en';
  // zh/ja 用本地格式,其余 ISO-like
  const locale = { zh: 'zh-CN', ja: 'ja-JP', de: 'de-DE', ru: 'ru-RU', en: 'en-US' }[lang] || 'en-US';
  return new Date().toLocaleString(locale);
}

async function notifyDown(config, host, port) {
  if (!smtpConfigured(config.smtp)) {
    console.log('[notify] SMTP 未配置,跳过异常通知邮件');
    return;
  }
  if (withinCooldown(config.monitor.notifyCooldownMinutes)) {
    console.log('[notify] 冷却中,本次不重复发送异常邮件');
    return;
  }
  const tpl = getTpl(config);
  const nodeName = config.node.name;
  await send(
    config.smtp,
    tpl.downSubject(nodeName),
    tpl.downBody(nodeName, host, port, nowStr(config))
  );
  // lastNotifyAt现在只在这里更新(down邮件专属冷却),见上面send()里的说明。
  store.updateState({ lastNotifyAt: new Date().toISOString() });
}

async function notifyRecovered(config, downSinceIso) {
  if (!smtpConfigured(config.smtp)) {
    console.log('[notify] SMTP 未配置,跳过恢复通知邮件');
    return;
  }
  const tpl = getTpl(config);
  const nodeName = config.node.name;
  const downSince = downSinceIso ? new Date(downSinceIso) : null;
  const downMinutes = downSince
    ? Math.round((Date.now() - downSince.getTime()) / 60000)
    : null;
  await send(
    config.smtp,
    tpl.recoveredSubject(nodeName),
    tpl.recoveredBody(nodeName, nowStr(config), downMinutes)
  );
}

// AI 诊断结果通知:跟异常通知是分开的两封邮件,因为诊断要等「连续失败达到阈值」才会出结果,
// 时间点跟第一封异常邮件对不上,硬塞进去反而让用户等邮件等半天。这封信不占用 notifyDown 的冷却时间,
// 只在诊断真的出结果时发一次(每次异常发作期最多一次,由 checker.js 控制触发次数)。
async function notifyDiagnosis(config, diagnosisText) {
  if (!smtpConfigured(config.smtp)) {
    console.log('[notify] SMTP 未配置,跳过 AI 诊断通知邮件');
    return;
  }
  const tpl = getTpl(config);
  const nodeName = config.node.name;
  await send(
    config.smtp,
    tpl.diagSubject(nodeName),
    tpl.diagBody(nodeName, diagnosisText, nowStr(config))
  );
}

// 二层可用性检测连续N次失败时的"仅建议"通知(v34本轮新增)。跟notifyDown是两条
// 完全独立的通知线——不复用notifyDown的冷却状态(lastNotifyAt),避免TCP层的down
// 邮件把这封本该独立发出的邮件的冷却时间占掉;用同一个notifyCooldownMinutes配置项,
// 但冷却计时字段单独存(lastUsabilitySuggestAt)。
function withinUsabilitySuggestCooldown(cooldownMinutes) {
  const state = store.getState();
  if (!state.lastUsabilitySuggestAt) return false;
  const elapsedMs = Date.now() - new Date(state.lastUsabilitySuggestAt).getTime();
  return elapsedMs < cooldownMinutes * 60 * 1000;
}

async function notifyUsabilitySuggestion(config, { code, detail, kbText }) {
  if (!smtpConfigured(config.smtp)) {
    console.log('[notify] SMTP 未配置,跳过二层可用性建议通知邮件');
    return;
  }
  const cooldownMinutes = (config.monitor && config.monitor.notifyCooldownMinutes) || 0;
  if (withinUsabilitySuggestCooldown(cooldownMinutes)) {
    console.log('[notify] 冷却中,本次不重复发送二层可用性建议邮件');
    return;
  }
  const tpl = getTpl(config);
  const nodeName = config.node.name;
  await send(
    config.smtp,
    tpl.usabilitySuggestSubject(nodeName),
    tpl.usabilitySuggestBody(nodeName, code, detail, kbText, nowStr(config))
  );
  store.updateState({ lastUsabilitySuggestAt: new Date().toISOString() });
}

// 二层检测触发自动重启后的通知(v36新增)。故意不复用notifyDown/
// notifyUsabilitySuggestion的冷却字段——三条通知线各自独立记录"上次发送时间",
// 理由跟notifyUsabilitySuggestion顶部注释一致:共用冷却字段会导致其中一条
// 抢占另一条的发送窗口,用户可能因此错过某一类邮件。
function withinUsabilityAutoRestartCooldown(cooldownMinutes) {
  const state = store.getState();
  if (!state.lastUsabilityAutoRestartAt) return false;
  const elapsedMs = Date.now() - new Date(state.lastUsabilityAutoRestartAt).getTime();
  return elapsedMs < cooldownMinutes * 60 * 1000;
}

async function notifyUsabilityAutoRestart(config, { code, detail, kbText, restartOk }) {
  if (!smtpConfigured(config.smtp)) {
    console.log('[notify] SMTP 未配置,跳过二层可用性自动重启通知邮件');
    return;
  }
  const cooldownMinutes = (config.monitor && config.monitor.notifyCooldownMinutes) || 0;
  if (withinUsabilityAutoRestartCooldown(cooldownMinutes)) {
    console.log('[notify] 冷却中,本次不重复发送二层可用性自动重启邮件');
    return;
  }
  const tpl = getTpl(config);
  const nodeName = config.node.name;
  await send(
    config.smtp,
    tpl.usabilityAutoRestartSubject(nodeName),
    tpl.usabilityAutoRestartBody(nodeName, code, detail, kbText, restartOk, nowStr(config))
  );
  store.updateState({ lastUsabilityAutoRestartAt: new Date().toISOString() });
}

module.exports = { notifyDown, notifyRecovered, notifyDiagnosis, notifyUsabilitySuggestion, notifyUsabilityAutoRestart };