Merge pull request #347 from sripwoud/fix/web-multi-file-import

fix(web): import all selected files, not just the first
This commit is contained in:
rustmailer
2026-08-19 13:38:12 +08:00
committed by GitHub
6 changed files with 583 additions and 55 deletions

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import { extractFolderHint } from '../folder-hint'
const emlWithLabels = [
'From: a@example.com',
'To: b@example.com',
'Subject: hello',
'X-Gmail-Labels: TestBatch',
'',
'body',
].join('\n')
const emlPlain = ['From: a@example.com', 'Subject: hello', '', 'body'].join(
'\n'
)
describe('extractFolderHint', () => {
it('extracts the folder from X-Gmail-Labels and records the source file', async () => {
const hint = await extractFolderHint(
new File([emlWithLabels], 'sample-01.eml')
)
expect(hint).toEqual({
name: 'TestBatch',
source: 'gmail-labels',
fileName: 'sample-01.eml',
})
})
it('unfolds folded header lines without swallowing the body', async () => {
const folded = [
'From: a@example.com',
'X-Gmail-Labels: Inbox,',
' Receipts',
'Subject: hello',
'',
'body line',
].join('\n')
const hint = await extractFolderHint(new File([folded], 'x.eml'))
expect(hint?.name).toBe('Receipts')
})
it('falls back to the file name and records it as the source file', async () => {
const hint = await extractFolderHint(new File([emlPlain], 'Receipts.eml'))
expect(hint).toEqual({
name: 'Receipts',
source: 'filename',
fileName: 'Receipts.eml',
})
})
})

View File

@@ -0,0 +1,297 @@
import { describe, it, expect, vi } from 'vitest'
import type { ImportProgress } from '@/api/import/api'
import { importFiles, type ImportFilesDeps } from '../import-files'
const makeFile = (name: string, size: number) =>
new File([new Uint8Array(size)], name)
const prog = (over: Partial<ImportProgress> = {}): ImportProgress => ({
import_id: 'imp_1',
status: 'Completed',
format: 'eml',
total: 1,
success: 1,
duplicates: 0,
failed: 0,
failed_details: [],
...over,
})
const makeDeps = (over: Partial<ImportFilesDeps> = {}): ImportFilesDeps => ({
upload: vi.fn(async () => prog()),
getProgress: vi.fn(async () => prog()),
onUploadPct: vi.fn(),
onProgress: vi.fn(),
onPhase: vi.fn(),
pollIntervalMs: 0,
...over,
})
describe('importFiles', () => {
it('uploads every selected file, in order', async () => {
const files = [
makeFile('a.eml', 10),
makeFile('b.eml', 10),
makeFile('c.eml', 10),
]
const upload = vi.fn(async (file: File) => prog({ import_id: file.name }))
const deps = makeDeps({ upload })
await importFiles(files, deps)
expect(upload).toHaveBeenCalledTimes(3)
expect(upload.mock.calls.map(([f]) => f.name)).toEqual([
'a.eml',
'b.eml',
'c.eml',
])
})
it('reports uploading then processing for each file', async () => {
const files = [makeFile('a.eml', 10), makeFile('b.eml', 10)]
const deps = makeDeps({
upload: vi.fn(async () =>
prog({ status: 'Pending', total: 0, success: 0 })
),
})
await importFiles(files, deps)
expect(vi.mocked(deps.onPhase).mock.calls.map(([p]) => p)).toEqual([
'uploading',
'processing',
'uploading',
'processing',
])
})
it('aggregates counts across files', async () => {
const files = [makeFile('a.mbox', 10), makeFile('b.mbox', 10)]
const results: Record<string, ImportProgress> = {
'a.mbox': prog({
import_id: 'a',
total: 3,
success: 2,
failed: 1,
duplicates: 1,
}),
'b.mbox': prog({ import_id: 'b', total: 2, success: 2 }),
}
const deps = makeDeps({
upload: vi.fn(async (file: File) => results[file.name]),
})
const result = await importFiles(files, deps)
expect(result.total).toBe(5)
expect(result.success).toBe(4)
expect(result.failed).toBe(1)
expect(result.duplicates).toBe(1)
expect(result.status).toBe('Completed')
})
it('polls until the import reaches a terminal status', async () => {
const files = [makeFile('a.mbox', 10)]
const polls = [
prog({ status: 'Processing', total: 5, success: 2 }),
prog({ status: 'Completed', total: 5, success: 5 }),
]
const getProgress = vi.fn(async () => polls.shift()!)
const deps = makeDeps({
upload: vi.fn(async () =>
prog({ status: 'Pending', total: 0, success: 0 })
),
getProgress,
})
const result = await importFiles(files, deps)
expect(getProgress).toHaveBeenCalledTimes(2)
expect(result.success).toBe(5)
const seen = vi.mocked(deps.onProgress).mock.calls.map(([p]) => p.success)
expect(seen).toContain(2)
})
it('continues past a failed upload and surfaces its error', async () => {
const files = [makeFile('bad.eml', 10), makeFile('good.eml', 10)]
const upload = vi.fn(async (file: File) => {
if (file.name === 'bad.eml') {
throw { response: { data: { message: 'not a valid email file' } } }
}
return prog({ import_id: 'good' })
})
const deps = makeDeps({ upload })
const result = await importFiles(files, deps)
expect(upload).toHaveBeenCalledTimes(2)
expect(result.total).toBe(2)
expect(result.success).toBe(1)
expect(result.failed).toBe(1)
expect(result.failed_details).toHaveLength(1)
expect(result.failed_details[0].error_message).toContain('bad.eml')
expect(result.failed_details[0].error_message).toContain(
'not a valid email file'
)
expect(result.status).toBe('Completed')
})
it('throws when every upload fails, so the caller can toast and reset', async () => {
const files = [makeFile('a.eml', 10), makeFile('b.eml', 10)]
const deps = makeDeps({
upload: vi.fn(async () => {
throw { response: { data: { message: 'server unreachable' } } }
}),
})
await expect(importFiles(files, deps)).rejects.toThrow('server unreachable')
})
it('reports cumulative upload progress that never resets across files', async () => {
const files = [makeFile('a.eml', 100), makeFile('b.eml', 300)]
const deps = makeDeps({
upload: vi.fn(async (_file: File, onPct: (pct: number) => void) => {
onPct(50)
onPct(100)
return prog()
}),
})
await importFiles(files, deps)
const seen = vi.mocked(deps.onUploadPct).mock.calls.map(([pct]) => pct)
expect(seen.length).toBeGreaterThan(0)
for (let i = 1; i < seen.length; i++) {
expect(seen[i]).toBeGreaterThanOrEqual(seen[i - 1])
}
expect(seen[seen.length - 1]).toBe(100)
})
it('labels failed details with the file name only for multi-file selections', async () => {
const failing = (id: string) =>
prog({
import_id: id,
total: 2,
success: 1,
failed: 1,
failed_details: [{ index: 0, error_message: 'bad message' }],
})
const multi = await importFiles(
[makeFile('a.mbox', 10), makeFile('b.mbox', 10)],
makeDeps({ upload: vi.fn(async (file: File) => failing(file.name)) })
)
expect(multi.failed_details.map((d) => d.error_message)).toEqual([
'a.mbox: bad message',
'b.mbox: bad message',
])
const single = await importFiles(
[makeFile('a.mbox', 10)],
makeDeps({ upload: vi.fn(async () => failing('a')) })
)
expect(single.failed_details.map((d) => d.error_message)).toEqual([
'bad message',
])
})
it('gives up on a file after repeated poll errors and continues', async () => {
const files = [makeFile('a.mbox', 10), makeFile('b.eml', 10)]
const upload = vi.fn(async (file: File) =>
file.name === 'a.mbox'
? prog({ import_id: 'a', status: 'Pending', total: 0, success: 0 })
: prog({ import_id: 'b' })
)
const getProgress = vi.fn(async () => {
throw new Error('network down')
})
const deps = makeDeps({ upload, getProgress })
const result = await importFiles(files, deps)
expect(upload).toHaveBeenCalledTimes(2)
expect(result.success).toBe(1)
expect(result.failed).toBe(1)
expect(result.failed_details[0].error_message).toContain('a.mbox')
expect(result.status).toBe('Completed')
})
it('records the file position as the index of a synthetic failure', async () => {
const files = [
makeFile('a.eml', 10),
makeFile('bad.eml', 10),
makeFile('c.eml', 10),
]
const deps = makeDeps({
upload: vi.fn(async (file: File) => {
if (file.name === 'bad.eml') throw new Error('boom')
return prog()
}),
})
const result = await importFiles(files, deps)
expect(result.failed_details).toHaveLength(1)
expect(result.failed_details[0].index).toBe(1)
})
it('is Failed when the server reports a fatal failure with zero counts', async () => {
const files = [makeFile('a.mbox', 10)]
const deps = makeDeps({
upload: vi.fn(async () =>
prog({
status: 'Failed',
total: 0,
success: 0,
failed: 0,
failed_details: [{ index: 0, error_message: 'mailbox not found' }],
})
),
})
const result = await importFiles(files, deps)
expect(result.status).toBe('Failed')
})
it('stops uploading and polling once aborted', async () => {
const controller = new AbortController()
const files = [makeFile('a.mbox', 10), makeFile('b.mbox', 10)]
const upload = vi.fn(async () =>
prog({ status: 'Pending', total: 0, success: 0 })
)
const getProgress = vi.fn(async () => {
controller.abort()
return prog({ status: 'Processing', total: 5, success: 1 })
})
const deps = makeDeps({ upload, getProgress, signal: controller.signal })
await importFiles(files, deps)
expect(upload).toHaveBeenCalledTimes(1)
expect(getProgress).toHaveBeenCalledTimes(1)
})
it('is Failed when nothing succeeded', async () => {
const files = [makeFile('a.mbox', 10)]
const deps = makeDeps({
upload: vi.fn(async () =>
prog({
status: 'Failed',
total: 2,
success: 0,
failed: 2,
failed_details: [
{ index: 0, error_message: 'parse error' },
{ index: 1, error_message: 'parse error' },
],
})
),
})
const result = await importFiles(files, deps)
expect(result.status).toBe('Failed')
expect(result.failed).toBe(2)
})
})

View File

@@ -44,14 +44,14 @@ function getHeader(raw: string, name: string): string | null {
const re = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:\\s*(.+)$`, 'im');
const m = raw.match(re);
if (!m) return null;
// Unfold continuation lines (leading whitespace)
// Unfold continuation lines: only contiguous lines starting with
// horizontal whitespace belong to this header (RFC 5322 folding).
let val = m[1].trim();
const startIdx = m.index! + m[0].length;
const rest = raw.slice(startIdx);
const contRe = /^\s+(.+)$/gm;
let cm: RegExpExecArray | null;
while ((cm = contRe.exec(rest)) !== null) {
val += ' ' + cm[1].trim();
for (const line of rest.split(/\r?\n/).slice(1)) {
if (!/^[ \t]+\S/.test(line)) break;
val += ' ' + line.trim();
}
return decodeRfc2047(val);
}
@@ -105,6 +105,8 @@ export interface FolderHint {
name: string;
/** Where the hint came from. */
source: 'gmail-labels' | 'bichon-metadata' | 'filename' | 'mbox-filename' | 'pst-filename';
/** Name of the file the hint was extracted from. */
fileName: string;
}
/**
@@ -127,26 +129,26 @@ export async function extractFolderHint(file: File): Promise<FolderHint | null>
// 1. X-Bichon-Metadata (highest priority, explicit)
const bichonFolder = folderFromBichonMetadata(headers);
if (bichonFolder) return { name: bichonFolder, source: 'bichon-metadata' };
if (bichonFolder) return { name: bichonFolder, source: 'bichon-metadata', fileName: file.name };
// 2. X-Gmail-Labels
const gmailFolder = folderFromGmailLabels(headers);
if (gmailFolder) return { name: gmailFolder, source: 'gmail-labels' };
if (gmailFolder) return { name: gmailFolder, source: 'gmail-labels', fileName: file.name };
// 3. For MBOX files, use the filename
if (isMbox) {
const fnFolder = folderFromFileName(file.name);
if (fnFolder) return { name: fnFolder, source: 'mbox-filename' };
if (fnFolder) return { name: fnFolder, source: 'mbox-filename', fileName: file.name };
}
// 4. For EML files, try the filename
const fnFolder = folderFromFileName(file.name);
if (fnFolder) return { name: fnFolder, source: 'filename' };
if (fnFolder) return { name: fnFolder, source: 'filename', fileName: file.name };
// 5. For PST files, try the filename
if (isPst) {
const fnFolder = folderFromFileName(file.name);
if (fnFolder) return { name: fnFolder, source: 'pst-filename' };
if (fnFolder) return { name: fnFolder, source: 'pst-filename', fileName: file.name };
}
return null;

View File

@@ -0,0 +1,192 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
import type { ImportProgress } from '@/api/import/api'
export interface ImportFilesDeps {
upload: (file: File, onPct: (pct: number) => void) => Promise<ImportProgress>
getProgress: (importId: string) => Promise<ImportProgress>
onUploadPct: (pct: number) => void
onProgress: (progress: ImportProgress) => void
onPhase: (phase: 'uploading' | 'processing') => void
signal?: AbortSignal
pollIntervalMs?: number
}
const DEFAULT_POLL_INTERVAL_MS = 1000
const MAX_POLL_ERRORS = 5
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const isTerminal = (status: ImportProgress['status']) =>
status === 'Completed' || status === 'Failed'
function emptyProgress(): ImportProgress {
return {
import_id: '',
status: 'Completed',
format: '',
total: 0,
success: 0,
duplicates: 0,
failed: 0,
failed_details: [],
}
}
export function errorMessage(err: unknown): string {
if (err && typeof err === 'object') {
const maybe = err as {
response?: { data?: { message?: unknown } }
message?: unknown
}
const serverMessage = maybe.response?.data?.message
if (typeof serverMessage === 'string' && serverMessage) return serverMessage
if (typeof maybe.message === 'string' && maybe.message) return maybe.message
}
return String(err)
}
function mergeProgress(
aggregate: ImportProgress,
fileProgress: ImportProgress,
label: string | null
): ImportProgress {
return {
...aggregate,
import_id: fileProgress.import_id || aggregate.import_id,
format: fileProgress.format || aggregate.format,
total: aggregate.total + fileProgress.total,
success: aggregate.success + fileProgress.success,
duplicates: aggregate.duplicates + fileProgress.duplicates,
failed: aggregate.failed + fileProgress.failed,
failed_details: [
...aggregate.failed_details,
...fileProgress.failed_details.map((d) =>
label ? { ...d, error_message: `${label}: ${d.error_message}` } : d
),
],
}
}
function fileFailure(fileIndex: number, message: string): ImportProgress {
return {
...emptyProgress(),
status: 'Failed',
total: 1,
failed: 1,
failed_details: [{ index: fileIndex, error_message: message }],
}
}
async function waitForTerminal(
initial: ImportProgress,
getProgress: (importId: string) => Promise<ImportProgress>,
pollIntervalMs: number,
signal: AbortSignal | undefined,
onTick: (progress: ImportProgress) => void
): Promise<ImportProgress> {
let current = initial
let consecutiveErrors = 0
while (!isTerminal(current.status)) {
await sleep(pollIntervalMs)
if (signal?.aborted) return current
try {
current = await getProgress(initial.import_id)
consecutiveErrors = 0
onTick(current)
} catch (err) {
consecutiveErrors++
if (consecutiveErrors > MAX_POLL_ERRORS) {
throw new Error(
`lost track of import progress (the import may still be running, check import history): ${errorMessage(err)}`
)
}
}
}
return current
}
export async function importFiles(
files: File[],
deps: ImportFilesDeps
): Promise<ImportProgress> {
const { upload, getProgress, onUploadPct, onProgress, onPhase, signal } = deps
const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
const totalBytes = files.reduce((sum, f) => sum + f.size, 0)
const label = files.length > 1 ? (file: File) => file.name : () => null
let aggregate = emptyProgress()
let uploadedBytes = 0
let transportFailures = 0
let sawFailure = false
for (const [index, file] of files.entries()) {
if (signal?.aborted) break
const startBytes = uploadedBytes
const reportPct = (filePct: number) => {
if (totalBytes === 0) return
const bytes = startBytes + (filePct / 100) * file.size
onUploadPct(Math.min(100, Math.round((bytes / totalBytes) * 100)))
}
onPhase('uploading')
let initial: ImportProgress
try {
initial = await upload(file, reportPct)
} catch (err) {
transportFailures++
sawFailure = true
aggregate = mergeProgress(
aggregate,
fileFailure(index, `${file.name}: ${errorMessage(err)}`),
null
)
uploadedBytes = startBytes + file.size
reportPct(100)
onProgress(aggregate)
continue
}
uploadedBytes = startBytes + file.size
reportPct(100)
onPhase('processing')
let final: ImportProgress
try {
final = await waitForTerminal(
initial,
getProgress,
pollIntervalMs,
signal,
(current) => onProgress(mergeProgress(aggregate, current, label(file)))
)
} catch (err) {
sawFailure = true
aggregate = mergeProgress(
aggregate,
fileFailure(index, `${file.name}: ${errorMessage(err)}`),
null
)
onProgress(aggregate)
continue
}
if (final.status === 'Failed' || final.failed > 0) sawFailure = true
aggregate = mergeProgress(aggregate, final, label(file))
onProgress(aggregate)
}
if (files.length > 0 && transportFailures === files.length) {
throw new Error(
aggregate.failed_details[0]?.error_message ?? 'Upload failed'
)
}
const result: ImportProgress = {
...aggregate,
status: aggregate.success === 0 && sawFailure ? 'Failed' : 'Completed',
}
onProgress(result)
return result
}

View File

@@ -45,6 +45,7 @@ import {
import { get_system_configurations } from '@/api/system/api';
import { list_mailboxes } from '@/api/mailbox/api';
import { extractFolderHint, type FolderHint } from './folder-hint';
import { importFiles, errorMessage } from './import-files';
const MAX_EML = 100 * 1024 * 1024; // 100 MB (hardcoded)
const DEFAULT_MAX_MBOX = 1024 * 1024 * 1024; // 1 GB (fallback; actual limit from server settings)
@@ -104,7 +105,6 @@ export default function ImportPage() {
const [folder, setFolder] = useState('INBOX');
const [files, setFiles] = useState<QueuedFile[]>([]);
const [dragging, setDragging] = useState(false);
// const [importId, setImportId] = useState<string | null>(null);
const [progress, setProgress] = useState<ImportProgress | null>(null);
const [uploadPct, setUploadPct] = useState(0);
const [phase, setPhase] = useState<'idle' | 'uploading' | 'processing' | 'done'>('idle');
@@ -117,7 +117,11 @@ export default function ImportPage() {
// Combobox state for account selection
const [accountOpen, setAccountOpen] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
return () => { abortRef.current?.abort(); };
}, []);
const { data: accounts = [] } = useQuery({
queryKey: ['nosync-accounts'],
@@ -168,33 +172,6 @@ export default function ImportPage() {
}
})();
const startPolling = useCallback((id: string) => {
if (pollRef.current) clearInterval(pollRef.current);
let retries = 0;
pollRef.current = setInterval(async () => {
try {
const p = await get_import_progress(id);
setProgress(p);
retries = 0;
if (p.status === 'Completed' || p.status === 'Failed') {
if (pollRef.current) clearInterval(pollRef.current);
setPhase('done');
refetchHistory();
}
} catch {
retries++;
if (retries > 5) {
if (pollRef.current) clearInterval(pollRef.current);
setPhase('idle');
}
}
}, 1000);
}, [refetchHistory]);
useEffect(() => {
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, []);
const handleFiles = useCallback(async (newFiles: FileList | File[]) => {
const arr = Array.from(newFiles) as File[];
const queued: QueuedFile[] = arr.map((f) => {
@@ -209,7 +186,6 @@ export default function ImportPage() {
setFiles(queued);
setPhase('idle');
setProgress(null);
//setImportId(null);
// Extract folder hint from the first valid file.
// PST files are binary (OLE2) — headers can't be extracted in-browser.
@@ -277,26 +253,32 @@ export default function ImportPage() {
const importMutation = useMutation({
mutationFn: async () => {
if (!accountId || !files.length) return;
const file = files[0].file;
const controller = new AbortController();
abortRef.current = controller;
setPhase('uploading');
setUploadPct(0);
const result = await upload_import(
Number(accountId),
effectiveFolder,
file.name,
file,
(pct) => setUploadPct(pct),
setProgress(null);
await importFiles(
files.map((q) => q.file),
{
upload: (file, onPct) =>
upload_import(Number(accountId), effectiveFolder, file.name, file, onPct),
getProgress: get_import_progress,
onUploadPct: setUploadPct,
onProgress: setProgress,
onPhase: setPhase,
signal: controller.signal,
},
);
//setImportId(result.import_id);
setProgress(result);
setPhase('processing');
startPolling(result.import_id);
setPhase('done');
refetchHistory();
},
onError: (err: any) => {
onError: (err: unknown) => {
setPhase('idle');
setProgress(null);
toast({
title: t('common.failed'),
description: err?.response?.data?.message || err.message,
description: errorMessage(err),
variant: 'destructive',
});
},
@@ -437,7 +419,7 @@ export default function ImportPage() {
</Badge>
{folderHint && (
<span className="text-[10px] text-muted-foreground">
({t('import.source')}: {folderHintLabel(folderHint)})
({t('import.source')}: {folderHintLabel(folderHint)}{files.length > 1 && `, ${folderHint.fileName}`})
</span>
)}
</div>
@@ -730,7 +712,7 @@ export default function ImportPage() {
<div className="text-xs text-muted-foreground">
{isPstSelected
? t('import.pstFolders', 'PST folder structure will be preserved during import')
: (<>{t('import.willImportTo', 'Will import to')}: <span className="font-medium text-foreground">{effectiveFolder}</span></>)}
: (<>{t('import.willImportTo', 'Will import to')}: <span className="font-medium text-foreground">{effectiveFolder}</span>{files.length > 1 && <> · {t('import.fileCount', { count: files.length })}</>}</>)}
</div>
<Button
onClick={() => importMutation.mutate()}

View File

@@ -742,6 +742,7 @@
"failed": "Import failed",
"failedCount": "{{count}} failed",
"failedDetails": "Failed items",
"fileCount": "{{count}} files",
"folder": "Folder",
"folderMethod": "2. Choose folder method",
"folderMethodDesc": "How should the target mail folder be determined?",