fix: make Forward as attachment, Export as .eml and View source work in the demo

This commit is contained in:
Linus Rath
2026-10-28 10:23:20 +01:00
parent 60b5a91a87
commit 039ed0598b
3 changed files with 161 additions and 6 deletions
+32
View File
@@ -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');
});
});
+19 -6
View File
@@ -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<string, string> = 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
+110
View File
@@ -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}`;
}