From 89b8cc525b874b2acfa13d037f50291d8964fe02 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Thu, 6 Aug 2026 13:14:11 -0400 Subject: [PATCH] Render inline mail images in the body, drop the ones nothing references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mail client embeds an image it attached to a message by pointing at its MIME part: ``. Mail connectors treated every such part as an ordinary attachment, which produced two problems. An image the body still references was listed below the message instead of appearing where the sender placed it — and because connectors report no dimensions for it, it rendered in a fixed preview box at nothing like its real size. The Outlook connector dropped these parts entirely, so a screenshot pasted into a message never showed up at all. Worse, connectors trim quoted history out of a reply's body but classified attachments against the raw message. A sender's signature image is re-embedded by every reply in a chain, so each message carried a part whose only reference sat in the quote that had just been removed. Those orphans became an attachment on every message in a long thread — often a blank placeholder the reader had never seen, because the sender's own mail gateway had already stripped the image. Parts are now classified against the body that survives trimming: - Referenced by the retained body -> the fileRef action carries the part's `contentId`, and Plot renders the image inline where the sender put it. - Not referenced -> dropped. There is nowhere to render it, and listing it as an attachment shows a file the reader cannot place. - No HTML body to test against (a plain-text message) -> left as an attachment rather than silently discarded. Adds `contentId` to `ActionType.fileRef`, `contentId`/`inline` to `ImapMessage.attachments`, and `referencedContentIds`/`normalizeContentId` to the signals entry point so all three mail connectors share one rule. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AZjF9kXrBHeX4415ifBXvZ --- .changeset/fileref-content-id.md | 16 ++ connectors/apple/src/mail/transform.test.ts | 49 +++++++ connectors/apple/src/mail/transform.ts | 31 +++- connectors/google/src/mail/gmail-api.test.ts | 137 ++++++++++++++++++ connectors/google/src/mail/gmail-api.ts | 86 ++++++++--- .../outlook/src/mail/graph-mail-api.test.ts | 50 ++++++- connectors/outlook/src/mail/graph-mail-api.ts | 50 +++++-- twister/src/plot.ts | 15 ++ twister/src/signals.ts | 50 +++++++ twister/src/tools/imap.ts | 16 +- 10 files changed, 463 insertions(+), 37 deletions(-) create mode 100644 .changeset/fileref-content-id.md diff --git a/.changeset/fileref-content-id.md b/.changeset/fileref-content-id.md new file mode 100644 index 00000000..db15f165 --- /dev/null +++ b/.changeset/fileref-content-id.md @@ -0,0 +1,16 @@ +--- +"@plotday/twister": minor +--- + +Added: `contentId` on `ActionType.fileRef` actions. + +Marks a file reference as an inline body image rather than an attachment. Set +it to the part's Content-ID (without angle brackets) when the note's content +still carries a matching `cid:` reference, and Plot renders the image where the +sender placed it, at its own size, instead of appending an attachment chip. + +Mail connectors should use this to distinguish three cases: an ordinary +attachment (no `contentId`), an inline image the retained content still +references (`contentId` set), and an inline image whose only reference lived in +quoted history that was trimmed away — a signature logo, typically — which +should be dropped rather than surfaced as an attachment nobody can place. diff --git a/connectors/apple/src/mail/transform.test.ts b/connectors/apple/src/mail/transform.test.ts index 660776f3..c755966f 100644 --- a/connectors/apple/src/mail/transform.test.ts +++ b/connectors/apple/src/mail/transform.test.ts @@ -445,6 +445,55 @@ describe("transformMessages attachments", () => { expect(note.actions).toBeUndefined(); }); + /** One inline image part, as BODYSTRUCTURE reports a `multipart/related` image. */ + const inlinePart = (contentId: string) => ({ + partNumber: "2", + fileName: "image001.jpg", + mimeType: "image/jpeg", + size: 823, + encoding: "base64", + contentId, + inline: true, + }); + + it("tags an inline image the body references with its Content-ID", () => { + const m = msg({ + uid: 15, + bodyHtml: `

Here it is:

`, + attachments: [inlinePart("ii_abc123")], + }); + const note = transform([m])[0].notes![0] as unknown as { + actions?: Array; + }; + expect(note.actions).toHaveLength(1); + expect(note.actions![0].contentId).toBe("ii_abc123"); + }); + + it("drops an inline image the body never references", () => { + const m = msg({ + uid: 16, + bodyHtml: `

No image here.

`, + attachments: [inlinePart("ii_orphan")], + }); + const note = transform([m])[0].notes![0] as unknown as { actions?: ActionLike[] }; + expect(note.actions).toBeUndefined(); + }); + + it("keeps an inline image when only a plain-text body survived", () => { + // No HTML to match a `cid:` against, so the part can't be shown to be + // orphaned — keep it rather than silently discard an attachment. + const m = msg({ + uid: 17, + bodyText: "Can we meet?", + attachments: [inlinePart("ii_abc123")], + }); + const note = transform([m])[0].notes![0] as unknown as { + actions?: Array; + }; + expect(note.actions).toHaveLength(1); + expect(note.actions![0].contentId ?? null).toBeNull(); + }); + it("FIX 6: omits an inline calendar part whose fileName is the synthesized 'attachment' placeholder", () => { const m = msg({ uid: 15, diff --git a/connectors/apple/src/mail/transform.ts b/connectors/apple/src/mail/transform.ts index 24840867..505083a8 100644 --- a/connectors/apple/src/mail/transform.ts +++ b/connectors/apple/src/mail/transform.ts @@ -1,5 +1,6 @@ import type { ImapAddress, ImapMessage } from "@plotday/twister/tools/imap"; import { ActionType, type Action, type NewContact, type NewLinkWithNotes } from "@plotday/twister"; +import { referencedContentIds } from "@plotday/twister/signals"; import { parse } from "../product-channel"; import { appleMailSignals } from "./apple-facets"; @@ -163,18 +164,40 @@ function compareCopies(a: MailMessage, b: MailMessage): number { * chip on emails that don't even bundle (bare invites). A genuinely named * calendar attachment (e.g. a forwarded `invite.ics`) still appears * normally — only the synthesized-name case is suppressed. + * + * Inline images are classified against the message's HTML body: one the body + * references is tagged with its Content-ID so Plot renders it in place, while + * one nothing references is dropped — it has nowhere to render, and would + * otherwise put an attachment chip on the message for an image the reader never + * saw. A message with no HTML body carries no `cid:` references to match + * against, so there inline parts stay attachments rather than being silently + * discarded. */ function attachmentActions(m: MailMessage): Action[] | undefined { if (!m.attachments || m.attachments.length === 0) return undefined; - const actions = m.attachments - .filter((a) => !(isCalendarAttachment(a.mimeType) && a.fileName === "attachment")) - .map((a) => ({ + const body = bodyOf(m); + const referenced = + body && body.contentType === "html" + ? referencedContentIds(body.content) + : null; + const actions: Action[] = []; + for (const a of m.attachments) { + if (isCalendarAttachment(a.mimeType) && a.fileName === "attachment") continue; + const inlineId = a.inline && a.contentId ? a.contentId : null; + const embedded = + inlineId !== null && + referenced !== null && + referenced.has(inlineId.toLowerCase()); + if (inlineId !== null && referenced !== null && !embedded) continue; + actions.push({ type: ActionType.fileRef as ActionType.fileRef, ref: buildAttachmentRef(m.mailbox, m.uid, a.partNumber), fileName: a.fileName, fileSize: a.size, mimeType: a.mimeType, - })); + ...(embedded ? { contentId: inlineId } : {}), + }); + } return actions.length > 0 ? actions : undefined; } diff --git a/connectors/google/src/mail/gmail-api.test.ts b/connectors/google/src/mail/gmail-api.test.ts index e478209b..d38ca173 100644 --- a/connectors/google/src/mail/gmail-api.test.ts +++ b/connectors/google/src/mail/gmail-api.test.ts @@ -1307,3 +1307,140 @@ describe("extractCalendarReplies", () => { }); }); +describe("transformGmailThread inline images", () => { + /** + * A `multipart/related` message: the HTML body plus one image part carried + * the way a mail client attaches an inline image — `Content-Disposition: + * inline` with a `Content-ID` the HTML points at via `src="cid:…"`. + */ + function relatedThread(html: string, contentId = "ii_abc123"): GmailThread { + return thread({ + from: "Robin ", + to: "me@example.com", + subject: "Re: Query", + payload: part("multipart/related", { + parts: [ + part("multipart/alternative", { + parts: [part("text/html", { data: html })], + }), + { + mimeType: "image/jpeg", + filename: "image001.jpg", + headers: [ + { name: "Content-Type", value: 'image/jpeg; name="image001.jpg"' }, + { + name: "Content-Disposition", + value: 'inline; filename="image001.jpg"', + }, + { name: "Content-ID", value: `<${contentId}>` }, + { name: "X-Attachment-Id", value: contentId }, + ], + body: { size: 823, attachmentId: "att-inline-1" }, + }, + ], + }), + }); + } + + function actionsOf(link: ReturnType) { + return (link.notes![0].actions ?? []) as Array<{ + fileName: string; + contentId?: string | null; + }>; + } + + it("drops an inline image whose only reference was in the trimmed quote", () => { + // The signature logo lives inside the quoted history, which + // `stripQuotedReply` cuts away — nothing in the retained body points at it. + const link = transformGmailThread( + relatedThread( + `
Brilliant! Thank you.
` + + `
` + + `

Robin Fisher

` + + `
` + ) + ); + + expect(actionsOf(link)).toEqual([]); + }); + + it("keeps an inline image the retained body references, tagged with its Content-ID", () => { + const link = transformGmailThread( + relatedThread( + `
Here is the chart:
` + + `
` + ) + ); + + expect(actionsOf(link)).toEqual([ + expect.objectContaining({ + fileName: "image001.jpg", + contentId: "ii_abc123", + }), + ]); + }); + + it("leaves an ordinary attachment untagged", () => { + const link = transformGmailThread( + thread({ + from: "Robin ", + to: "me@example.com", + subject: "Report", + payload: part("multipart/mixed", { + parts: [ + part("text/html", { data: "
See attached.
" }), + { + mimeType: "application/pdf", + filename: "report.pdf", + headers: [ + { + name: "Content-Disposition", + value: 'attachment; filename="report.pdf"', + }, + ], + body: { size: 40201, attachmentId: "att-pdf-1" }, + }, + ], + }), + }) + ); + + const actions = actionsOf(link); + expect(actions).toHaveLength(1); + expect(actions[0].fileName).toBe("report.pdf"); + expect(actions[0].contentId ?? null).toBeNull(); + }); + + it("keeps an inline image when only a plain-text body survived", () => { + // Nothing to match a `cid:` against, so the part can't be shown to be + // orphaned — keep it rather than silently discard an attachment. + const link = transformGmailThread( + thread({ + from: "Robin ", + to: "me@example.com", + subject: "Re: Query", + payload: part("multipart/related", { + parts: [ + part("text/plain", { data: "Brilliant! Thank you." }), + { + mimeType: "image/jpeg", + filename: "image001.jpg", + headers: [ + { + name: "Content-Disposition", + value: 'inline; filename="image001.jpg"', + }, + { name: "Content-ID", value: "" }, + ], + body: { size: 823, attachmentId: "att-inline-1" }, + }, + ], + }), + }) + ); + + expect(actionsOf(link)).toHaveLength(1); + }); +}); + diff --git a/connectors/google/src/mail/gmail-api.ts b/connectors/google/src/mail/gmail-api.ts index a1349797..b8b18cf5 100644 --- a/connectors/google/src/mail/gmail-api.ts +++ b/connectors/google/src/mail/gmail-api.ts @@ -9,7 +9,11 @@ import type { } from "@plotday/twister/plot"; import { markdownToPlainText } from "@plotday/twister/utils/markdown"; import { markdownToHtml } from "@plotday/twister/utils/markdown-html"; -import { isNoReplySender } from "@plotday/twister/signals"; +import { + isNoReplySender, + normalizeContentId, + referencedContentIds, +} from "@plotday/twister/signals"; import { icsProp, parseIcsReply } from "@plotday/rsvp-fold"; @@ -1161,9 +1165,23 @@ function isForwardedMessage(content: string): boolean { return true; } +/** A part's header value by name (case-insensitive), or null. */ +function partHeader(part: GmailMessagePart, name: string): string | null { + const lower = name.toLowerCase(); + return ( + (part.headers ?? []).find((h) => h.name.toLowerCase() === lower)?.value ?? + null + ); +} + /** * Recursively collects attachment parts from a Gmail message payload. * An attachment part has a non-empty filename and a body.attachmentId. + * + * `contentId` and `inline` describe how the part was carried, so callers can + * tell a real attachment from an image the HTML body embeds via `cid:`. A + * `multipart/related` image routinely omits `Content-Disposition` altogether, + * so a bare `Content-ID` counts as inline too. */ export function collectAttachments( part: GmailMessagePart | undefined @@ -1172,19 +1190,29 @@ export function collectAttachments( fileName: string; fileSize: number | null; mimeType: string; + contentId: string | null; + inline: boolean; }> { if (!part) return []; - const here = - part.filename && part.body?.attachmentId - ? [ - { - partId: part.body.attachmentId, - fileName: part.filename, - fileSize: part.body?.size ?? null, - mimeType: part.mimeType ?? "application/octet-stream", - }, - ] - : []; + let here: ReturnType = []; + if (part.filename && part.body?.attachmentId) { + // RFC 2392: the `cid:` URL names the Content-ID without its angle + // brackets, so strip them here rather than at every comparison site. + const contentId = normalizeContentId(partHeader(part, "Content-ID")); + const disposition = partHeader(part, "Content-Disposition"); + here = [ + { + partId: part.body.attachmentId, + fileName: part.filename, + fileSize: part.body?.size ?? null, + mimeType: part.mimeType ?? "application/octet-stream", + contentId, + inline: disposition + ? /^\s*inline\b/i.test(disposition) + : contentId !== null, + }, + ]; + } const children = (part.parts ?? []).flatMap(collectAttachments); return [...here, ...children]; } @@ -1310,14 +1338,32 @@ export function transformGmailThread(thread: GmailThread): NewLinkWithNotes { const content = body || message.snippet; - // Build fileRef actions for each attachment part - const actions: Action[] = attachmentParts.map((a) => ({ - type: ActionType.fileRef as ActionType.fileRef, - ref: `${message.id}:${a.partId}`, - fileName: a.fileName, - fileSize: a.fileSize, - mimeType: a.mimeType, - })); + // Build fileRef actions for each attachment part. Inline images are + // classified against the body we KEPT: one the retained content still + // references is tagged with its Content-ID so Plot renders it in place, + // while one whose only reference was trimmed away with the quoted history + // is dropped. Only meaningful for an HTML body we actually kept — a + // plain-text body carries no `cid:` references to match against, so there + // the parts stay attachments rather than being silently discarded. + const referenced = + contentType === "html" && body.trim() ? referencedContentIds(body) : null; + const actions: Action[] = []; + for (const a of attachmentParts) { + const inlineId = a.inline && a.contentId ? a.contentId : null; + const embedded = + inlineId !== null && + referenced !== null && + referenced.has(inlineId.toLowerCase()); + if (inlineId !== null && referenced !== null && !embedded) continue; + actions.push({ + type: ActionType.fileRef as ActionType.fileRef, + ref: `${message.id}:${a.partId}`, + fileName: a.fileName, + fileSize: a.fileSize, + mimeType: a.mimeType, + ...(embedded ? { contentId: inlineId } : {}), + }); + } // Note author (sender) and per-message recipients for visibility. // source is populated so the DM recipient picker can resolve Gmail contacts. diff --git a/connectors/outlook/src/mail/graph-mail-api.test.ts b/connectors/outlook/src/mail/graph-mail-api.test.ts index 93e2fe3a..e39260d7 100644 --- a/connectors/outlook/src/mail/graph-mail-api.test.ts +++ b/connectors/outlook/src/mail/graph-mail-api.test.ts @@ -102,7 +102,7 @@ describe("transformOutlookConversation", () => { expect(team?.name).toBeUndefined(); }); - it("emits fileRef actions for non-inline file attachments only", () => { + it("emits fileRef actions for file attachments, dropping unreferenced inline images", () => { const atts = new Map([ [ "id-1", @@ -144,6 +144,54 @@ describe("transformOutlookConversation", () => { expect(actions).toHaveLength(1); expect(actions[0].ref).toBe("id-1:a1"); }); + + /** One inline image attachment carrying `contentId`, as Graph reports it. */ + const inlineImage = (contentId: string): GraphAttachmentMeta => ({ + id: "a-inline", + name: "image001.jpg", + contentType: "image/jpeg", + size: 823, + isInline: true, + odataType: "#microsoft.graph.fileAttachment", + contentId, + }); + + function inlineActions(html: string, contentId = "ii_abc123") { + const link = transformOutlookConversation({ + ...base, + attachmentsByMessageId: new Map([["id-1", [inlineImage(contentId)]]]), + messages: [ + msg({ + hasAttachments: true, + body: { contentType: "html", content: html }, + }), + ], + }); + return ( + link.notes![0] as { + actions: Array<{ ref: string; contentId?: string | null }> | null; + } + ).actions; + } + + it("keeps an inline image the retained body references, tagged with its Content-ID", () => { + const actions = inlineActions( + `

Here it is:

` + ); + expect(actions).toHaveLength(1); + expect(actions![0].ref).toBe("id-1:a-inline"); + expect(actions![0].contentId).toBe("ii_abc123"); + }); + + it("drops an inline image whose only reference was in the trimmed quote", () => { + // `stripQuotedReply` cuts at the reply header block, so the signature logo + // below it is gone from the body the reader sees. + const actions = inlineActions( + `

Thanks!

` + + `

Regards, Robin

` + ); + expect(actions ?? []).toHaveLength(0); + }); }); describe("conversation state helpers", () => { diff --git a/connectors/outlook/src/mail/graph-mail-api.ts b/connectors/outlook/src/mail/graph-mail-api.ts index 1799f623..6a137f72 100644 --- a/connectors/outlook/src/mail/graph-mail-api.ts +++ b/connectors/outlook/src/mail/graph-mail-api.ts @@ -6,7 +6,11 @@ import type { NewContact, NewLinkWithNotes, } from "@plotday/twister/plot"; -import { isNoReplySender } from "@plotday/twister/signals"; +import { + isNoReplySender, + normalizeContentId, + referencedContentIds, +} from "@plotday/twister/signals"; import type { RsvpReply } from "@plotday/rsvp-fold"; import { stripQuotedReply } from "./email-parsing"; @@ -63,6 +67,12 @@ export type GraphAttachmentMeta = { isInline: boolean; /** "#microsoft.graph.fileAttachment" | itemAttachment | referenceAttachment */ odataType: string; + /** + * Content-ID of an inline part, without angle brackets — what the body's + * `cid:` reference names. Null for ordinary attachments, and for inline + * parts Graph reports without one. + */ + contentId?: string | null; }; /** Well-known folder name → folder id map (only the ones we care about). */ @@ -402,7 +412,7 @@ export class GraphMailApi { const data = (await this.call( "GET", `${GRAPH}/me/messages/${encodeURIComponent(messageId)}/attachments`, - { $select: "id,name,contentType,size,isInline" } + { $select: "id,name,contentType,size,isInline,contentId" } )) as { value?: Array> } | null; return ((data?.value ?? []) as Array>).map((a) => ({ id: a.id as string, @@ -411,6 +421,9 @@ export class GraphMailApi { size: (a.size as number | undefined) ?? null, isInline: (a.isInline as boolean | undefined) ?? false, odataType: (a["@odata.type"] as string | undefined) ?? "", + contentId: normalizeContentId( + (a.contentId as string | undefined) ?? null + ), })); } @@ -823,20 +836,35 @@ export function transformOutlookConversation(opts: { const body = stripQuotedReply(message.body?.content ?? "", contentType); const content = body || message.bodyPreview || ""; - const actions: Action[] = ( - opts.attachmentsByMessageId.get(message.id) ?? [] - ) - .filter( - (a) => - !a.isInline && a.odataType === "#microsoft.graph.fileAttachment" - ) - .map((a) => ({ + // Inline parts are classified against the body we KEPT: one the retained + // content still references is tagged with its Content-ID so Plot renders it + // in place, while one whose only reference was trimmed away with the quoted + // history is dropped. Only meaningful for an HTML body we actually kept — + // a plain-text body carries no `cid:` references to match against, so there + // inline parts stay attachments rather than being silently discarded. + const referenced = + contentType === "html" && body.trim() ? referencedContentIds(body) : null; + const actions: Action[] = []; + for (const a of opts.attachmentsByMessageId.get(message.id) ?? []) { + if (a.odataType !== "#microsoft.graph.fileAttachment") continue; + const inlineId = a.isInline && a.contentId ? a.contentId : null; + const embedded = + inlineId !== null && + referenced !== null && + referenced.has(inlineId.toLowerCase()); + // An inline part Graph reports without a contentId can never be matched + // to the body, so it is treated as an orphan whenever we have a body to + // check — the same conclusion the old blanket `!isInline` filter reached. + if (a.isInline && referenced !== null && !embedded) continue; + actions.push({ type: ActionType.fileRef as ActionType.fileRef, ref: `${message.id}:${a.id}`, fileName: a.name, fileSize: a.size, mimeType: a.contentType ?? "application/octet-stream", - })); + ...(embedded ? { contentId: inlineId } : {}), + }); + } const senderActor: NewActor = { email: fromAddress, diff --git a/twister/src/plot.ts b/twister/src/plot.ts index f2853e88..5c8f0105 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -321,6 +321,21 @@ export type Action = imageWidth?: number | null; /** Intrinsic height of the image in pixels (only for image files) */ imageHeight?: number | null; + /** + * Content-ID of an inline body image, without the angle brackets — the + * value a `cid:` reference in the note's content resolves against. + * + * Set this ONLY when the note's content still references the part (an + * `` that survived quote-trimming). Plot then renders + * the image in the body where the sender placed it, at its own size, + * instead of appending an attachment chip. + * + * Leave it unset for ordinary attachments. An inline part the retained + * content no longer references — a signature logo left behind when a + * quoted reply was trimmed — should be dropped entirely rather than + * emitted with a `contentId`, since nothing in the body points at it. + */ + contentId?: string | null; } | { /** Thread reference action for navigating to a related thread */ diff --git a/twister/src/signals.ts b/twister/src/signals.ts index b5ba2ba7..dc65fbbe 100644 --- a/twister/src/signals.ts +++ b/twister/src/signals.ts @@ -102,3 +102,53 @@ function localPart(address: string | null): string { export function isNoReplySender(address: string | null): boolean { return NOREPLY_LOCALPART.test(localPart(address)); } + +/** + * Content-IDs an HTML mail body still points at, lowercased for comparison. + * + * Mail connectors use this to classify a message's inline image parts against + * the body they actually kept, after quoted history has been trimmed away. + * Three outcomes follow: + * + * - Referenced → set the fileRef action's `contentId` so Plot renders the + * image in the body where the sender placed it. + * - Not referenced → the part is an orphan. Its only reference lived in the + * quoted history that was trimmed, which is what happens to a sender's + * signature logo on every reply in a chain. Drop it: there is nowhere to + * render it, and surfacing it as an attachment puts a chip on every message + * for an image the reader never saw. + * - No HTML body to test against (a plain-text message) → skip the check + * entirely rather than treat every inline part as an orphan. + * + * Values are compared lowercased because clients are inconsistent about the + * case they echo a Content-ID back in. + */ +export function referencedContentIds(html: string): Set { + const ids = new Set(); + // The src of `` in any quoting style, stopping at whatever + // terminates the URL. + const re = /\bcid:([^"'\s>)]+)/gi; + let match: RegExpExecArray | null; + while ((match = re.exec(html)) !== null) { + const raw = match[1]; + let decoded = raw; + try { + // RFC 2392 percent-encodes characters that are special in a URL. + decoded = decodeURIComponent(raw); + } catch { + // Malformed escape — compare the raw value instead. + } + ids.add(decoded.toLowerCase()); + } + return ids; +} + +/** + * Strips the angle brackets RFC 2822 wraps a `Content-ID` header in, leaving + * the bare value a `cid:` URL names (RFC 2392). Returns null for a missing or + * empty header. + */ +export function normalizeContentId(header: string | null): string | null { + if (!header) return null; + return header.trim().replace(/^<|>$/g, "") || null; +} diff --git a/twister/src/tools/imap.ts b/twister/src/tools/imap.ts index 337b3a60..e48a18d3 100644 --- a/twister/src/tools/imap.ts +++ b/twister/src/tools/imap.ts @@ -143,8 +143,22 @@ export type ImapMessage = { * `partNumber` is the IMAP part number (e.g. "2" or "2.1") used to fetch that * part's content separately, and `encoding` is the part's own * Content-Transfer-Encoding. + * + * `contentId` (angle brackets stripped) and `inline` describe how the part + * was carried, so a body image the HTML embeds via `cid:` can be told apart + * from a genuine attachment. A part sent without an explicit + * `Content-Disposition` but with a `Content-ID` counts as inline — that is + * how `multipart/related` images usually arrive. */ - attachments?: { partNumber: string; fileName: string; mimeType: string; size: number; encoding: string }[]; + attachments?: { + partNumber: string; + fileName: string; + mimeType: string; + size: number; + encoding: string; + contentId?: string | null; + inline?: boolean; + }[]; }; /** Options for fetchMessages(). */