mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
fix: make Forward as attachment, Export as .eml and View source work in the demo
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user