From 039ed0598b206e31ba4f58bdbeaf7cfa84e01964 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:23:20 +0100 Subject: [PATCH] fix: make Forward as attachment, Export as .eml and View source work in the demo --- lib/__tests__/demo-client-eml.test.ts | 32 ++++++++ lib/demo/demo-client.ts | 25 ++++-- lib/demo/demo-eml.ts | 110 ++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 lib/__tests__/demo-client-eml.test.ts create mode 100644 lib/demo/demo-eml.ts diff --git a/lib/__tests__/demo-client-eml.test.ts b/lib/__tests__/demo-client-eml.test.ts new file mode 100644 index 000000000..d6413e10b --- /dev/null +++ b/lib/__tests__/demo-client-eml.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { DemoJMAPClient } from '@/lib/demo/demo-client'; + +// Export as .eml, View source and Forward as attachment need the message's +// raw blob. The demo has no server to keep one, so it builds the .eml from +// the message; without a blob id those entries were greyed out in the demo. +describe('DemoJMAPClient raw messages', () => { + it('gives every fixture message a blob that holds its .eml', async () => { + const client = new DemoJMAPClient(); + const { emails } = await client.getEmails(undefined, undefined, 500); + expect(emails.length).toBeGreaterThan(0); + expect(emails.every((e) => !!e.blobId)).toBe(true); + + const email = emails.find((e) => e.from?.[0]?.name && e.subject)!; + const blob = await client.fetchBlob(email.blobId!); + expect(blob.type).toBe('message/rfc822'); + const eml = await blob.text(); + expect(eml).toContain(`Subject: ${email.subject}`); + expect(eml).toContain(`<${email.from![0].email}>`); + expect(eml).toContain('MIME-Version: 1.0'); + // Headers and body are split by an empty line. + expect(eml).toMatch(/\r\n\r\n/); + }); + + it('gives a saved draft a blob too', async () => { + const client = new DemoJMAPClient(); + const id = await client.createDraft(['a@example.com'], 'Draft for export', 'Body text'); + const draft = await client.getEmail(id); + expect(draft?.blobId).toBeTruthy(); + expect(await (await client.fetchBlob(draft!.blobId!)).text()).toContain('Subject: Draft for export'); + }); +}); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 44bb9e3d7..ec3374cc6 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -12,6 +12,7 @@ import { compareEmails, type SortLevel } from '@/lib/message-list-order'; import { withBasePath } from '@/lib/browser-navigation'; import { toInertBlob } from '@/lib/file-preview'; import type { StorageQuota } from "@/lib/quota"; +import { buildDemoEml, demoEmlBlobId, demoEmlEmailId } from './demo-eml'; // Fixture blobs with real bytes behind them, served from public/demo/ (see // fixtures/assets.ts for the list and the licences). Any other blob id falls @@ -20,6 +21,12 @@ const DEMO_BLOB_ASSETS: Record = Object.fromEntries( Object.entries(DEMO_ASSETS).map(([blobId, asset]) => [blobId, asset.path]), ); +/** Gives a demo message a raw-message blob (see demo-eml.ts) unless it has one. */ +function withEmlBlob(email: Email): Email { + if (!email.blobId) email.blobId = demoEmlBlobId(email.id); + return email; +} + /** localStorage key that switches the simulated incoming mail off (screenshot runs). */ export const DEMO_SIMULATE_KEY = 'bulwark-demo-simulate'; @@ -47,7 +54,10 @@ export class DemoJMAPClient implements IJMAPClient { this.data = getDemoData(profile); // Fixtures carry raw headers the way a server sends them; derive the // parsed fields the viewer reads, as JMAPClient.parseEmailHeaders does. - for (const email of this.data.emails) this.deriveHeaderFields(email); + for (const email of this.data.emails) { + this.deriveHeaderFields(email); + withEmlBlob(email); + } this.recalcMailboxCounts(); for (const s of this.data.scheduled) { const id = generateDemoId('submission'); @@ -807,9 +817,9 @@ export class DemoJMAPClient implements IJMAPClient { }; if (existing >= 0) { - this.data.emails[existing] = email; + this.data.emails[existing] = withEmlBlob(email); } else { - this.data.emails.push(email); + this.data.emails.push(withEmlBlob(email)); } this.recalcMailboxCounts(); return id; @@ -859,7 +869,7 @@ export class DemoJMAPClient implements IJMAPClient { inReplyTo: inReplyTo?.length ? inReplyTo : undefined, references: references?.length ? references : undefined, }; - this.data.emails.push(email); + this.data.emails.push(withEmlBlob(email)); let emailSubmissionId: string | undefined; if (delayedUntil) { emailSubmissionId = generateDemoId('submission'); @@ -924,6 +934,9 @@ export class DemoJMAPClient implements IJMAPClient { if (blobId === GONOGO_INVITE_BLOB) { return new Blob([createGoNoGoInvitationIcs()], { type: 'text/calendar; method=REQUEST' }); } + const emlOf = demoEmlEmailId(blobId); + const emlEmail = emlOf ? this.data.emails.find((e) => e.id === emlOf) : undefined; + if (emlEmail) return new Blob([buildDemoEml(emlEmail)], { type: 'message/rfc822' }); const asset = DEMO_BLOB_ASSETS[blobId]; if (asset) { try { @@ -1482,7 +1495,7 @@ export class DemoJMAPClient implements IJMAPClient { preview: 'Signed/encrypted demo message', hasAttachment: false, }; - this.data.emails.push(email); + this.data.emails.push(withEmlBlob(email)); let emailSubmissionId: string | undefined; if (delayedUntil) { emailSubmissionId = generateDemoId('submission'); @@ -1608,7 +1621,7 @@ export class DemoJMAPClient implements IJMAPClient { messageId: `<${id}@demo.example.com>`, }; - this.data.emails.unshift(email); + this.data.emails.unshift(withEmlBlob(email)); this.recalcMailboxCounts(); // Notify state change to trigger UI refresh diff --git a/lib/demo/demo-eml.ts b/lib/demo/demo-eml.ts new file mode 100644 index 000000000..c0dff2e66 --- /dev/null +++ b/lib/demo/demo-eml.ts @@ -0,0 +1,110 @@ +import type { Email, EmailAddress } from '@/lib/jmap/types'; + +/** + * Demo messages have no raw message on a server. Each gets a blob id with + * this prefix, and the demo client builds the .eml from the message when the + * blob is fetched, so Export as .eml, View source and Forward as attachment + * work in the demo as well. + */ +export const DEMO_EML_BLOB_PREFIX = 'demo-eml-'; + +export function demoEmlBlobId(emailId: string): string { + return `${DEMO_EML_BLOB_PREFIX}${emailId}`; +} + +/** The email id behind a demo .eml blob id, or null for any other blob. */ +export function demoEmlEmailId(blobId: string): string | null { + return blobId.startsWith(DEMO_EML_BLOB_PREFIX) ? blobId.slice(DEMO_EML_BLOB_PREFIX.length) : null; +} + +/** RFC 2047 encoded-word for a header text with non-ASCII characters. */ +function encodeWord(text: string): string { + if (/^[\x20-\x7e]*$/.test(text)) return text; + const bytes = new TextEncoder().encode(text); + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return `=?UTF-8?B?${btoa(binary)}?=`; +} + +function formatAddress(address: EmailAddress): string { + if (!address.name) return address.email; + const name = /^[\x20-\x7e]*$/.test(address.name) && /[",.;:<>@()[\]\\]/.test(address.name) + ? `"${address.name.replace(/(["\\])/g, '\\$1')}"` + : encodeWord(address.name); + return `${name} <${address.email}>`; +} + +function addressHeader(name: string, list: EmailAddress[] | undefined): string[] { + return list?.length ? [`${name}: ${list.map(formatAddress).join(', ')}`] : []; +} + +const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +/** RFC 5322 date, in UTC. */ +function formatDate(iso: string): string { + const d = new Date(iso); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${DAYS[d.getUTCDay()]}, ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} ` + + `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} +0000`; +} + +function bodyText(email: Email, parts: Email['textBody']): string | null { + const part = parts?.[0]; + if (!part?.partId) return null; + return email.bodyValues?.[part.partId]?.value ?? null; +} + +function textPart(type: string, body: string): string { + return [ + `Content-Type: ${type}; charset=utf-8`, + 'Content-Transfer-Encoding: 8bit', + '', + body.replace(/\r?\n/g, '\r\n'), + ].join('\r\n'); +} + +/** The message as an RFC 5322 .eml: its headers and its text and HTML bodies. */ +export function buildDemoEml(email: Email): string { + // Raw headers the fixture carries (authentication results, spam score, + // List-Id...) come first, as a server would keep them. + const raw = Object.entries(email.headers ?? {}).flatMap(([name, value]) => + (Array.isArray(value) ? value : [value]).map((v) => `${name}: ${v}`), + ); + const headers = [ + ...raw, + ...addressHeader('From', email.from), + ...addressHeader('Reply-To', email.replyTo), + ...addressHeader('To', email.to), + ...addressHeader('Cc', email.cc), + `Subject: ${encodeWord(email.subject ?? '')}`, + `Date: ${formatDate(email.sentAt ?? email.receivedAt)}`, + `Message-ID: ${email.messageId ?? `<${email.id}@demo.example.com>`}`, + ...(email.inReplyTo?.length ? [`In-Reply-To: ${email.inReplyTo.join(' ')}`] : []), + ...(email.references?.length ? [`References: ${email.references.join(' ')}`] : []), + 'MIME-Version: 1.0', + ]; + + const text = bodyText(email, email.textBody); + const html = bodyText(email, email.htmlBody); + let body: string; + if (text !== null && html !== null && text !== html) { + const boundary = `demo-${email.id}`; + headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`); + body = [ + `--${boundary}`, + textPart('text/plain', text), + `--${boundary}`, + textPart('text/html', html), + `--${boundary}--`, + '', + ].join('\r\n'); + } else { + const [type, value] = html !== null ? ['text/html', html] : ['text/plain', text ?? email.preview ?? '']; + const part = textPart(type, value); + const split = part.indexOf('\r\n\r\n'); + headers.push(part.slice(0, split)); + body = part.slice(split + 4); + } + return `${headers.join('\r\n')}\r\n\r\n${body}`; +}