diff --git a/.github/release-notes/v1.2.9.md b/.github/release-notes/v1.2.9.md new file mode 100644 index 0000000..bde100a --- /dev/null +++ b/.github/release-notes/v1.2.9.md @@ -0,0 +1,25 @@ +## 本次更新 + +### 修复邮件导入 413 + +- 修复 all-in-one 和多容器部署中,内部 Nginx 使用默认 `1 MB` 上传限制,导致单封稍大的 EML 邮件也导入失败的问题。 +- API 上传入口现在允许最多 `50 MB` 的单批请求;单封邮件仍遵循系统设置中的邮件大小限制。 +- 遇到 `413 Request Entity Too Large` 时改为显示明确的中文提示。 + +### 支持大批量导入 + +- 一次选择多封 EML/MBOX 后,网页会按最多 20 个文件、约 `32 MB` 自动分批上传,无需用户手动拆分文件。 +- 每批成功后立即保存邮件;后续批次失败不会删除已经成功导入的邮件。 +- 导入结束后统一显示成功和跳过数量,并刷新当前邮件列表。 + +### 保留历史邮件时间 + +- 导入时继续保留邮件头中的原始 `Date` 时间。 +- 收件箱按邮件历史时间倒序显示,最新邮件排列在最前。 +- 新增回归测试,验证批量导入后不受文件选择或处理顺序影响。 + +### 验证 + +- 已通过 Go API 全量测试、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。 + +**完整更新日志**:[v1.2.8...v1.2.9](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.8...v1.2.9) diff --git a/VERSION b/VERSION index db6fb4a..9d4f823 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.8 +1.2.9 diff --git a/apps/api/internal/app/mail_transfer_handlers_test.go b/apps/api/internal/app/mail_transfer_handlers_test.go index 867c568..6d915eb 100644 --- a/apps/api/internal/app/mail_transfer_handlers_test.go +++ b/apps/api/internal/app/mail_transfer_handlers_test.go @@ -65,20 +65,21 @@ func TestMailImportExportAndOwnership(t *testing.T) { t.Fatalf("owner login=%d", code) } - eml := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: imported message\r\nMessage-ID: \r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello import") + eml := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: imported message\r\nDate: Tue, 2 Jan 2024 12:00:00 +0000\r\nMessage-ID: \r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello import") + olderEML := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: older imported message\r\nDate: Mon, 1 Jan 2024 12:00:00 +0000\r\nMessage-ID: \r\nContent-Type: text/plain; charset=utf-8\r\n\r\nolder import") var imported struct { Imported int `json:"imported"` Skipped int `json:"skipped"` Errors []string `json:"errors"` } - if code := doMailImport(t, owner, ownerMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &imported); code != http.StatusOK || imported.Imported != 1 || imported.Skipped != 0 { + if code := doMailImport(t, owner, ownerMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml, "older.eml": olderEML}, &imported); code != http.StatusOK || imported.Imported != 2 || imported.Skipped != 0 { t.Fatalf("import code=%d response=%+v", code, imported) } var list struct { Items []MailMessage `json:"items"` } - if code := owner.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+ownerMailbox.ID, nil, &list); code != http.StatusOK || len(list.Items) != 1 || list.Items[0].Subject != "imported message" { + if code := owner.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+ownerMailbox.ID, nil, &list); code != http.StatusOK || len(list.Items) != 2 || list.Items[0].Subject != "imported message" || list.Items[1].Subject != "older imported message" { t.Fatalf("list code=%d items=%+v", code, list.Items) } @@ -90,7 +91,7 @@ func TestMailImportExportAndOwnership(t *testing.T) { if err != nil { t.Fatal(err) } - if len(zr.File) != 1 { + if len(zr.File) != 2 { t.Fatalf("zip entries=%d", len(zr.File)) } entry, err := zr.File[0].Open() diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 8d19b37..5fad19a 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -85,6 +85,7 @@ async function uploadForm(path: string, form: FormData): Promise { try { const res = await fetch(path, { method: "POST", credentials: "include", body: form, signal: controller.signal }) if (!res.ok) { + if (res.status === 413) throw new Error("导入文件过大,请减少单次导入数量后重试") let message = `${res.status} ${res.statusText}` try { const body = await res.json(); message = body.error || message } catch {} throw new Error(message) diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 6ef87b8..bf1e5b9 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -88,9 +88,28 @@ const filterLabels: Record = { const emptyAdvancedSearch: AdvancedMailSearch = { from: "", to: "", subject: "", startDate: "", endDate: "", hasAttachments: false, unread: false, starred: false } const emptyAdvancedSearchDraft: AdvancedMailSearchDraft = { ...emptyAdvancedSearch } +const mailImportBatchBytes = 32 * 1024 * 1024 +const mailImportBatchFiles = 20 const mailCompactBreakpoint = 768 const mailDetailBreakpoint = 768 +function buildMailImportBatches(files: File[]) { + const batches: File[][] = [] + let batch: File[] = [] + let batchBytes = 0 + for (const file of files) { + if (batch.length > 0 && (batch.length >= mailImportBatchFiles || batchBytes + file.size > mailImportBatchBytes)) { + batches.push(batch) + batch = [] + batchBytes = 0 + } + batch.push(file) + batchBytes += file.size + } + if (batch.length > 0) batches.push(batch) + return batches +} + function useMaxViewportWidth(maxWidth: number) { const [matches, setMatches] = React.useState(false) React.useEffect(() => { @@ -1112,8 +1131,22 @@ export function MailPage() { if (files.length === 0 || !selectedMailbox) return setImportingMail(true) try { - const result = await api.importMail(files, { mailboxId: selectedMailbox.id, folder: mailView === "folder" ? folder : "Inbox" }) + const batches = buildMailImportBatches(files) + const target = { mailboxId: selectedMailbox.id, folder: mailView === "folder" ? folder : "Inbox" } + const result = { imported: 0, skipped: 0, errors: [] as string[] } + for (const batch of batches) { + try { + const current = await api.importMail(batch, target) + result.imported += current.imported + result.skipped += current.skipped + result.errors.push(...current.errors) + } catch (error) { + result.skipped += batch.length + result.errors.push(error instanceof Error ? error.message : "导入请求失败") + } + } await refreshMailData() + if (result.imported === 0 && result.errors.length > 0) throw new Error(result.errors[0]) toast({ title: `已导入 ${result.imported} 封邮件`, description: result.skipped > 0 ? `${result.skipped} 封未能导入${result.errors[0] ? `:${result.errors[0]}` : ""}` : `已保存到 ${mailView === "folder" ? viewTitle : "收件箱"}`, diff --git a/deploy/all-in-one/nginx.conf b/deploy/all-in-one/nginx.conf index a114fa5..ead8329 100644 --- a/deploy/all-in-one/nginx.conf +++ b/deploy/all-in-one/nginx.conf @@ -5,6 +5,7 @@ server { index index.html; location /api/ { + client_max_body_size 50m; proxy_pass http://127.0.0.1:8080/api/; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/deploy/nginx/default.conf b/deploy/nginx/default.conf index 726a3ba..50fb8b2 100644 --- a/deploy/nginx/default.conf +++ b/deploy/nginx/default.conf @@ -3,6 +3,7 @@ server { server_name _; location /api/ { + client_max_body_size 50m; proxy_pass http://api:8080/api/; proxy_http_version 1.1; proxy_set_header Host $host;