← back to Claude Mail
claude-mail: email channel for Claude at claude@agentabrams.com (sender-gated, secret-phrase-locked, local access)
2c4c86b36a27b1a1c25dbc9277b317c34bf1d2f1 · 2026-06-25 08:31:48 -0700 · Steve
Files touched
A .gitignoreA README.mdA imap-check.jsA lib.jsA package-lock.jsonA package.jsonA poller.jsA run.shA send.js
Diff
commit 2c4c86b36a27b1a1c25dbc9277b317c34bf1d2f1
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jun 25 08:31:48 2026 -0700
claude-mail: email channel for Claude at claude@agentabrams.com (sender-gated, secret-phrase-locked, local access)
---
.gitignore | 10 +
README.md | 64 ++++++
imap-check.js | 26 +++
lib.js | 140 +++++++++++++
package-lock.json | 575 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
package.json | 17 ++
poller.js | 75 +++++++
run.sh | 8 +
send.js | 16 ++
9 files changed, 931 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fb7fbc5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env
+.env.*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+state.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7edbe2b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,64 @@
+# claude-mail
+
+A back-and-forth **email channel for Claude** at `claude@agentabrams.com`.
+Steve (or an allowlisted person) emails the address; a local poller reads it,
+runs `claude` headless on this Mac with full local access, and replies in-thread.
+Reply to the reply → it keeps the conversation going.
+
+## How it works
+
+```
+sender ──▶ claude@agentabrams.com (Purelymail mailbox)
+ │ IMAP (imap.purelymail.com)
+ ▼
+ poller.js ──▶ [ SENDER GATE ] ──▶ runClaude(-p) ──▶ SMTP reply in-thread
+ 3 locks, all required (Max-plan auth, local tools)
+```
+
+### The sender gate — 3 independent locks (all must pass)
+1. **Allowlist** — `From` must be one of `ALLOWED_SENDERS`.
+2. **Secret phrase** — the subject must contain `[ok:<SECRET_PHRASE>]` (or the bare phrase).
+3. **Email auth** — inbound mail must pass DKIM/DMARC/SPF (anti-spoof), unless `REQUIRE_AUTH_PASS=false`.
+
+Anything failing the gate is **silently ignored** (marked read, no reply, no `claude` run).
+Strangers get total silence and never reach the machine.
+
+### Autonomy rails (injected into every run via `--append-system-prompt`)
+- Safe / reversible / local / read-only work runs directly and is reported.
+- **Gated** actions (prod writes, sends to third parties, DNS, spend, deletes,
+ customer-facing, remote pushes) are **NOT executed** — Claude describes the plan
+ and asks for a "go". Default `CLAUDE_PERMISSION_MODE=acceptEdits` also keeps the
+ headless run from running unattended `Bash` (Read/Grep/Glob/Edit/Write still work).
+
+## Files
+- `poller.js` — one poll pass (run on an interval by launchd). Idempotent.
+- `lib.js` — config, sender gate, quote-stripping, claude runner, mailer.
+- `send.js` — one-off sender: `node --env-file=.env send.js <to> "<subj>" < body.txt`.
+- `imap-check.js` — read-only connectivity check (no claude, no send).
+- `run.sh` — wrapper used by the LaunchAgent.
+- `.env` — secrets + config (gitignored).
+- `~/Library/LaunchAgents/com.steve.claude-mail.plist` — the scheduler (every 120s).
+
+## Activate the always-on poller
+```sh
+launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.claude-mail.plist
+launchctl kickstart -k gui/$(id -u)/com.steve.claude-mail # run once now
+```
+Stop / disable:
+```sh
+launchctl bootout gui/$(id -u)/com.steve.claude-mail
+```
+Logs: `tail -f ~/Projects/claude-mail/poller.log`
+
+## Manual run (no scheduler)
+```sh
+cd ~/Projects/claude-mail && ./run.sh # one pass
+node --env-file=.env imap-check.js # read-only inbox peek
+```
+
+## Tuning
+- **Allowlist / phrase** — edit `.env` (`ALLOWED_SENDERS`, `SECRET_PHRASE`).
+- **Let it run Bash unattended** — set `CLAUDE_PERMISSION_MODE=bypassPermissions`
+ (more capable, more risk; the gate + rails are then the only guardrails).
+- **Deliverability** — `agentabrams.com` may need DKIM enabled (Purelymail +
+ Cloudflare DNS) so replies don't land in spam. Gated DNS change — do separately.
diff --git a/imap-check.js b/imap-check.js
new file mode 100644
index 0000000..4e4a468
--- /dev/null
+++ b/imap-check.js
@@ -0,0 +1,26 @@
+'use strict';
+// READ-ONLY IMAP connectivity check. Connects, counts UNSEEN, prints recent
+// senders/subjects. Never runs claude, never sends, never modifies flags.
+const { ImapFlow } = require('imapflow');
+const { cfg } = require('./lib');
+
+(async () => {
+ const client = new ImapFlow({
+ host: cfg.imapHost, port: cfg.imapPort, secure: true,
+ auth: { user: cfg.user, pass: cfg.pass }, logger: false,
+ });
+ await client.connect();
+ const lock = await client.getMailboxLock('INBOX');
+ try {
+ const status = await client.status('INBOX', { messages: true, unseen: true });
+ console.log(`IMAP OK as ${cfg.user} — total=${status.messages} unseen=${status.unseen}`);
+ const recent = [];
+ for await (const msg of client.fetch({ seen: false }, { envelope: true })) {
+ recent.push(` unseen: from=${msg.envelope?.from?.[0]?.address} subj="${msg.envelope?.subject || ''}"`);
+ }
+ if (recent.length) console.log(recent.join('\n')); else console.log(' (no unseen messages)');
+ } finally {
+ lock.release();
+ await client.logout().catch(() => {});
+ }
+})().catch((e) => { console.error('IMAP CHECK FAILED:', e.message); process.exit(1); });
diff --git a/lib.js b/lib.js
new file mode 100644
index 0000000..6aaa324
--- /dev/null
+++ b/lib.js
@@ -0,0 +1,140 @@
+'use strict';
+// claude-mail shared lib: config, sender gate, claude runner, mailer.
+// Env is loaded by `node --env-file=.env` (Node 22+), so we just read process.env.
+
+const { spawn } = require('node:child_process');
+const nodemailer = require('nodemailer');
+
+const cfg = {
+ user: process.env.MAIL_USER,
+ pass: process.env.MAIL_PASS,
+ imapHost: process.env.IMAP_HOST || 'imap.purelymail.com',
+ imapPort: Number(process.env.IMAP_PORT || 993),
+ smtpHost: process.env.SMTP_HOST || 'smtp.purelymail.com',
+ smtpPort: Number(process.env.SMTP_PORT || 465),
+ allowed: (process.env.ALLOWED_SENDERS || '')
+ .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
+ phrase: (process.env.SECRET_PHRASE || '').trim(),
+ requireAuthPass: String(process.env.REQUIRE_AUTH_PASS || 'true') === 'true',
+ claudeBin: process.env.CLAUDE_BIN || 'claude',
+ workdir: process.env.CLAUDE_WORKDIR || process.env.HOME,
+ permissionMode: process.env.CLAUDE_PERMISSION_MODE || 'acceptEdits',
+ timeoutMs: Number(process.env.CLAUDE_TIMEOUT_MS || 900000),
+ fromName: process.env.FROM_NAME || 'Claude',
+};
+
+// ---- Sender gate -----------------------------------------------------------
+// Returns { ok: bool, reason: string }. Three independent locks:
+// 1) From address in allowlist
+// 2) Subject contains the secret phrase (as [ok:PHRASE] or bare PHRASE)
+// 3) Inbound mail passed DKIM or DMARC (anti-spoof) — unless REQUIRE_AUTH_PASS=false
+function gate(parsed) {
+ const from = (parsed.from?.value?.[0]?.address || '').toLowerCase();
+ if (!from) return { ok: false, reason: 'no From address' };
+ if (!cfg.allowed.includes(from)) return { ok: false, reason: `sender not allowlisted: ${from}` };
+
+ const subject = (parsed.subject || '');
+ const sLower = subject.toLowerCase();
+ const pLower = cfg.phrase.toLowerCase();
+ const hasPhrase = pLower && (sLower.includes(`[ok:${pLower}]`) || sLower.includes(pLower));
+ if (!hasPhrase) return { ok: false, reason: 'missing secret phrase in subject' };
+
+ if (cfg.requireAuthPass) {
+ const ar = headerStr(parsed, 'authentication-results').toLowerCase();
+ const passed = ar.includes('dkim=pass') || ar.includes('dmarc=pass') || ar.includes('spf=pass');
+ if (!passed) return { ok: false, reason: `failed email auth (dkim/dmarc): ${ar.slice(0, 120) || 'no Authentication-Results'}` };
+ }
+ return { ok: true, reason: 'ok', from };
+}
+
+function headerStr(parsed, name) {
+ try {
+ const v = parsed.headers?.get(name);
+ if (!v) return '';
+ return Array.isArray(v) ? v.join(' ; ') : String(v);
+ } catch { return ''; }
+}
+
+// ---- Quote stripping -------------------------------------------------------
+// Keep only Steve's new text, drop the quoted history on replies.
+function stripQuote(text) {
+ if (!text) return '';
+ const lines = text.split(/\r?\n/);
+ const out = [];
+ const markers = [
+ /^>/, // quoted line
+ /^On .+ wrote:$/i, // gmail/apple reply header
+ /^-----Original Message-----/i,
+ /^_{5,}\s*$/, // outlook divider
+ /^From:\s.+@/i, // forwarded/quoted header block
+ /^Sent from my /i,
+ ];
+ for (const ln of lines) {
+ if (markers.some((m) => m.test(ln.trim()))) break;
+ out.push(ln);
+ }
+ return out.join('\n').trim() || text.trim();
+}
+
+// ---- Claude headless runner ------------------------------------------------
+const RAILS = [
+ 'You are answering an email from Steve (or an allowlisted person) sent to your dedicated mailbox claude@agentabrams.com.',
+ 'You have full local access to this Mac and its tools, and Steve\'s global CLAUDE.md standing rules apply.',
+ 'AUTONOMY: Do safe, reversible, local, read-only work directly and report the result.',
+ 'GATED: For anything gated — production writes, sending messages to third parties, DNS/domain changes, spending money, deleting/overwriting, customer-facing changes, pushing to remotes — DO NOT execute it. Instead, describe exactly what you would do and ask the sender to reply "go" (with the secret phrase) or to run it in a live session.',
+ 'FORMAT: Reply in concise plain text suitable for an email body. No markdown headers or code fences unless essential. Lead with the answer.',
+].join(' ');
+
+function runClaude(prompt) {
+ return new Promise((resolve) => {
+ const env = { ...process.env };
+ // Force Max-plan auth on the home network (mirror Steve's `claude` shell fn).
+ delete env.ANTHROPIC_API_KEY;
+ delete env.ANTHROPIC_AUTH_TOKEN;
+
+ const args = [
+ '-p', prompt,
+ '--append-system-prompt', RAILS,
+ '--permission-mode', cfg.permissionMode,
+ '--output-format', 'text',
+ ];
+ const child = spawn(cfg.claudeBin, args, { cwd: cfg.workdir, env });
+ let out = '', err = '';
+ const timer = setTimeout(() => { child.kill('SIGKILL'); }, cfg.timeoutMs);
+ child.stdout.on('data', (d) => { out += d; });
+ child.stderr.on('data', (d) => { err += d; });
+ child.on('close', (code) => {
+ clearTimeout(timer);
+ if (code === 0 && out.trim()) return resolve({ ok: true, text: out.trim() });
+ resolve({ ok: false, text: (out.trim() || err.trim() || `claude exited ${code}`) });
+ });
+ child.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, text: `spawn error: ${e.message}` }); });
+ });
+}
+
+// ---- Mailer ----------------------------------------------------------------
+function transport() {
+ return nodemailer.createTransport({
+ host: cfg.smtpHost,
+ port: cfg.smtpPort,
+ secure: cfg.smtpPort === 465,
+ auth: { user: cfg.user, pass: cfg.pass },
+ });
+}
+
+async function sendMail({ to, subject, text, inReplyTo, references }) {
+ const t = transport();
+ const headers = {};
+ const info = await t.sendMail({
+ from: `"${cfg.fromName}" <${cfg.user}>`,
+ to,
+ subject,
+ text,
+ inReplyTo,
+ references,
+ headers,
+ });
+ return info;
+}
+
+module.exports = { cfg, gate, stripQuote, runClaude, sendMail };
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..a4908eb
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,575 @@
+{
+ "name": "claude-mail",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "claude-mail",
+ "version": "1.0.0",
+ "dependencies": {
+ "imapflow": "^1.0.171",
+ "mailparser": "^3.7.1",
+ "nodemailer": "^6.9.14"
+ }
+ },
+ "node_modules/@pinojs/redact": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
+ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
+ "license": "MIT"
+ },
+ "node_modules/@selderee/plugin-htmlparser2": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
+ "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "~2.3.0",
+ "domhandler": "~5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ },
+ "peerDependencies": {
+ "selderee": "~0.12.0"
+ }
+ },
+ "node_modules/@zone-eu/mailsplit": {
+ "version": "5.4.12",
+ "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.12.tgz",
+ "integrity": "sha512-w7Gy+NvjZ0MiXm8F6zfjImAqcTONKDImgWVBjDKQVFUXWuz3VFM5levNArkL2M877ajql5+bkS2pDV56injlmg==",
+ "license": "(MIT OR EUPL-1.1+)",
+ "dependencies": {
+ "libbase64": "1.3.0",
+ "libmime": "5.3.8",
+ "libqp": "2.1.1"
+ }
+ },
+ "node_modules/atomic-sleep": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
+ "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/deepmerge-ts": {
+ "version": "7.1.5",
+ "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
+ "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/encoding-japanese": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
+ "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/html-to-text": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.0.tgz",
+ "integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@selderee/plugin-htmlparser2": "~0.12.0",
+ "deepmerge-ts": "^7.1.5",
+ "dom-serializer": "^2.0.0",
+ "htmlparser2": "^10.1.0",
+ "selderee": "~0.12.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ }
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/imapflow": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.4.2.tgz",
+ "integrity": "sha512-73CGfb5+W0FkZ5CY4GfSdsoXyQ+17wdKkpMN2vwJHdLtOOFQWxv0ilG7KYY79XHBYg5njjqxXYB2FPw5Tl81zQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@zone-eu/mailsplit": "5.4.12",
+ "encoding-japanese": "2.2.0",
+ "iconv-lite": "0.7.2",
+ "libbase64": "1.3.0",
+ "libmime": "5.3.8",
+ "libqp": "2.1.1",
+ "nodemailer": "9.0.1",
+ "pino": "10.3.1",
+ "socks": "2.8.9"
+ }
+ },
+ "node_modules/imapflow/node_modules/nodemailer": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
+ "integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/ip-address": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
+ "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/leac": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz",
+ "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ }
+ },
+ "node_modules/libbase64": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
+ "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
+ "license": "MIT"
+ },
+ "node_modules/libmime": {
+ "version": "5.3.8",
+ "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
+ "integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
+ "license": "MIT",
+ "dependencies": {
+ "encoding-japanese": "2.2.0",
+ "iconv-lite": "0.7.2",
+ "libbase64": "1.3.0",
+ "libqp": "2.1.1"
+ }
+ },
+ "node_modules/libqp": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
+ "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
+ "license": "MIT"
+ },
+ "node_modules/linkify-it": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
+ "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/markdown-it"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "uc.micro": "^2.0.0"
+ }
+ },
+ "node_modules/mailparser": {
+ "version": "3.9.12",
+ "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.12.tgz",
+ "integrity": "sha512-kbT4xtkKEddonhDTjjWWVFJlI5axm0S9QuIAHs1ue3IEw+fNd9o4ytPKaG7p1LOE2aKifrWLjx/omOGLHz/9RQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@zone-eu/mailsplit": "5.4.13",
+ "encoding-japanese": "2.2.0",
+ "he": "1.2.0",
+ "html-to-text": "10.0.0",
+ "iconv-lite": "0.7.2",
+ "libmime": "5.4.0",
+ "linkify-it": "5.0.1",
+ "nodemailer": "9.0.1",
+ "punycode.js": "2.3.1",
+ "tlds": "1.261.0"
+ }
+ },
+ "node_modules/mailparser/node_modules/@zone-eu/mailsplit": {
+ "version": "5.4.13",
+ "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.13.tgz",
+ "integrity": "sha512-j40NeNlSAivqnKEjzZYsGP/bKWr2zwuyb8XOYsC3i0ZlfM5UnTYTa7aCRkE8rYQvVzE1dRjVYdvZ1Epi6x+h6w==",
+ "license": "(MIT OR EUPL-1.1+)",
+ "dependencies": {
+ "libbase64": "1.3.0",
+ "libmime": "5.4.0",
+ "libqp": "2.1.1"
+ }
+ },
+ "node_modules/mailparser/node_modules/libmime": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.0.tgz",
+ "integrity": "sha512-MAWyU2qYtCsrZ6GClgukz2jx73NQrzA6ASo9qzThuTG+A7+H3lWQuBsjoy9lls+v6q/epAeBdEMJ3T0f4b+uRw==",
+ "license": "MIT",
+ "dependencies": {
+ "encoding-japanese": "2.2.0",
+ "iconv-lite": "0.7.2",
+ "libbase64": "1.3.0",
+ "libqp": "2.1.1"
+ }
+ },
+ "node_modules/mailparser/node_modules/nodemailer": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
+ "integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/nodemailer": {
+ "version": "6.10.1",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
+ "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/on-exit-leak-free": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
+ "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/parseley": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz",
+ "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==",
+ "license": "MIT",
+ "dependencies": {
+ "leac": "^0.7.0",
+ "peberminta": "^0.10.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ }
+ },
+ "node_modules/peberminta": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz",
+ "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ }
+ },
+ "node_modules/pino": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
+ "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
+ "license": "MIT",
+ "dependencies": {
+ "@pinojs/redact": "^0.4.0",
+ "atomic-sleep": "^1.0.0",
+ "on-exit-leak-free": "^2.1.0",
+ "pino-abstract-transport": "^3.0.0",
+ "pino-std-serializers": "^7.0.0",
+ "process-warning": "^5.0.0",
+ "quick-format-unescaped": "^4.0.3",
+ "real-require": "^0.2.0",
+ "safe-stable-stringify": "^2.3.1",
+ "sonic-boom": "^4.0.1",
+ "thread-stream": "^4.0.0"
+ },
+ "bin": {
+ "pino": "bin.js"
+ }
+ },
+ "node_modules/pino-abstract-transport": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
+ "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.0.0"
+ }
+ },
+ "node_modules/pino-std-serializers": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
+ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
+ "license": "MIT"
+ },
+ "node_modules/process-warning": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
+ "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/punycode.js": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/quick-format-unescaped": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
+ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
+ "license": "MIT"
+ },
+ "node_modules/real-require": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
+ "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12.13.0"
+ }
+ },
+ "node_modules/safe-stable-stringify": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/selderee": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz",
+ "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
+ "license": "MIT",
+ "dependencies": {
+ "parseley": "~0.13.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ }
+ },
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks": {
+ "version": "2.8.9",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
+ "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.1.1",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/sonic-boom": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
+ "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "atomic-sleep": "^1.0.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/thread-stream": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
+ "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "real-require": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/thread-stream/node_modules/real-require": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
+ "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
+ "license": "MIT"
+ },
+ "node_modules/tlds": {
+ "version": "1.261.0",
+ "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
+ "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
+ "license": "MIT",
+ "bin": {
+ "tlds": "bin.js"
+ }
+ },
+ "node_modules/uc.micro": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..3113ca4
--- /dev/null
+++ b/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "claude-mail",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Email back-and-forth channel for Claude at claude@agentabrams.com — sender-gated, secret-phrase-locked, with local machine access and safe/gated autonomy rails.",
+ "type": "commonjs",
+ "main": "poller.js",
+ "scripts": {
+ "poll": "node poller.js",
+ "test-smtp": "node send.js"
+ },
+ "dependencies": {
+ "imapflow": "^1.0.171",
+ "mailparser": "^3.7.1",
+ "nodemailer": "^6.9.14"
+ }
+}
diff --git a/poller.js b/poller.js
new file mode 100644
index 0000000..d75b1c6
--- /dev/null
+++ b/poller.js
@@ -0,0 +1,75 @@
+'use strict';
+// claude-mail poller — one pass: connect IMAP, process UNSEEN mail, reply, mark seen.
+// Run on an interval by launchd (com.steve.claude-mail). Idempotent: only UNSEEN
+// messages are touched, and every processed message is marked \Seen so it is never
+// handled twice.
+const { ImapFlow } = require('imapflow');
+const { simpleParser } = require('mailparser');
+const { cfg, gate, stripQuote, runClaude, sendMail } = require('./lib');
+
+const log = (...a) => console.log(new Date().toISOString(), ...a);
+
+async function main() {
+ const client = new ImapFlow({
+ host: cfg.imapHost, port: cfg.imapPort, secure: true,
+ auth: { user: cfg.user, pass: cfg.pass }, logger: false,
+ });
+ await client.connect();
+ const lock = await client.getMailboxLock('INBOX');
+ try {
+ const unseen = await client.search({ seen: false });
+ if (!unseen || unseen.length === 0) { log('no new mail'); return; }
+ log(`found ${unseen.length} unseen`);
+
+ for (const uid of unseen) {
+ let parsed;
+ try {
+ const { content } = await client.download(uid, undefined, { uid: true });
+ parsed = await simpleParser(content);
+ } catch (e) { log(`uid ${uid}: download/parse failed: ${e.message}`); continue; }
+
+ const from = parsed.from?.value?.[0]?.address || '(unknown)';
+ const subject = parsed.subject || '(no subject)';
+ const decision = gate(parsed);
+
+ if (!decision.ok) {
+ // Silent ignore: no reply to strangers / bad phrase / spoofed mail.
+ log(`IGNORE uid ${uid} from=${from} subj="${subject}" reason="${decision.reason}"`);
+ await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true });
+ continue;
+ }
+
+ log(`ACCEPT uid ${uid} from=${from} subj="${subject}" -> running claude`);
+ const body = stripQuote(parsed.text || parsed.html?.replace(/<[^>]+>/g, ' ') || '');
+ const prompt = body || subject;
+
+ let reply;
+ const res = await runClaude(prompt);
+ reply = res.ok ? res.text
+ : `I hit a problem running that locally:\n\n${res.text}\n\n— Claude (agentabrams)`;
+
+ const replySubject = /^re:/i.test(subject) ? subject : `Re: ${subject}`;
+ const msgId = parsed.messageId;
+ try {
+ const info = await sendMail({
+ to: from,
+ subject: replySubject,
+ text: reply,
+ inReplyTo: msgId,
+ references: msgId,
+ });
+ log(`REPLIED uid ${uid} -> ${from} id=${info.messageId}`);
+ } catch (e) {
+ log(`REPLY FAILED uid ${uid} -> ${from}: ${e.message}`);
+ // leave UNSEEN so the next pass retries the reply
+ continue;
+ }
+ await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true });
+ }
+ } finally {
+ lock.release();
+ await client.logout().catch(() => {});
+ }
+}
+
+main().then(() => process.exit(0)).catch((e) => { log('FATAL', e.message); process.exit(1); });
diff --git a/run.sh b/run.sh
new file mode 100755
index 0000000..cb4808d
--- /dev/null
+++ b/run.sh
@@ -0,0 +1,8 @@
+#!/bin/zsh
+# claude-mail poller wrapper — one poll pass. Used by the LaunchAgent and for
+# manual runs. Keeps PATH sane so the spawned `claude` (a node script) resolves.
+export PATH="/usr/local/bin:/Users/stevestudio2/.npm-global/bin:/usr/bin:/bin:/usr/sbin:/sbin"
+cd "$(dirname "$0")" || exit 1
+# Force Max-plan auth on the home network (mirror Steve's `claude` shell fn).
+unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN
+exec /usr/local/bin/node --env-file=.env poller.js
diff --git a/send.js b/send.js
new file mode 100644
index 0000000..c19a9be
--- /dev/null
+++ b/send.js
@@ -0,0 +1,16 @@
+'use strict';
+// One-off sender. Usage:
+// node --env-file=.env send.js "to@addr" "Subject line" < body.txt
+// echo "body" | node --env-file=.env send.js "to@addr" "Subject"
+const { sendMail, cfg } = require('./lib');
+
+(async () => {
+ const to = process.argv[2];
+ const subject = process.argv[3] || '(no subject)';
+ if (!to) { console.error('usage: send.js <to> <subject> (body on stdin)'); process.exit(2); }
+ let body = '';
+ for await (const chunk of process.stdin) body += chunk;
+ if (!body.trim()) body = '(empty body)';
+ const info = await sendMail({ to, subject, text: body });
+ console.log(`sent from ${cfg.user} -> ${to} | id=${info.messageId} | response=${info.response}`);
+})().catch((e) => { console.error('SEND FAILED:', e.message); process.exit(1); });
(oldest)
·
back to Claude Mail
·
docs: agentabrams.com Cloudflare migration package (DTD verd 92f1320 →