Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/fileref-content-id.md
Original file line numberDiff line numberDiff line change
@@ -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.
49 changes: 49 additions & 0 deletions connectors/apple/src/mail/transform.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: `<p>Here it is:</p><img src="cid:ii_abc123">`,
attachments: [inlinePart("ii_abc123")],
});
const note = transform([m])[0].notes![0] as unknown as {
actions?: Array<ActionLike & { contentId?: string | null }>;
};
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: `<p>No image here.</p>`,
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<ActionLike & { contentId?: string | null }>;
};
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,
Expand Down
31 changes: 27 additions & 4 deletions connectors/apple/src/mail/transform.ts
Original file line numberDiff line numberDiff line change
@@ -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";
Expand DownExpand Up@@ -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;
}

Expand Down
137 changes: 137 additions & 0 deletions connectors/google/src/mail/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <robin@example.com>",
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<typeof transformGmailThread>) {
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(
`<div dir="ltr">Brilliant! Thank you.</div>` +
`<div class="gmail_quote gmail_quote_container">` +
`<blockquote class="gmail_quote"><p>Robin Fisher</p>` +
`<img src="cid:ii_abc123" width="169" height="43" ` +
`alt="Image removed by sender."></blockquote></div>`
)
);

expect(actionsOf(link)).toEqual([]);
});

it("keeps an inline image the retained body references, tagged with its Content-ID", () => {
const link = transformGmailThread(
relatedThread(
`<div dir="ltr">Here is the chart:<br>` +
`<img src="cid:ii_abc123" width="600" height="400"></div>`
)
);

expect(actionsOf(link)).toEqual([
expect.objectContaining({
fileName: "image001.jpg",
contentId: "ii_abc123",
}),
]);
});

it("leaves an ordinary attachment untagged", () => {
const link = transformGmailThread(
thread({
from: "Robin <robin@example.com>",
to: "me@example.com",
subject: "Report",
payload: part("multipart/mixed", {
parts: [
part("text/html", { data: "<div>See attached.</div>" }),
{
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 <robin@example.com>",
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: "<ii_abc123>" },
],
body: { size: 823, attachmentId: "att-inline-1" },
},
],
}),
})
);

expect(actionsOf(link)).toHaveLength(1);
});
});

86 changes: 66 additions & 20 deletions connectors/google/src/mail/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";


Expand DownExpand Up@@ -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
Expand All@@ -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<typeof collectAttachments> = [];
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];
}
Expand DownExpand Up@@ -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.
Expand Down
Loading
Loading