← 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 →