[object Object]

← back to Claude Mail

claude-mail: pass image attachments to the headless claude run

5ff9546b05f93149fcc1a0fe52a5f52a6220f961 · 2026-06-27 10:17:41 -0700 · Steve

Extract image/* parts from incoming mail, save to a temp dir, and hand the
paths to claude -p so it can view them with the Read tool. Image-only emails
(no text body) now get processed instead of bounced.

Files touched

Diff

commit 5ff9546b05f93149fcc1a0fe52a5f52a6220f961
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Jun 27 10:17:41 2026 -0700

    claude-mail: pass image attachments to the headless claude run
    
    Extract image/* parts from incoming mail, save to a temp dir, and hand the
    paths to claude -p so it can view them with the Read tool. Image-only emails
    (no text body) now get processed instead of bounced.
---
 lib.js    | 42 +++++++++++++++++++++++++++++++++++++++---
 poller.js | 17 +++++++++++------
 2 files changed, 50 insertions(+), 9 deletions(-)

diff --git a/lib.js b/lib.js
index f81b481..7695f38 100644
--- a/lib.js
+++ b/lib.js
@@ -3,6 +3,9 @@
 // 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');
@@ -119,15 +122,48 @@ const RAILS = [
   '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) {
+// Persist image attachments from a parsed message to a temp dir so the headless
+// `claude` run can view them with the Read tool (which renders images visually).
+// Returns [{ path, filename, contentType }] for image/* parts only. Best-effort:
+// a write that fails just drops that one image, never throws.
+function saveImageAttachments(parsed, tag = 'msg') {
+  const atts = (parsed && parsed.attachments) || [];
+  const images = atts.filter((a) => /^image\//i.test(a.contentType || '') && a.content);
+  if (images.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 []; }
+  images.forEach((a, i) => {
+    // Sanitize: take basename only, strip anything path-ish, fall back by index/type.
+    const ext = (a.contentType.split('/')[1] || 'img').replace(/[^a-z0-9]/gi, '').slice(0, 8);
+    const base = path.basename(a.filename || `image-${i + 1}.${ext}`).replace(/[^\w.\-]/g, '_');
+    const safe = base || `image-${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 image */ }
+  });
+  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 image attachments, 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 image blocks.
+    let fullPrompt = prompt;
+    if (Array.isArray(imagePaths) && imagePaths.length) {
+      const list = imagePaths.map((p) => `- ${p.path}`).join('\n');
+      fullPrompt = `${prompt}\n\n[The sender attached ${imagePaths.length} image file(s). `
+        + `View each with the Read tool — it renders images visually — at these local paths:\n${list}\n]`;
+    }
+
     const args = [
-      '-p', prompt,
+      '-p', fullPrompt,
       '--append-system-prompt', RAILS,
       '--permission-mode', cfg.permissionMode,
       '--output-format', 'text',
@@ -185,4 +221,4 @@ async function sendMail({ to, subject, text, inReplyTo, references }) {
   return info;
 }
 
-module.exports = { cfg, gate, stripQuote, runClaude, sendMail };
+module.exports = { cfg, gate, stripQuote, runClaude, sendMail, saveImageAttachments };
diff --git a/poller.js b/poller.js
index 91488b1..20c87d9 100644
--- a/poller.js
+++ b/poller.js
@@ -5,7 +5,7 @@
 // handled twice.
 const { ImapFlow } = require('imapflow');
 const { simpleParser } = require('mailparser');
-const { cfg, gate, stripQuote, runClaude, sendMail } = require('./lib');
+const { cfg, gate, stripQuote, runClaude, sendMail, saveImageAttachments } = require('./lib');
 const { recordSuccess, recordFailure } = require('./health');
 
 const log = (...a) => console.log(new Date().toISOString(), ...a);
@@ -40,18 +40,23 @@ async function main() {
         continue;
       }
 
-      log(`ACCEPT uid ${uid} from=${from} subj="${subject}" -> running claude`);
+      // Persist any image attachments so the claude run can view them.
+      const images = saveImageAttachments(parsed, `uid${uid}`);
+      log(`ACCEPT uid ${uid} from=${from} subj="${subject}" images=${images.length} -> running claude`);
       const body = stripQuote(parsed.text || parsed.html?.replace(/<[^>]+>/g, ' ') || '');
       const prompt = (body || subject || '').trim();
 
       let reply;
-      if (!prompt) {
-        // Nothing to act on (empty body + empty subject) — don't invoke claude
-        // on an empty prompt; reply asking for content.
+      if (!prompt && images.length === 0) {
+        // Nothing to act on (empty body + empty subject + no images) — don't invoke
+        // claude on an empty prompt; reply asking for content.
         reply = 'I got your message but it had no readable body or subject to act on. '
           + 'Reply with what you\'d like me to do (keep the phrase in the subject).\n\n— Claude (agentabrams)';
       } else {
-        const res = await runClaude(prompt);
+        // Image-only mail still gets a prompt so Claude knows to describe the attachment.
+        const effectivePrompt = prompt
+          || 'This email had no text — please look at the attached image(s) and describe what you see.';
+        const res = await runClaude(effectivePrompt, images);
         reply = res.ok ? res.text
           : `I hit a problem running that locally:\n\n${res.text}\n\n— Claude (agentabrams)`;
       }

← d59fd42 claude-mail: self-heal — page Steve via CNCP+macOS+George wh  ·  back to Claude Mail  ·  claude-mail: accept PDF attachments too (Read renders them p 26f2ff5 →