fix(attachments): render inline cid: images in emails and feeds
Capture each attachment's Content-ID at ingestion (postal-mime and mailparser paths) and rewrite cid: image refs to the stored /files URL in processEmailContent, shared by the entry view and RSS/Atom feeds. Bodyless HTML fragments are now serialized so sanitization and the cid rewrite apply to them too. Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
6cd2d425a2
commit
debbfc623e
9 files changed
+187
-7
No files matched your search
@@ -55,7 +55,11 @@ function buildFeed(
|
||||
for (const email of emails) {
|
||||
const entryUrl = `${baseUrl}/entries/${feedId}/${email.receivedAt}`;
|
||||
const firstAttachment = email.attachments?.[0];
|
||||
const bodyContent = processEmailContent(email.content);
|
||||
const bodyContent = processEmailContent(
|
||||
email.content,
|
||||
email.attachments,
|
||||
baseUrl,
|
||||
);
|
||||
feed.addItem({
|
||||
title: email.subject,
|
||||
id: entryUrl,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { processEmailContent } from "./html-processor";
|
||||
import type { AttachmentData } from "../types";
|
||||
|
||||
describe("processEmailContent — body extraction", () => {
|
||||
it("extracts content inside <body> tags", () => {
|
||||
@@ -123,3 +124,75 @@ describe("processEmailContent — mso style cleanup", () => {
|
||||
expect(result).not.toContain("mso-font-size");
|
||||
});
|
||||
});
|
||||
|
||||
describe("processEmailContent — inline cid: rewriting", () => {
|
||||
const attachment = (
|
||||
overrides: Partial<AttachmentData> = {},
|
||||
): AttachmentData => ({
|
||||
id: "att-123",
|
||||
filename: "chicken big.png",
|
||||
contentType: "image/png",
|
||||
size: 100,
|
||||
contentId: "ii_mpi85rqy0",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("rewrites cid: src to a relative /files URL when no baseUrl", () => {
|
||||
const html = '<body><img src="cid:ii_mpi85rqy0" alt="x"/></body>';
|
||||
const result = processEmailContent(html, [attachment()]);
|
||||
expect(result).toContain('src="/files/att-123/chicken%20big.png"');
|
||||
expect(result).not.toContain("cid:");
|
||||
});
|
||||
|
||||
it("rewrites cid: src to an absolute URL when baseUrl is given", () => {
|
||||
const html = '<body><img src="cid:ii_mpi85rqy0"/></body>';
|
||||
const result = processEmailContent(
|
||||
html,
|
||||
[attachment()],
|
||||
"https://feed.example",
|
||||
);
|
||||
expect(result).toContain(
|
||||
'src="https://feed.example/files/att-123/chicken%20big.png"',
|
||||
);
|
||||
});
|
||||
|
||||
it("matches a stored Content-ID that has angle brackets", () => {
|
||||
const html = '<body><img src="cid:ii_mpi85rqy0"/></body>';
|
||||
const result = processEmailContent(html, [
|
||||
attachment({ contentId: "<ii_mpi85rqy0>" }),
|
||||
]);
|
||||
expect(result).toContain('src="/files/att-123/chicken%20big.png"');
|
||||
});
|
||||
|
||||
it("is case-insensitive on the cid: scheme", () => {
|
||||
const html = '<body><img src="CID:ii_mpi85rqy0"/></body>';
|
||||
const result = processEmailContent(html, [attachment()]);
|
||||
expect(result).toContain('src="/files/att-123/chicken%20big.png"');
|
||||
});
|
||||
|
||||
it("leaves unknown cid references unchanged", () => {
|
||||
const html = '<body><img src="cid:unknown"/></body>';
|
||||
const result = processEmailContent(html, [attachment()]);
|
||||
expect(result).toContain('src="cid:unknown"');
|
||||
});
|
||||
|
||||
it("leaves cid references unchanged when no attachments are provided", () => {
|
||||
const html = '<body><img src="cid:ii_mpi85rqy0"/></body>';
|
||||
const result = processEmailContent(html);
|
||||
expect(result).toContain('src="cid:ii_mpi85rqy0"');
|
||||
});
|
||||
|
||||
it("ignores attachments without a contentId", () => {
|
||||
const html = '<body><img src="cid:ii_mpi85rqy0"/></body>';
|
||||
const result = processEmailContent(html, [
|
||||
attachment({ contentId: undefined }),
|
||||
]);
|
||||
expect(result).toContain('src="cid:ii_mpi85rqy0"');
|
||||
});
|
||||
|
||||
it("does not touch normal http image sources", () => {
|
||||
const html = '<body><img src="https://example.com/a.png"/></body>';
|
||||
const result = processEmailContent(html, [attachment()]);
|
||||
expect(result).toContain('src="https://example.com/a.png"');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,16 @@
|
||||
import { parseHTML } from "linkedom";
|
||||
import escapeHtml from "escape-html";
|
||||
import { AttachmentData } from "../types";
|
||||
|
||||
// Strip surrounding angle brackets and whitespace from a Content-ID so that a
|
||||
// stored value like "<ii_mpi85rqy0>" matches an HTML reference "cid:ii_mpi85rqy0".
|
||||
export function normalizeCid(
|
||||
cid: string | null | undefined,
|
||||
): string | undefined {
|
||||
if (!cid) return undefined;
|
||||
const trimmed = cid.trim().replace(/^<|>$/g, "").trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function cleanMsoStyles(style: string): string {
|
||||
return style
|
||||
@@ -13,7 +24,27 @@ function isPlainText(content: string): boolean {
|
||||
return !/<[a-z][\s\S]*>/i.test(content);
|
||||
}
|
||||
|
||||
function sanitizeElement(el: Element): void {
|
||||
function rewriteCidSrc(
|
||||
el: Element,
|
||||
cidMap: Map<string, AttachmentData>,
|
||||
baseUrl: string,
|
||||
): void {
|
||||
const src = el.getAttribute("src") ?? "";
|
||||
const match = src.match(/^\s*cid:(.+)$/i);
|
||||
if (!match) return;
|
||||
const attachment = cidMap.get(normalizeCid(match[1]) ?? "");
|
||||
if (!attachment) return;
|
||||
el.setAttribute(
|
||||
"src",
|
||||
`${baseUrl}/files/${attachment.id}/${encodeURIComponent(attachment.filename)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeElement(
|
||||
el: Element,
|
||||
cidMap: Map<string, AttachmentData>,
|
||||
baseUrl: string,
|
||||
): void {
|
||||
// Snapshot attribute names before mutating (linkedom attributes is array-like)
|
||||
const attrs = Array.from(
|
||||
el.attributes as unknown as ArrayLike<{ name: string }>,
|
||||
@@ -33,6 +64,9 @@ function sanitizeElement(el: Element): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cidMap.size > 0) {
|
||||
rewriteCidSrc(el, cidMap, baseUrl);
|
||||
}
|
||||
// Strip mso-* inline style properties (Office HTML noise)
|
||||
const style = el.getAttribute("style");
|
||||
if (style !== null) {
|
||||
@@ -52,22 +86,39 @@ function sanitizeElement(el: Element): void {
|
||||
* - Removes dangerous elements: <script>, <iframe>, <object>, <embed>
|
||||
* - Removes event handler attributes and javascript: URLs
|
||||
* - Strips mso-* inline style properties (Office HTML)
|
||||
* - Rewrites inline cid: image refs to the stored attachment URL. baseUrl=""
|
||||
* yields relative URLs (entry page, same origin); a baseUrl yields absolute
|
||||
* URLs (feeds, for external RSS readers).
|
||||
*/
|
||||
export function processEmailContent(content: string): string {
|
||||
export function processEmailContent(
|
||||
content: string,
|
||||
attachments?: AttachmentData[],
|
||||
baseUrl = "",
|
||||
): string {
|
||||
if (!content) return "";
|
||||
|
||||
if (isPlainText(content)) {
|
||||
return `<pre style="white-space: pre-wrap; word-break: break-word;">${escapeHtml(content)}</pre>`;
|
||||
}
|
||||
|
||||
const cidMap = new Map<string, AttachmentData>();
|
||||
for (const att of attachments ?? []) {
|
||||
const cid = normalizeCid(att.contentId);
|
||||
if (cid) cidMap.set(cid, att);
|
||||
}
|
||||
|
||||
const { document } = parseHTML(content);
|
||||
|
||||
document
|
||||
.querySelectorAll("script, object, embed, iframe, frame, frameset")
|
||||
.forEach((el: Element) => el.remove());
|
||||
|
||||
document.querySelectorAll("*").forEach((el: Element) => sanitizeElement(el));
|
||||
document
|
||||
.querySelectorAll("*")
|
||||
.forEach((el: Element) => sanitizeElement(el, cidMap, baseUrl));
|
||||
|
||||
// Full documents expose a <body>; bodyless fragments are serialized directly
|
||||
// so that sanitization and cid rewriting still apply to their nodes.
|
||||
const body = document.querySelector("body");
|
||||
return body ? body.innerHTML : content;
|
||||
return body ? body.innerHTML : document.toString();
|
||||
}
|
||||
Reference in new issue
Block a user