[object Object]

← back to Claude Mail

claude-mail: accept PDF attachments too (Read renders them page-by-page)

26f2ff5b69048145e123d22453eeadd3eb012b3f · 2026-06-27 12:04:24 -0700 · Steve Abrams

Files touched

Diff

commit 26f2ff5b69048145e123d22453eeadd3eb012b3f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Jun 27 12:04:24 2026 -0700

    claude-mail: accept PDF attachments too (Read renders them page-by-page)
---
 lib.js    | 37 ++++++++++++++++++++-----------------
 poller.js |  4 ++--
 2 files changed, 22 insertions(+), 19 deletions(-)

diff --git a/lib.js b/lib.js
index 7695f38..eaa2930 100644
--- a/lib.js
+++ b/lib.js
@@ -122,25 +122,27 @@ 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(' ');
 
-// 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.
+// 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 images = atts.filter((a) => /^image\//i.test(a.contentType || '') && a.content);
-  if (images.length === 0) return [];
+  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 []; }
-  images.forEach((a, i) => {
+  wanted.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 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 image */ }
+    catch { /* skip this file */ }
   });
   return out;
 }
@@ -152,14 +154,15 @@ function runClaude(prompt, imagePaths = []) {
     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.
+    // 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}`).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 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 = [
diff --git a/poller.js b/poller.js
index 20c87d9..ce4c4da 100644
--- a/poller.js
+++ b/poller.js
@@ -53,9 +53,9 @@ async function main() {
         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 {
-        // Image-only mail still gets a prompt so Claude knows to describe the attachment.
+        // Attachment-only mail still gets a prompt so Claude knows to describe it.
         const effectivePrompt = prompt
-          || 'This email had no text — please look at the attached image(s) and describe what you see.';
+          || 'This email had no text — please look at the attached file(s) (image or PDF) 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)`;

← 5ff9546 claude-mail: pass image attachments to the headless claude r  ·  back to Claude Mail  ·  chore: macstudio3 migration — reconcile from mac2 + repoint 69f2dae →