[object Object]

← back to Claude Mail

claude-mail: self-heal — page Steve via CNCP+macOS+George when poller fails N times (mailbox-independent), auto-recover notice; restore probe.js

d59fd42321c54ea8101e1a09e501d1ef833ec5bd · 2026-06-25 15:40:43 -0700 · Steve

Files touched

Diff

commit d59fd42321c54ea8101e1a09e501d1ef833ec5bd
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jun 25 15:40:43 2026 -0700

    claude-mail: self-heal — page Steve via CNCP+macOS+George when poller fails N times (mailbox-independent), auto-recover notice; restore probe.js
---
 .gitignore |   3 ++
 health.js  | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 poller.js  |  12 +++++++-
 probe.js   |  24 +++++++++++++++
 4 files changed, 138 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index fb7fbc5..14b91a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,6 @@ dist/
 build/
 .next/
 state.json
+
+# self-heal state
+.health.json
diff --git a/health.js b/health.js
new file mode 100644
index 0000000..89e00a6
--- /dev/null
+++ b/health.js
@@ -0,0 +1,100 @@
+'use strict';
+// claude-mail self-heal / alert layer.
+// The poller calls recordFailure(msg) when a poll throws and recordSuccess()
+// when one completes. We track CONSECUTIVE failures in a small state file and,
+// once they cross a threshold, fire an alert over channels that DO NOT depend
+// on the (possibly-broken) Purelymail mailbox: CNCP (local HTTP), a native
+// macOS notification, and George/Gmail (separate auth) as a bonus. On recovery
+// we fire a one-shot "back online" notice and reset the counter.
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { execFile } = require('node:child_process');
+
+const STATE = path.join(__dirname, '.health.json');
+const THRESHOLD = Number(process.env.HEALTH_FAIL_THRESHOLD || 3);      // consecutive fails before alerting (~30s @ 10s poll)
+const REALERT_MS = Number(process.env.HEALTH_REALERT_MS || 30 * 60 * 1000); // re-page at most every 30 min during an outage
+const CNCP = process.env.CNCP_URL || 'http://127.0.0.1:3333';
+const GEORGE = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
+const ALERT_TO = process.env.HEALTH_ALERT_EMAIL || 'steveabramsdesigns@gmail.com';
+
+function read() {
+  try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); }
+  catch { return { consecutiveFailures: 0, firstFailureAt: null, lastAlertAt: null, lastError: null }; }
+}
+function write(s) { try { fs.writeFileSync(STATE, JSON.stringify(s, null, 2)); } catch {} }
+const log = (...a) => console.log(new Date().toISOString(), '[health]', ...a);
+
+// ---- alert channels (all best-effort, none may throw) ----------------------
+function macNotify(title, msg) {
+  return new Promise((res) => {
+    const safe = (s) => String(s).replace(/["\\]/g, ' ').slice(0, 240);
+    execFile('osascript', ['-e', `display notification "${safe(msg)}" with title "${safe(title)}" sound name "Basso"`],
+      () => res());
+  });
+}
+async function cncpAlert(title, note) {
+  // Land it in the parking lot so it shows up in the command center Steve watches.
+  for (const ep of ['/api/parking-lot', '/api/parkinglot', '/api/wins']) {
+    try {
+      const body = ep.includes('wins')
+        ? { project: 'claude-mail', title, summary: note }
+        : { title, note, source: 'claude-mail-health' };
+      const r = await fetch(CNCP + ep, {
+        method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
+      });
+      if (r.ok) { log(`CNCP alert posted via ${ep}`); return true; }
+    } catch {}
+  }
+  return false;
+}
+async function georgeAlert(subject, text) {
+  // George has its own (Gmail) auth, independent of the Purelymail mailbox.
+  try {
+    const r = await fetch(GEORGE + '/api/send', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Authorization': 'Basic ' + Buffer.from('admin:').toString('base64') },
+      body: JSON.stringify({ account: 'steve-office', to: ALERT_TO, subject, body: `<pre>${text}</pre>` }),
+    });
+    if (r.ok) { log('George alert email sent'); return true; }
+  } catch {}
+  return false;
+}
+
+async function fireAlert(kind, detail) {
+  const title = kind === 'down' ? '🔴 claude-mail poller is FAILING' : '🟢 claude-mail poller recovered';
+  const note = kind === 'down'
+    ? `The claude@agentabrams.com poller has failed ${detail.count}x in a row since ${detail.since}. Last error: ${detail.error}. The email channel is NOT reading mail. Check: cd ~/Projects/claude-mail && node --env-file=.env probe.js`
+    : `claude-mail poller is back: it failed ${detail.count}x but a poll just succeeded. The channel is reading mail again.`;
+  log(`FIRING ${kind} alert: ${note}`);
+  await Promise.all([ macNotify(title, note), cncpAlert(title, note), georgeAlert(title, note) ]);
+}
+
+// ---- public API ------------------------------------------------------------
+async function recordFailure(errMsg) {
+  const s = read();
+  s.consecutiveFailures += 1;
+  s.lastError = String(errMsg || 'unknown');
+  if (!s.firstFailureAt) s.firstFailureAt = new Date().toISOString();
+  const now = Date.now();
+  const due = !s.lastAlertAt || (now - new Date(s.lastAlertAt).getTime()) > REALERT_MS;
+  if (s.consecutiveFailures >= THRESHOLD && due) {
+    s.lastAlertAt = new Date().toISOString();
+    write(s); // persist before the (async) alert so a crash mid-alert can't double-fire
+    await fireAlert('down', { count: s.consecutiveFailures, since: s.firstFailureAt, error: s.lastError });
+  } else {
+    write(s);
+  }
+}
+async function recordSuccess() {
+  const s = read();
+  if (s.consecutiveFailures >= THRESHOLD) {
+    // We had paged about an outage; tell Steve it cleared.
+    await fireAlert('recovered', { count: s.consecutiveFailures });
+  }
+  if (s.consecutiveFailures !== 0 || s.lastAlertAt) {
+    write({ consecutiveFailures: 0, firstFailureAt: null, lastAlertAt: null, lastError: null });
+  }
+}
+
+module.exports = { recordFailure, recordSuccess };
diff --git a/poller.js b/poller.js
index fb124f4..91488b1 100644
--- a/poller.js
+++ b/poller.js
@@ -6,6 +6,7 @@
 const { ImapFlow } = require('imapflow');
 const { simpleParser } = require('mailparser');
 const { cfg, gate, stripQuote, runClaude, sendMail } = require('./lib');
+const { recordSuccess, recordFailure } = require('./health');
 
 const log = (...a) => console.log(new Date().toISOString(), ...a);
 
@@ -79,4 +80,13 @@ async function main() {
   }
 }
 
-main().then(() => process.exit(0)).catch((e) => { log('FATAL', e.message); process.exit(1); });
+main()
+  .then(async () => { await recordSuccess(); process.exit(0); })
+  .catch(async (e) => {
+    log('FATAL', e.message);
+    // Self-heal/alert: a poll that throws (e.g. auth rejected, server
+    // unreachable) increments a consecutive-failure counter that pages Steve
+    // over mailbox-independent channels once it crosses the threshold.
+    try { await recordFailure(e.message); } catch (he) { log('health.recordFailure errored:', he.message); }
+    process.exit(1);
+  });
diff --git a/probe.js b/probe.js
new file mode 100644
index 0000000..2f22973
--- /dev/null
+++ b/probe.js
@@ -0,0 +1,24 @@
+'use strict';
+// READ-ONLY diagnostic: does the mailbox credential authenticate right now?
+// Surfaces the REAL server response that the poller's generic "Command failed"
+// hides. Run: node --env-file=.env probe.js
+const { ImapFlow } = require('imapflow');
+const nodemailer = require('nodemailer');
+
+(async () => {
+  const i = new ImapFlow({
+    host: process.env.IMAP_HOST || 'imap.purelymail.com',
+    port: Number(process.env.IMAP_PORT || 993), secure: true,
+    auth: { user: process.env.MAIL_USER, pass: process.env.MAIL_PASS }, logger: false,
+  });
+  try { await i.connect(); console.log('IMAP login: OK'); await i.logout(); }
+  catch (e) { console.log('IMAP login: FAIL —', e.response || e.responseText || e.message); }
+
+  const s = nodemailer.createTransport({
+    host: process.env.SMTP_HOST || 'smtp.purelymail.com',
+    port: Number(process.env.SMTP_PORT || 465), secure: true,
+    auth: { user: process.env.MAIL_USER, pass: process.env.MAIL_PASS },
+  });
+  try { await s.verify(); console.log('SMTP login: OK'); }
+  catch (e) { console.log('SMTP login: FAIL —', e.message); }
+})();

← ec9022a claude-mail: add read-only inbox/sent audit diagnostics  ·  back to Claude Mail  ·  claude-mail: pass image attachments to the headless claude r 5ff9546 →