mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
feat: tell plugins the Content-ID and alt text of a message's inline images
EmailReadView.attachments gain cid and alt for images the HTML shows inline. plugin-projection.test.ts now expects the cid on its inline logo, since the view carries it.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Email } from '../jmap/types';
|
||||
import { emailToReadView } from '../plugin-projection';
|
||||
import { cidAltTexts, normaliseCid } from '../plugin-sandbox/assistant/b.projection';
|
||||
|
||||
// An inline image of a message names its Content-ID and the alt text the
|
||||
// sender wrote for it, so the assistant can name the picture.
|
||||
|
||||
const html = [
|
||||
'<p>Hi Alice, here is the logo idea:</p>',
|
||||
'<p><img width="240" src="cid:logo1@aa" alt="Orange logo with a stripe"></p>',
|
||||
"<img src='CID:chart%40aa' title='Sales & costs'>",
|
||||
'<img src="cid:plain@aa" alt="">',
|
||||
'<img src="https://tracker.example.com/p.gif" alt="tracker" width="1" height="1">',
|
||||
].join('');
|
||||
|
||||
const email = {
|
||||
id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: {}, subject: 'Logo', receivedAt: '2026-05-01T10:00:00Z',
|
||||
htmlBody: [{ partId: '1', blobId: 'b-html', size: 1, type: 'text/html' }],
|
||||
bodyValues: { 1: { value: html } },
|
||||
attachments: [
|
||||
{ partId: '2', blobId: 'blob-logo', name: 'logo.png', type: 'image/png', size: 10, disposition: 'inline', cid: '<logo1@aa>' },
|
||||
{ partId: '3', blobId: 'blob-chart', name: 'chart.png', type: 'image/png', size: 10, cid: 'chart@aa' },
|
||||
{ partId: '4', blobId: 'blob-plain', name: 'plain.png', type: 'image/png', size: 10, cid: 'plain@aa' },
|
||||
{ partId: '5', blobId: 'blob-pdf', name: 'agenda.pdf', type: 'application/pdf', size: 10, disposition: 'attachment' },
|
||||
],
|
||||
} as unknown as Email;
|
||||
|
||||
describe('cid and alt on EmailReadView.attachments', () => {
|
||||
it('gives inline images their Content-ID and alt text, and leaves plain attachments alone', () => {
|
||||
const view = emailToReadView(email);
|
||||
expect(view.attachments).toEqual([
|
||||
{ blobId: 'blob-logo', name: 'logo.png', type: 'image/png', size: 10, inline: true, cid: 'logo1@aa', alt: 'Orange logo with a stripe' },
|
||||
{ blobId: 'blob-chart', name: 'chart.png', type: 'image/png', size: 10, inline: true, cid: 'chart@aa', alt: 'Sales & costs' },
|
||||
{ blobId: 'blob-plain', name: 'plain.png', type: 'image/png', size: 10, inline: true, cid: 'plain@aa' },
|
||||
{ blobId: 'blob-pdf', name: 'agenda.pdf', type: 'application/pdf', size: 10, inline: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads alt texts only of cid images, never of remote ones', () => {
|
||||
expect([...cidAltTexts(email).entries()]).toEqual([['logo1@aa', 'Orange logo with a stripe'], ['chart@aa', 'Sales & costs']]);
|
||||
expect(normaliseCid('<Logo1%40AA>')).toBe('logo1@aa');
|
||||
});
|
||||
|
||||
it('copes with a message without HTML', () => {
|
||||
const plain = { ...email, htmlBody: [], bodyValues: {} } as unknown as Email;
|
||||
expect(emailToReadView(plain).attachments[0]).toEqual({ blobId: 'blob-logo', name: 'logo.png', type: 'image/png', size: 10, inline: true, cid: 'logo1@aa' });
|
||||
});
|
||||
});
|
||||
@@ -57,7 +57,7 @@ describe('emailToReadView attachments', () => {
|
||||
|
||||
expect(view.attachments).toEqual([
|
||||
{ blobId: 'b-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234, inline: false },
|
||||
{ blobId: 'b-img', name: 'logo.png', type: 'image/png', size: 99, inline: true },
|
||||
{ blobId: 'b-img', name: 'logo.png', type: 'image/png', size: 99, inline: true, cid: 'logo@x' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import { generateEmailSource } from '@/lib/email-source';
|
||||
import { isServerSetKeyword } from '@/lib/jmap/server-keywords';
|
||||
import { attachmentExtras, cidAltTexts } from '@/lib/plugin-sandbox/assistant/b.projection';
|
||||
|
||||
// Resolve a message's plain-text body from its JMAP body parts. Plugins that
|
||||
// translate or scan content need the real body, not just the short `preview`
|
||||
@@ -52,6 +53,7 @@ export function plainTextFromEmail(email: Email): string {
|
||||
* carry it, others only the caller knows (see EmailReadView.accountId).
|
||||
*/
|
||||
export function emailToReadView(email: Email, accountId = email.sourceAccountId): EmailReadView {
|
||||
const alts = cidAltTexts(email);
|
||||
return {
|
||||
id: email.id,
|
||||
...(accountId ? { accountId } : {}),
|
||||
@@ -73,6 +75,7 @@ export function emailToReadView(email: Email, accountId = email.sourceAccountId)
|
||||
type: part.type || 'application/octet-stream',
|
||||
size: part.size || 0,
|
||||
inline: part.disposition === 'inline' || !!part.cid,
|
||||
...attachmentExtras(part, alts),
|
||||
})),
|
||||
preview: email.preview || '',
|
||||
text: plainTextFromEmail(email),
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Area b of the assistant's host APIs: what the message view adds to each
|
||||
// attachment. An image the HTML shows inline (`<img src="cid:…">`) gets its
|
||||
// Content-ID and the alt text the sender wrote for it, so the assistant can
|
||||
// name a picture without looking at it.
|
||||
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import type { AttachmentExtrasB } from './b.types';
|
||||
|
||||
const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
||||
|
||||
function decodeEntities(value: string): string {
|
||||
return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, name: string) => {
|
||||
if (name[0] === '#') {
|
||||
const code = name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
|
||||
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : whole;
|
||||
}
|
||||
return ENTITIES[name.toLowerCase()] ?? whole;
|
||||
});
|
||||
}
|
||||
|
||||
/** The value of one attribute of a tag's source, or null. */
|
||||
function attribute(tag: string, name: string): string | null {
|
||||
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(tag);
|
||||
if (!m) return null;
|
||||
return decodeEntities(m[1] ?? m[2] ?? m[3] ?? '');
|
||||
}
|
||||
|
||||
/** "<logo1@aa>", "logo1@aa", "cid:logo1%40aa" → "logo1@aa" (lower case, for comparing). */
|
||||
export function normaliseCid(value: string): string {
|
||||
let id = value.trim().replace(/^cid:/i, '').replace(/^<|>$/g, '');
|
||||
try { id = decodeURIComponent(id); } catch { /* keep it as written */ }
|
||||
return id.toLowerCase();
|
||||
}
|
||||
|
||||
/** Content-ID → alt text, from the `<img src="cid:…" alt="…">` tags of the message's HTML. */
|
||||
export function cidAltTexts(email: Pick<Email, 'htmlBody' | 'bodyValues'>): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
const values = email.bodyValues ?? {};
|
||||
for (const part of email.htmlBody ?? []) {
|
||||
const html = values[part.partId]?.value;
|
||||
if (typeof html !== 'string') continue;
|
||||
for (const [tag] of html.matchAll(/<img\b[^>]*>/gi)) {
|
||||
const src = attribute(tag, 'src');
|
||||
if (!src || !/^cid:/i.test(src.trim())) continue;
|
||||
const alt = (attribute(tag, 'alt') ?? attribute(tag, 'title') ?? '').replace(/\s+/g, ' ').trim();
|
||||
const cid = normaliseCid(src);
|
||||
if (alt && !out.has(cid)) out.set(cid, alt.slice(0, 500));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `cid` and `alt` of one attachment, for EmailReadView.attachments (empty for a plain attachment). */
|
||||
export function attachmentExtras(part: { cid?: string | null }, alts: Map<string, string>): AttachmentExtrasB {
|
||||
if (!part.cid) return {};
|
||||
const cid = part.cid.replace(/^<|>$/g, '');
|
||||
const alt = alts.get(normaliseCid(cid));
|
||||
return { cid, ...(alt ? { alt } : {}) };
|
||||
}
|
||||
@@ -10,6 +10,14 @@ export interface AreaHostFeaturesB {
|
||||
attachmentText?: boolean;
|
||||
}
|
||||
|
||||
/** What EmailReadView.attachments adds for an image the message shows inline. */
|
||||
export interface AttachmentExtrasB {
|
||||
/** The Content-ID the message's HTML refers to (`<img src="cid:…">`), without angle brackets. */
|
||||
cid?: string;
|
||||
/** The alt text of that image in the HTML, when the sender wrote one. */
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What api.email.getAttachmentText answers. `status`: ok (`text` holds the
|
||||
* text), too_big (over 20 MB, or an Office file that unpacks to more than
|
||||
|
||||
+4
-2
@@ -1,6 +1,7 @@
|
||||
import type { CredentialSpec } from './plugin-secrets/credentials';
|
||||
import type { CatalogSlotName } from './plugin-slot-catalog';
|
||||
import { ASSISTANT_PERMISSIONS, type AssistantHostFeatures } from './plugin-sandbox/assistant/types';
|
||||
import type { AttachmentExtrasB } from './plugin-sandbox/assistant/b.types';
|
||||
|
||||
// Plugin & Theme system types
|
||||
|
||||
@@ -419,9 +420,10 @@ export interface EmailReadView {
|
||||
/**
|
||||
* The message's attachments. `blobId` is what `email.getAttachment`
|
||||
* takes. Inline images are included and marked. Empty until the host has
|
||||
* loaded the message's body structure.
|
||||
* loaded the message's body structure. An image the HTML shows inline
|
||||
* also has its `cid` and, when the sender wrote one, its `alt` text.
|
||||
*/
|
||||
attachments: { blobId: string; name: string; type: string; size: number; inline: boolean }[];
|
||||
attachments: ({ blobId: string; name: string; type: string; size: number; inline: boolean } & AttachmentExtrasB)[];
|
||||
preview: string;
|
||||
keywords: string[];
|
||||
/** Full plain-text body of the message (HTML-only bodies are stripped to
|
||||
|
||||
Reference in New Issue
Block a user