← back to Claude Mail
lib.js
225 lines
'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 fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const nodemailer = require('nodemailer');
const MailComposer = require('nodemailer/lib/mail-composer');
const { ImapFlow } = require('imapflow');
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 }. Two locks (Steve 2026-07-16 — secret
// phrase retired, "allowlist = enough"):
// 1) From address in allowlist (hard-rejects everyone else)
// 2) 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' };
// Mail-loop breakers (RFC 3834) — these run BEFORE the allowlist so an
// auto-responder, bounce, or our own outbound copy can never start a
// ping-pong even if its From were somehow allowlisted. Purely additive:
// legitimate human mail carries none of these headers.
if (cfg.user && from === cfg.user.toLowerCase()) {
return { ok: false, reason: 'own-address mail (loop guard)' };
}
const autoSubmitted = headerStr(parsed, 'auto-submitted').toLowerCase();
if (autoSubmitted && autoSubmitted !== 'no') {
return { ok: false, reason: `auto-submitted mail (loop guard): ${autoSubmitted}` };
}
const precedence = headerStr(parsed, 'precedence').toLowerCase();
if (/\b(bulk|list|junk|auto_reply)\b/.test(precedence)) {
return { ok: false, reason: `bulk/auto precedence (loop guard): ${precedence}` };
}
// Vacation/auto-reply and list-management headers (Gmail/Outlook/Mailman).
for (const h of ['x-autoreply', 'x-autorespond', 'x-auto-response-suppress',
'list-id', 'list-unsubscribe', 'feedback-id']) {
if (headerStr(parsed, h)) return { ok: false, reason: `auto/list header (loop guard): ${h}` };
}
const localFrom = from.split('@')[0];
if (/^(mailer-daemon|postmaster|no-?reply|do-?not-?reply|bounce)/.test(localFrom)) {
return { ok: false, reason: `system/no-reply sender (loop guard): ${from}` };
}
if (!cfg.allowed.includes(from)) return { ok: false, reason: `sender not allowlisted: ${from}` };
// Secret phrase NO LONGER required (Steve 2026-07-16: "allowlist = enough").
// The allowlist check above already hard-rejects any non-allowlisted sender, so
// the phrase was only ever a redundant second factor on trusted mail. The gate
// is now: on the allowlist AND DKIM/DMARC-aligned. Dave + every George agent
// account can just email claude@agentabrams.com with any subject.
if (cfg.requireAuthPass) {
const ar = headerStr(parsed, 'authentication-results').toLowerCase();
// Only DKIM/DMARC count — both are aligned to the header From the allowlist
// checks. Bare SPF authenticates the envelope return-path, not From, so it's
// dropped as an anti-spoof signal (all allowlisted senders are DKIM/DMARC-signed).
const passed = ar.includes('dkim=pass') || ar.includes('dmarc=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(' ');
// Persist viewable attachments (images + PDFs) from a parsed message to a temp
// dir so the headless `claude` run can open them with the Read tool — which
// renders images visually and reads PDFs page-by-page. Returns
// [{ path, filename, contentType }] for image/* and application/pdf parts only.
// Best-effort: a write that fails just drops that one file, never throws.
// (Name kept as saveImageAttachments for call-site compatibility.)
function saveImageAttachments(parsed, tag = 'msg') {
const atts = (parsed && parsed.attachments) || [];
const wanted = atts.filter((a) => /^(image\/|application\/pdf)/i.test(a.contentType || '') && a.content);
if (wanted.length === 0) return [];
const dir = path.join(os.tmpdir(), 'claude-mail-attachments', `${tag}-${Date.now()}`);
const out = [];
try { fs.mkdirSync(dir, { recursive: true }); } catch { return []; }
wanted.forEach((a, i) => {
// Sanitize: take basename only, strip anything path-ish, fall back by index/type.
const ext = (a.contentType.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 8);
const base = path.basename(a.filename || `file-${i + 1}.${ext}`).replace(/[^\w.\-]/g, '_');
const safe = base || `file-${i + 1}.${ext}`;
const dest = path.join(dir, safe);
try { fs.writeFileSync(dest, a.content); out.push({ path: dest, filename: safe, contentType: a.contentType }); }
catch { /* skip this file */ }
});
return out;
}
function runClaude(prompt, imagePaths = []) {
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;
// If the email carried viewable attachments (images and/or PDFs), tell Claude
// where they are on disk so it can open them with the Read tool. The CLI takes
// a text prompt, so we hand off via file paths rather than inline blocks.
let fullPrompt = prompt;
if (Array.isArray(imagePaths) && imagePaths.length) {
const list = imagePaths.map((p) => `- ${p.path} (${p.contentType || 'unknown type'})`).join('\n');
fullPrompt = `${prompt}\n\n[The sender attached ${imagePaths.length} file(s). `
+ `Open each with the Read tool — it renders images visually and reads PDFs `
+ `page-by-page (use the pages parameter for long PDFs) — at these local paths:\n${list}\n]`;
}
const args = [
'-p', fullPrompt,
'--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 }) {
// Build the raw message ONCE so the SMTP-sent bytes and the archived copy match.
const mail = new MailComposer({
from: `"${cfg.fromName}" <${cfg.user}>`,
to, subject, text, inReplyTo, references,
});
const raw = await mail.compile().build();
const info = await transport().sendMail({
envelope: { from: cfg.user, to },
raw,
});
// Archive a copy to the IMAP Sent folder so every reply is auditable.
// Best-effort: a failed append must never fail the send.
try {
const c = new ImapFlow({
host: cfg.imapHost, port: cfg.imapPort, secure: true,
auth: { user: cfg.user, pass: cfg.pass }, logger: false,
});
await c.connect();
await c.append('Sent', raw, ['\\Seen']);
await c.logout();
} catch (e) {
console.error(new Date().toISOString(), `sent-archive append failed: ${e.message}`);
}
return info;
}
module.exports = { cfg, gate, stripQuote, runClaude, sendMail, saveImageAttachments };