Compare commits

..

2 Commits

Author SHA1 Message Date
zxyszx 39ff9ce01d fix: support batched mail imports
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 13:08:37 +08:00
zxyszx df50f8b3ef fix: reset mailbox view on refresh
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 12:55:21 +08:00
9 changed files with 99 additions and 18 deletions
+18
View File
@@ -0,0 +1,18 @@
## 本次更新
### 修复邮箱选择器默认状态
- 没有注册邮箱时,邮箱选择器明确显示“未注册邮箱”。
- 已有邮箱时,每次打开或刷新邮箱页面默认进入“全部邮箱”的“收件箱”,不再恢复上次选择的单个邮箱。
- 用户仍可在当前页面正常切换全部邮箱或单个邮箱,切换后统一返回收件箱。
### 修复邮箱下拉菜单宽度
- 邮箱下拉菜单现在与上方选择框等宽,不再向右多出一截。
- 搜索框、全部邮箱和邮箱地址均在相同宽度内对齐显示。
### 验证
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
**完整更新日志**[v1.2.7...v1.2.8](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.7...v1.2.8)
+25
View File
@@ -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)
+1 -1
View File
@@ -1 +1 @@
1.2.7
1.2.9
@@ -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: <imported@example.com>\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: <imported@example.com>\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: <older-imported@example.com>\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()
+1
View File
@@ -85,6 +85,7 @@ async function uploadForm<T>(path: string, form: FormData): Promise<T> {
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)
+1 -1
View File
@@ -70,7 +70,7 @@ const exactTranslations: Record<string, Translation> = {
"收起侧栏": { "zh-TW": "收合側欄", en: "Collapse sidebar" },
"选择邮箱": { "zh-TW": "選擇信箱", en: "Select mailbox" },
"加载邮箱...": { "zh-TW": "載入信箱...", en: "Loading mailboxes..." },
"未创建邮箱": { "zh-TW": "尚未建立信箱", en: "No mailbox created" },
"未注册邮箱": { "zh-TW": "未註冊信箱", en: "Unregistered mailbox" },
"没有可用邮箱": { "zh-TW": "沒有可用信箱", en: "No mailboxes available" },
"邮箱地址已复制": { "zh-TW": "信箱地址已複製", en: "Mailbox address copied" },
"打开导航": { "zh-TW": "開啟導覽", en: "Open navigation" },
+46 -12
View File
@@ -88,10 +88,28 @@ const filterLabels: Record<MailFilter, string> = {
const emptyAdvancedSearch: AdvancedMailSearch = { from: "", to: "", subject: "", startDate: "", endDate: "", hasAttachments: false, unread: false, starred: false }
const emptyAdvancedSearchDraft: AdvancedMailSearchDraft = { ...emptyAdvancedSearch }
const mailboxSelectionStorageVersion = "2"
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(() => {
@@ -122,10 +140,7 @@ export function MailPage() {
const [composeDraft, setComposeDraft] = React.useState<ComposeDraft | undefined>()
const sidebarCollapsed = false
const [mailFilter, setMailFilter] = React.useState<MailFilter>("all")
const [selectedMailboxId, setSelectedMailboxId] = React.useState(() => {
if (localStorage.getItem("lanqin:selected-mailbox-version") !== mailboxSelectionStorageVersion) return "all"
return localStorage.getItem("lanqin:selected-mailbox") || "all"
})
const [selectedMailboxId, setSelectedMailboxId] = React.useState("all")
const [selectedExternalAccountId, setSelectedExternalAccountId] = React.useState("")
const [expandedExternalAccountIds, setExpandedExternalAccountIds] = React.useState<string[]>([])
const [externalFolder, setExternalFolder] = React.useState("INBOX")
@@ -491,14 +506,19 @@ export function MailPage() {
}
if (!selectedMailboxId || (selectedMailboxId !== "all" && !items.some((item) => item.id === selectedMailboxId))) {
setSelectedMailboxId("all")
setSelectedExternalAccountId("")
setFolder("Inbox")
setMailView("folder")
setSelectedLabelId("")
setSelectedId(null)
setMailFilter("all")
}
}, [mailboxList.isSuccess, mailboxList.data?.items, selectedMailboxId])
React.useEffect(() => {
if (selectedMailboxId) localStorage.setItem("lanqin:selected-mailbox", selectedMailboxId)
else localStorage.removeItem("lanqin:selected-mailbox")
localStorage.setItem("lanqin:selected-mailbox-version", mailboxSelectionStorageVersion)
}, [selectedMailboxId])
localStorage.removeItem("lanqin:selected-mailbox")
localStorage.removeItem("lanqin:selected-mailbox-version")
}, [])
React.useEffect(() => {
setSelectedId(null)
@@ -1111,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 : "收件箱"}`,
@@ -3219,7 +3253,7 @@ function MailboxSwitcher({ collapsed, mailboxes, loading, selectedMailboxId, sel
const [mailboxQuery, setMailboxQuery] = React.useState("")
const isAllSelected = selectedMailboxId === "all"
const mailboxUnavailable = loading || mailboxes.length === 0
const displayAddress = loading ? "加载邮箱..." : mailboxes.length === 0 ? "未创建邮箱" : isAllSelected ? "全部邮箱" : selectedMailbox?.address || "选择邮箱"
const displayAddress = loading ? "加载邮箱..." : mailboxes.length === 0 ? "未注册邮箱" : isAllSelected ? "全部邮箱" : selectedMailbox?.address || "选择邮箱"
const selectedUnreadCount = isAllSelected ? unreadCount : (selectedMailbox?.unreadCount ?? unreadCount)
const normalizedQuery = mailboxQuery.trim().toLowerCase()
const showAllMailboxOption = !normalizedQuery || "全部邮箱".includes(normalizedQuery) || "all".includes(normalizedQuery)
@@ -3248,7 +3282,7 @@ function MailboxSwitcher({ collapsed, mailboxes, loading, selectedMailboxId, sel
align="start"
className={cn(
"max-w-[calc(100vw-32px)] p-1",
collapsed ? "w-[204px]" : "w-[calc(var(--radix-dropdown-menu-trigger-width)+2.375rem)] min-w-[calc(var(--radix-dropdown-menu-trigger-width)+2.375rem)]"
collapsed ? "w-[204px]" : "w-[var(--radix-dropdown-menu-trigger-width)] min-w-[var(--radix-dropdown-menu-trigger-width)]"
)}
>
{mailboxes.length > 0 && (
+1
View File
@@ -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;
+1
View File
@@ -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;