← 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 };