mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 00:15:32 +00:00
fix: give mail import and export their own settings section and name the upload limit as the server does
- Settings > Backup & reset gets a Mail files section for Import mail and Export all mail, instead of sitting under settings backup. - The pause message names Stalwart's 50000000-byte quota as 50 MB, not 48 MB, and the dialog and settings texts mention .ost files. - The old import's leftovers go: expandImportableEmails and EML_IMPORT_ACCEPT in lib/eml-import.ts, and the strings email_viewer.import_email, mailbox_context_menu.import_email and notifications.import_email_success/_error in all 30 locales. lib/__tests__/eml-import.test.ts loses the tests of the removed functions; its #891 checks that an .eml and an .eml in a ZIP keep their Date header now run against the import's own sources.
This commit is contained in:
@@ -1,54 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import JSZip from 'jszip';
|
||||
import { expandImportableEmails, readMessageDate, EML_IMPORT_ACCEPT } from '@/lib/eml-import';
|
||||
import { readMessageDate } from '@/lib/eml-import';
|
||||
import { emlSource, zipSource, type ImportItem } from '@/lib/mail-import/sources';
|
||||
import { ZipWriter } from '@/lib/mail-export/zip-writer';
|
||||
|
||||
const emlFile = (name: string, content = 'raw email', type = 'message/rfc822') =>
|
||||
new File([content], name, { type });
|
||||
|
||||
describe('expandImportableEmails', () => {
|
||||
it('wraps a .eml file as a message/rfc822 blob, keeping its name', async () => {
|
||||
const out = await expandImportableEmails([emlFile('msg.eml')]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].name).toBe('msg.eml');
|
||||
expect(out[0].blob.type).toBe('message/rfc822');
|
||||
await expect(out[0].blob.text()).resolves.toBe('raw email');
|
||||
});
|
||||
const opts = { skipBefore: 0, maxMessageBytes: 50_000_000 };
|
||||
|
||||
it('CHARACTERISATION: wraps a non-.eml, non-zip file as rfc822 too', async () => {
|
||||
const out = await expandImportableEmails([emlFile('note.txt', 'hi', 'text/plain')]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({ name: 'note.txt' });
|
||||
expect(out[0].blob.type).toBe('message/rfc822');
|
||||
});
|
||||
|
||||
it('extracts only .eml entries from a .zip, stripping path prefixes', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('a.eml', 'A');
|
||||
zip.file('sub/b.eml', 'B');
|
||||
zip.file('c.txt', 'C'); // skipped (not .eml)
|
||||
zip.folder('emptydir'); // skipped (directory)
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const file = new File([blob], 'archive.zip', { type: 'application/zip' });
|
||||
|
||||
const out = await expandImportableEmails([file]);
|
||||
expect(out.map((e) => e.name).sort()).toEqual(['a.eml', 'b.eml']);
|
||||
expect(out.every((e) => e.blob.type === 'message/rfc822')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the zip path when the MIME type is application/zip even without a .zip name', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('only.eml', 'X');
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const file = new File([blob], 'archive-no-ext', { type: 'application/zip' });
|
||||
|
||||
const out = await expandImportableEmails([file]);
|
||||
expect(out.map((e) => e.name)).toEqual(['only.eml']);
|
||||
});
|
||||
|
||||
it('exposes the accept string for the file picker', () => {
|
||||
expect(EML_IMPORT_ACCEPT).toBe('.eml,.zip,message/rfc822,application/zip');
|
||||
});
|
||||
});
|
||||
async function all(source: AsyncGenerator<ImportItem>): Promise<ImportItem[]> {
|
||||
const out: ImportItem[] = [];
|
||||
for await (const item of source) out.push(item);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('Date header as receivedAt (#891)', () => {
|
||||
const CRLF = String.fromCharCode(13, 10);
|
||||
@@ -63,16 +27,17 @@ describe('Date header as receivedAt (#891)', () => {
|
||||
].join(CRLF);
|
||||
|
||||
it('carries the Date header of an .eml file as a JMAP UTCDate', async () => {
|
||||
const out = await expandImportableEmails([emlFile('old.eml', raw)]);
|
||||
expect(out[0].receivedAt).toBe('2019-03-05T08:30:00Z');
|
||||
const [item] = await all(emlSource(emlFile('old.eml', raw), opts));
|
||||
expect(item).toMatchObject({ kind: 'message', receivedAt: '2019-03-05T08:30:00Z' });
|
||||
});
|
||||
|
||||
it('carries the Date header of each .eml inside a .zip', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('old.eml', raw);
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const out = await expandImportableEmails([new File([blob], 'a.zip', { type: 'application/zip' })]);
|
||||
expect(out[0].receivedAt).toBe('2019-03-05T08:30:00Z');
|
||||
const parts: Uint8Array[] = [];
|
||||
const z = new ZipWriter(async (c) => { parts.push(c.slice()); });
|
||||
await z.addFile('old.eml', new TextEncoder().encode(raw));
|
||||
await z.finish();
|
||||
const [item] = await all(zipSource(new File(parts as BlobPart[], 'a.zip', { type: 'application/zip' }), opts));
|
||||
expect(item).toMatchObject({ kind: 'message', receivedAt: '2019-03-05T08:30:00Z' });
|
||||
});
|
||||
|
||||
it('leaves receivedAt unset when there is no usable Date header', async () => {
|
||||
|
||||
@@ -1,26 +1,5 @@
|
||||
import { MAX_FUTURE_SENT_AT_MS } from "./email-date";
|
||||
|
||||
export interface ImportableEmail {
|
||||
name: string;
|
||||
blob: Blob;
|
||||
/**
|
||||
* The message's `Date` header as a JMAP UTCDate, passed as `receivedAt` on
|
||||
* Email/import. Without it the server stamps the import time and every
|
||||
* imported message looks like it arrived today (#891).
|
||||
*/
|
||||
receivedAt?: string;
|
||||
}
|
||||
|
||||
const EMAIL_MIME = "message/rfc822";
|
||||
|
||||
function isEmlName(name: string): boolean {
|
||||
return /\.eml$/i.test(name);
|
||||
}
|
||||
|
||||
function isZipName(name: string): boolean {
|
||||
return /\.zip$/i.test(name);
|
||||
}
|
||||
|
||||
/** Only the header block is needed; don't decode a whole large message. */
|
||||
const HEADER_SCAN_BYTES = 64 * 1024;
|
||||
|
||||
@@ -46,39 +25,3 @@ export function dateFromHeaderBlock(head: string, now: number = Date.now()): str
|
||||
if (Number.isNaN(time) || time - now > MAX_FUTURE_SENT_AT_MS) return undefined;
|
||||
return new Date(time).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
|
||||
const { default: JSZip } = await import("jszip");
|
||||
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
||||
const out: ImportableEmail[] = [];
|
||||
const entries = Object.values(zip.files);
|
||||
for (const entry of entries) {
|
||||
if (entry.dir) continue;
|
||||
if (!isEmlName(entry.name)) continue;
|
||||
const data = await entry.async("arraybuffer");
|
||||
const blob = new Blob([data], { type: EMAIL_MIME });
|
||||
out.push({
|
||||
name: entry.name.split(/[\\/]/).pop() || entry.name,
|
||||
blob,
|
||||
receivedAt: await readMessageDate(blob),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function expandImportableEmails(
|
||||
files: File[],
|
||||
): Promise<ImportableEmail[]> {
|
||||
const out: ImportableEmail[] = [];
|
||||
for (const file of files) {
|
||||
if (isZipName(file.name) || file.type === "application/zip") {
|
||||
out.push(...(await extractEmlsFromZip(file)));
|
||||
continue;
|
||||
}
|
||||
const blob = new Blob([await file.arrayBuffer()], { type: EMAIL_MIME });
|
||||
out.push({ name: file.name, blob, receivedAt: await readMessageDate(blob) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const EML_IMPORT_ACCEPT = ".eml,.zip,message/rfc822,application/zip";
|
||||
|
||||
Reference in New Issue
Block a user