Răsfoiți Sursa

feat: fix attachment handling with MIME source fallback

AppleScript's `mail attachments` object returns empty collections across
all account types (iCloud, Google, Exchange) when attachments are embedded
as MIME parts in the message source. This is a known limitation of how
Mail.app's AppleScript bridge exposes attachments - MIME-encoded
attachments aren't surfaced through the attachment object model.

This adds a MIME source fallback to listAttachments and saveAttachment:

- listAttachments: tries AppleScript first, falls back to parsing raw
  MIME source for Content-Disposition: attachment blocks
- saveAttachment: tries AppleScript first, falls back to extracting and
  decoding base64 from MIME source
- getRawSource: new helper method to retrieve raw MIME source via
  AppleScript `source of msg`
- hasAttachments: now detects attachments via AppleScript count + MIME
  source check instead of hardcoding false
- mimeParse utility: standalone MIME parser with 10 unit tests

Verified on iCloud, Google, and Exchange accounts with real attachments
(PDFs, DOCX, images) that previously returned "No attachments found".

73 tests passing (10 new + 63 existing), zero regressions.
Kevin May 2 luni în urmă
părinte
comite
78f1edd759
3 a modificat fișierele cu 466 adăugiri și 24 ștergeri
  1. 108 24
      src/services/appleMailManager.ts
  2. 154 0
      src/utils/mimeParse.test.ts
  3. 204 0
      src/utils/mimeParse.ts

+ 108 - 24
src/services/appleMailManager.ts

@@ -14,10 +14,11 @@
  */
 
 import { spawnSync } from "child_process";
-import { existsSync } from "fs";
+import { existsSync, writeFileSync } from "fs";
 import { isAbsolute, resolve } from "path";
 import { homedir } from "os";
 import { executeAppleScript } from "@/utils/applescript.js";
+import { parseMimeAttachments, extractMimeAttachment } from "@/utils/mimeParse.js";
 import type {
   Message,
   MessageContent,
@@ -493,7 +494,18 @@ export class AppleMailManager {
                 set msgDeleted to deleted status of msg as string
                 set msgMailbox to name of mb
                 set msgAccount to name of acct
-                return msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & msgJunk & "|||" & msgDeleted & "|||" & msgMailbox & "|||" & msgAccount
+                set hasAtt to "false"
+                try
+                  set attCount to count of mail attachments of msg
+                  if attCount > 0 then set hasAtt to "true"
+                end try
+                if hasAtt is "false" then
+                  try
+                    set rawSrc to source of msg
+                    if rawSrc contains "Content-Disposition: attachment" then set hasAtt to "true"
+                  end try
+                end if
+                return msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & msgJunk & "|||" & msgDeleted & "|||" & msgMailbox & "|||" & msgAccount & "|||" & hasAtt
               end if
             end try
           end repeat
@@ -526,7 +538,7 @@ export class AppleMailManager {
       isDeleted: parts[6] === "true",
       mailbox: parts[7],
       account: parts[8],
-      hasAttachments: false,
+      hasAttachments: parts.length > 9 ? parts[9] === "true" : false,
     };
   }
 
@@ -581,6 +593,40 @@ export class AppleMailManager {
     };
   }
 
+  /**
+   * Get the raw MIME source of a message.
+   * Used as fallback for attachment extraction when AppleScript
+   * mail attachments returns empty.
+   */
+  getRawSource(id: string): string | null {
+    const script = buildAppLevelScript(`
+      try
+        repeat with acct in accounts
+          repeat with mb in mailboxes of acct
+            try
+              set matchingMsgs to (messages of mb whose id is ${Number(id)})
+              if (count of matchingMsgs) > 0 then
+                set msg to item 1 of matchingMsgs
+                return source of msg
+              end if
+            end try
+          end repeat
+        end repeat
+        return ""
+      on error errMsg
+        return ""
+      end try
+    `);
+
+    const result = executeAppleScript(script, { timeoutMs: 120000 });
+
+    if (!result.success || !result.output.trim()) {
+      return null;
+    }
+
+    return result.output;
+  }
+
   /**
    * List messages in a mailbox.
    *
@@ -1286,8 +1332,11 @@ export class AppleMailManager {
 
   /**
    * List attachments for a message.
+   * Tries AppleScript first, falls back to MIME source parsing
+   * when AppleScript returns empty (known issue across all account types).
    */
   listAttachments(id: string): Attachment[] {
+    // Attempt 1: AppleScript mail attachments
     const script = buildAppLevelScript(`
       try
         repeat with acct in accounts
@@ -1319,30 +1368,42 @@ export class AppleMailManager {
 
     const result = executeAppleScript(script, { timeoutMs: 60000 });
 
-    if (!result.success || !result.output.trim()) {
-      return [];
-    }
+    if (result.success && result.output.trim()) {
+      const items = result.output.split("|||ITEM|||");
+      const attachments: Attachment[] = [];
 
-    const items = result.output.split("|||ITEM|||");
-    const attachments: Attachment[] = [];
+      for (const item of items) {
+        const parts = item.split("|||");
+        if (parts.length < 3) continue;
 
-    for (const item of items) {
-      const parts = item.split("|||");
-      if (parts.length < 3) continue;
+        attachments.push({
+          id: `${id}-${parts[0]}`,
+          name: parts[0],
+          mimeType: parts[1],
+          size: parseInt(parts[2]) || 0,
+        });
+      }
 
-      attachments.push({
-        id: `${id}-${parts[0]}`,
-        name: parts[0],
-        mimeType: parts[1],
-        size: parseInt(parts[2]) || 0,
-      });
+      if (attachments.length > 0) return attachments;
     }
 
-    return attachments;
+    // Attempt 2: MIME source fallback
+    const rawSource = this.getRawSource(id);
+    if (!rawSource) return [];
+
+    const mimeAttachments = parseMimeAttachments(rawSource);
+    return mimeAttachments.map((att) => ({
+      id: `${id}-${att.name}`,
+      name: att.name,
+      mimeType: att.mimeType,
+      size: att.size,
+    }));
   }
 
   /**
    * Save an attachment from a message to disk.
+   * Tries AppleScript first, falls back to MIME source extraction
+   * when AppleScript can't find the attachment.
    */
   saveAttachment(id: string, attachmentName: string, savePath: string): boolean {
     // Validate attachment name: block path separators, traversal, null bytes, and backslashes
@@ -1362,11 +1423,9 @@ export class AppleMailManager {
 
     const safeName = escapeForAppleScript(attachmentName);
     const safePath = escapeForAppleScript(resolvedPath);
-
-    // Use Number(id) as defense-in-depth — the Zod schema already enforces numeric IDs,
-    // but this ensures raw interpolation into AppleScript is safe even if validation changes.
     const numericId = Number(id);
 
+    // Attempt 1: AppleScript save
     const script = buildAppLevelScript(`
       try
         repeat with acct in accounts
@@ -1395,12 +1454,37 @@ export class AppleMailManager {
 
     const result = executeAppleScript(script, { timeoutMs: 60000 });
 
-    if (!result.success || result.output.startsWith("error:")) {
-      console.error(`Failed to save attachment: ${result.error || result.output}`);
+    if (result.success && result.output === "ok") {
+      return true;
+    }
+
+    // Attempt 2: MIME source fallback
+    const rawSource = this.getRawSource(id);
+    if (!rawSource) {
+      console.error(`Failed to save attachment: could not retrieve message source`);
       return false;
     }
 
-    return true;
+    const attachment = extractMimeAttachment(rawSource, attachmentName);
+    if (!attachment) {
+      console.error(`Failed to save attachment: "${attachmentName}" not found in MIME source`);
+      return false;
+    }
+
+    try {
+      const outPath = resolve(resolvedPath, attachmentName);
+      // Verify the resolved output path is still within allowed directories
+      const isOutAllowed = allowedPrefixes.some((prefix) => outPath.startsWith(prefix));
+      if (!isOutAllowed) {
+        console.error(`Output path "${outPath}" is outside allowed directories`);
+        return false;
+      }
+      writeFileSync(outPath, attachment.data);
+      return true;
+    } catch (err) {
+      console.error(`Failed to write attachment to disk: ${err}`);
+      return false;
+    }
   }
 
   // ===========================================================================

+ 154 - 0
src/utils/mimeParse.test.ts

@@ -0,0 +1,154 @@
+import { describe, it, expect } from "vitest";
+import { parseMimeAttachments, extractMimeAttachment } from "./mimeParse.js";
+
+const MIME_WITH_PDF = `Content-Type: multipart/mixed;
+\tboundary="_004_TEST"
+MIME-Version: 1.0
+
+--_004_TEST
+Content-Type: text/plain; charset="us-ascii"
+Content-Transfer-Encoding: quoted-printable
+
+Hello world
+
+--_004_TEST
+Content-Type: application/pdf; name="report.pdf"
+Content-Description: report.pdf
+Content-Disposition: attachment;
+\tfilename="report.pdf"; size=100;
+\tcreation-date="Mon, 14 Apr 2026 12:10:46 GMT";
+\tmodification-date="Mon, 14 Apr 2026 12:10:46 GMT"
+Content-Transfer-Encoding: base64
+
+JVBERi0xLjAKMSAwIG9iago=
+
+--_004_TEST--`;
+
+const MIME_TEXT_ONLY = `Content-Type: text/plain; charset="us-ascii"
+MIME-Version: 1.0
+
+Just a plain text email with no attachments.`;
+
+const MIME_MULTI_ATTACH = `Content-Type: multipart/mixed;
+\tboundary="_004_MULTI"
+
+--_004_MULTI
+Content-Type: text/plain; charset="us-ascii"
+
+Body text
+
+--_004_MULTI
+Content-Type: application/pdf; name="doc1.pdf"
+Content-Disposition: attachment; filename="doc1.pdf"; size=50
+Content-Transfer-Encoding: base64
+
+AAAA
+
+--_004_MULTI
+Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document; name="doc2.docx"
+Content-Disposition: attachment; filename="doc2.docx"; size=75
+Content-Transfer-Encoding: base64
+
+BBBB
+
+--_004_MULTI--`;
+
+const MIME_WITH_INLINE = `Content-Type: multipart/mixed;
+\tboundary="_004_INLINE"
+
+--_004_INLINE
+Content-Type: text/html; charset="us-ascii"
+
+<html><body>Hi</body></html>
+
+--_004_INLINE
+Content-Type: image/png; name="image001.png"
+Content-Disposition: inline; filename="image001.png"
+Content-Transfer-Encoding: base64
+
+iVBORw0KGgo=
+
+--_004_INLINE
+Content-Type: application/pdf; name="actual-doc.pdf"
+Content-Disposition: attachment; filename="actual-doc.pdf"; size=200
+Content-Transfer-Encoding: base64
+
+JVBERi0xLjAK
+
+--_004_INLINE--`;
+
+describe("parseMimeAttachments", () => {
+  it("extracts attachment metadata from MIME source", () => {
+    const result = parseMimeAttachments(MIME_WITH_PDF);
+    expect(result).toHaveLength(1);
+    expect(result[0].name).toBe("report.pdf");
+    expect(result[0].mimeType).toBe("application/pdf");
+    expect(result[0].size).toBe(100);
+  });
+
+  it("returns empty array for text-only messages", () => {
+    const result = parseMimeAttachments(MIME_TEXT_ONLY);
+    expect(result).toHaveLength(0);
+  });
+
+  it("extracts multiple attachments", () => {
+    const result = parseMimeAttachments(MIME_MULTI_ATTACH);
+    expect(result).toHaveLength(2);
+    expect(result[0].name).toBe("doc1.pdf");
+    expect(result[1].name).toBe("doc2.docx");
+  });
+
+  it("skips inline dispositions", () => {
+    const result = parseMimeAttachments(MIME_WITH_INLINE);
+    expect(result).toHaveLength(1);
+    expect(result[0].name).toBe("actual-doc.pdf");
+  });
+
+  it("returns empty array for empty input", () => {
+    expect(parseMimeAttachments("")).toHaveLength(0);
+    expect(parseMimeAttachments("   ")).toHaveLength(0);
+  });
+
+  it("estimates size from base64 when size header missing", () => {
+    const noSizeMime = `Content-Type: multipart/mixed;
+\tboundary="_004_NOSIZE"
+
+--_004_NOSIZE
+Content-Type: application/pdf; name="test.pdf"
+Content-Disposition: attachment; filename="test.pdf"
+Content-Transfer-Encoding: base64
+
+AAAAAAAAAAAAAAAA
+
+--_004_NOSIZE--`;
+    const result = parseMimeAttachments(noSizeMime);
+    expect(result).toHaveLength(1);
+    expect(result[0].size).toBeGreaterThan(0);
+  });
+});
+
+describe("extractMimeAttachment", () => {
+  it("decodes base64 content for a named attachment", () => {
+    const result = extractMimeAttachment(MIME_WITH_PDF, "report.pdf");
+    expect(result).not.toBeNull();
+    expect(result!.name).toBe("report.pdf");
+    expect(result!.data).toBeInstanceOf(Buffer);
+    expect(result!.data.length).toBeGreaterThan(0);
+  });
+
+  it("returns null for non-existent attachment", () => {
+    const result = extractMimeAttachment(MIME_WITH_PDF, "nope.pdf");
+    expect(result).toBeNull();
+  });
+
+  it("extracts the correct attachment from multiple", () => {
+    const result = extractMimeAttachment(MIME_MULTI_ATTACH, "doc2.docx");
+    expect(result).not.toBeNull();
+    expect(result!.name).toBe("doc2.docx");
+  });
+
+  it("returns null for empty input", () => {
+    expect(extractMimeAttachment("", "test.pdf")).toBeNull();
+    expect(extractMimeAttachment("   ", "test.pdf")).toBeNull();
+  });
+});

+ 204 - 0
src/utils/mimeParse.ts

@@ -0,0 +1,204 @@
+/**
+ * MIME Source Parser for Attachment Extraction
+ *
+ * Parses raw email MIME source to extract attachment metadata and content.
+ * Used as a fallback when AppleScript's `mail attachments` returns empty
+ * (which happens across all account types: iCloud, Google, Exchange).
+ *
+ * @module utils/mimeParse
+ */
+
+export interface MimeAttachmentInfo {
+  /** Filename from Content-Disposition or Content-Type name parameter */
+  name: string;
+  /** MIME type from Content-Type header */
+  mimeType: string;
+  /** Size in bytes from Content-Disposition size parameter, or estimated from base64 */
+  size: number;
+}
+
+export interface MimeAttachmentData extends MimeAttachmentInfo {
+  /** Decoded binary content */
+  data: Buffer;
+}
+
+/**
+ * Extract the boundary string from a Content-Type header.
+ */
+function extractBoundary(source: string): string | null {
+  const match = source.match(/boundary="?([^";\s\r\n]+)"?/i);
+  return match ? match[1] : null;
+}
+
+/**
+ * Extract a header value from a MIME part header block.
+ * Handles folded headers (continuation lines starting with whitespace).
+ */
+function getHeader(headers: string, name: string): string | null {
+  const regex = new RegExp(`^${name}:\\s*(.+(?:\\r?\\n[ \\t]+.+)*)`, "im");
+  const match = headers.match(regex);
+  if (!match) return null;
+  // Unfold: replace newline+whitespace with single space
+  return match[1].replace(/\r?\n[ \t]+/g, " ").trim();
+}
+
+/**
+ * Extract filename from Content-Disposition or Content-Type headers.
+ */
+function extractFilename(headers: string): string | null {
+  // Try Content-Disposition filename first
+  const dispHeader = getHeader(headers, "Content-Disposition");
+  if (dispHeader) {
+    const fnMatch = dispHeader.match(/filename="?([^";\r\n]+)"?/i);
+    if (fnMatch) return fnMatch[1].trim();
+  }
+  // Fall back to Content-Type name parameter
+  const ctHeader = getHeader(headers, "Content-Type");
+  if (ctHeader) {
+    const nameMatch = ctHeader.match(/name="?([^";\r\n]+)"?/i);
+    if (nameMatch) return nameMatch[1].trim();
+  }
+  return null;
+}
+
+/**
+ * Check if a MIME part has inline disposition (not a real attachment).
+ */
+function isInlineDisposition(headers: string): boolean {
+  const dispHeader = getHeader(headers, "Content-Disposition");
+  if (!dispHeader) return false;
+  return dispHeader.toLowerCase().startsWith("inline");
+}
+
+/**
+ * Extract size from Content-Disposition size parameter.
+ */
+function extractSize(headers: string): number {
+  const dispHeader = getHeader(headers, "Content-Disposition");
+  if (dispHeader) {
+    const sizeMatch = dispHeader.match(/size=(\d+)/i);
+    if (sizeMatch) return parseInt(sizeMatch[1], 10);
+  }
+  return 0;
+}
+
+/**
+ * Extract MIME type from Content-Type header.
+ */
+function extractMimeType(headers: string): string {
+  const ctHeader = getHeader(headers, "Content-Type");
+  if (!ctHeader) return "application/octet-stream";
+  const typeMatch = ctHeader.match(/^([^;\s]+)/);
+  return typeMatch ? typeMatch[1].toLowerCase() : "application/octet-stream";
+}
+
+/**
+ * Estimate decoded size from base64 content length.
+ */
+function estimateBase64Size(base64Body: string): number {
+  const cleaned = base64Body.replace(/[\s\r\n]/g, "");
+  return Math.floor((cleaned.length * 3) / 4);
+}
+
+/**
+ * Split MIME source into parts using the boundary.
+ */
+function splitMimeParts(
+  source: string,
+  boundary: string
+): Array<{ headers: string; body: string }> {
+  const parts: Array<{ headers: string; body: string }> = [];
+  const boundaryDelim = `--${boundary}`;
+
+  const sections = source.split(boundaryDelim);
+
+  for (const section of sections) {
+    const trimmed = section.trim();
+    if (!trimmed || trimmed.startsWith("--")) continue;
+
+    // Split headers from body at first blank line
+    const blankLineIdx = trimmed.search(/\r?\n\r?\n/);
+    if (blankLineIdx === -1) continue;
+
+    const headers = trimmed.substring(0, blankLineIdx);
+    const body = trimmed.substring(blankLineIdx).replace(/^\r?\n\r?\n/, "");
+
+    parts.push({ headers, body });
+  }
+
+  return parts;
+}
+
+/**
+ * Parse MIME source and return metadata for all file attachments.
+ * Skips inline dispositions (signature images, etc.).
+ *
+ * @param source - Raw MIME source of the email
+ * @returns Array of attachment metadata (name, mimeType, size)
+ */
+export function parseMimeAttachments(source: string): MimeAttachmentInfo[] {
+  if (!source || !source.trim()) return [];
+
+  const boundary = extractBoundary(source);
+  if (!boundary) return [];
+
+  const parts = splitMimeParts(source, boundary);
+  const attachments: MimeAttachmentInfo[] = [];
+
+  for (const part of parts) {
+    const filename = extractFilename(part.headers);
+    if (!filename) continue;
+
+    if (isInlineDisposition(part.headers)) continue;
+
+    const encoding = getHeader(part.headers, "Content-Transfer-Encoding");
+    if (!encoding || encoding.toLowerCase() !== "base64") continue;
+
+    attachments.push({
+      name: filename,
+      mimeType: extractMimeType(part.headers),
+      size: extractSize(part.headers) || estimateBase64Size(part.body),
+    });
+  }
+
+  return attachments;
+}
+
+/**
+ * Extract and decode a specific attachment from MIME source by filename.
+ *
+ * @param source - Raw MIME source of the email
+ * @param attachmentName - Filename to extract
+ * @returns Decoded attachment data, or null if not found
+ */
+export function extractMimeAttachment(
+  source: string,
+  attachmentName: string
+): MimeAttachmentData | null {
+  if (!source || !source.trim()) return null;
+
+  const boundary = extractBoundary(source);
+  if (!boundary) return null;
+
+  const parts = splitMimeParts(source, boundary);
+
+  for (const part of parts) {
+    const filename = extractFilename(part.headers);
+    if (filename !== attachmentName) continue;
+
+    const encoding = getHeader(part.headers, "Content-Transfer-Encoding");
+    if (!encoding || encoding.toLowerCase() !== "base64") continue;
+
+    const base64Clean = part.body.replace(/[\s\r\n]/g, "");
+    const data = Buffer.from(base64Clean, "base64");
+
+    return {
+      name: filename,
+      mimeType: extractMimeType(part.headers),
+      size: extractSize(part.headers) || data.length,
+      data,
+    };
+  }
+
+  return null;
+}