From 70dd2cec4ee4b528d34fe4f04f5919548a846982 Mon Sep 17 00:00:00 2001 From: zxyszx <299979470+zxyszx@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:29:03 +0800 Subject: [PATCH] feat: prepare v1.2.19 release --- .github/release-notes/v1.2.19.md | 6 + .github/workflows/ci.yml | 5 +- .github/workflows/docker.yml | 5 +- README.md | 61 ++++- apps/api/internal/app/app.go | 72 +++++ apps/api/internal/app/app_test.go | 114 +++++++- apps/api/internal/app/dns_handlers.go | 38 ++- apps/api/internal/app/mail_handlers.go | 33 ++- .../internal/app/mail_transfer_handlers.go | 13 +- apps/api/internal/app/telegram.go | 14 + apps/api/internal/app/telegram_test.go | 13 + apps/web/src/pages/admin.tsx | 28 +- apps/web/src/pages/mail.tsx | 85 ++++-- deploy/README.md | 4 +- deploy/rspamd/sync-dkim.sh | 25 +- docs/GUIDE.md | 4 +- docs/ISSUE_LEDGER.md | 140 ++++++++++ install.sh | 256 ++++++++++++------ tests/dkim_sync_test.sh | 54 ++++ tests/install_test.sh | 127 +++++++++ 20 files changed, 956 insertions(+), 141 deletions(-) create mode 100644 .github/release-notes/v1.2.19.md create mode 100755 tests/dkim_sync_test.sh diff --git a/.github/release-notes/v1.2.19.md b/.github/release-notes/v1.2.19.md new file mode 100644 index 0000000..b6c6e65 --- /dev/null +++ b/.github/release-notes/v1.2.19.md @@ -0,0 +1,6 @@ +- 优化“全部邮箱”写信:默认使用登录邮箱,可切换其他发件邮箱,切换时保留收件人、主题、正文和附件;写信窗口宽度同步调整。 +- 优化一键安装管理菜单:根据安装状态显示可用功能,补充运行状态、实际版本、访问地址和修复入口,并加强备份、回滚及命令检查。 +- 修复域名密钥变化后 Rspamd 可能继续使用旧 DKIM 私钥的问题;后台 DNS 检测现在会核对实际 DKIM 公钥。 +- 修复部分验证码邮件因收件邮箱或链接内容干扰而不显示验证码及复制按钮的问题。 +- 优化 DNS 记录复制:主机记录和记录值可分别复制,长 DKIM 记录能够正常换行显示。 +- 新邮箱默认创建“个人、家人、朋友、工作、重要”五个标签;已有邮箱升级后自动补齐,“全部邮箱”会合并同名标签并支持跨邮箱筛选与导出。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc74e12..7373eba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,9 +44,10 @@ jobs: run: | sudo apt-get update sudo apt-get install -y shellcheck sqlite3 - bash -n install.sh tests/install_test.sh - shellcheck -x install.sh tests/install_test.sh + bash -n install.sh tests/install_test.sh tests/dkim_sync_test.sh + shellcheck -x install.sh tests/install_test.sh tests/dkim_sync_test.sh deploy/rspamd/sync-dkim.sh bash tests/install_test.sh + bash tests/dkim_sync_test.sh - name: Setup pnpm uses: pnpm/action-setup@v4 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 49ae4b6..ec522ca 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -31,9 +31,10 @@ jobs: run: | sudo apt-get update sudo apt-get install -y shellcheck sqlite3 - bash -n install.sh tests/install_test.sh - shellcheck -x install.sh tests/install_test.sh + bash -n install.sh tests/install_test.sh tests/dkim_sync_test.sh + shellcheck -x install.sh tests/install_test.sh tests/dkim_sync_test.sh deploy/rspamd/sync-dkim.sh bash tests/install_test.sh + bash tests/dkim_sync_test.sh - name: Setup pnpm uses: pnpm/action-setup@v4 diff --git a/README.md b/README.md index 8f124d1..86b5012 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后 [![CI](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml/badge.svg)](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml) [![License](https://img.shields.io/github/license/zxyszx/NewSzxcn-Email)](LICENSE) -[邮箱指南](docs/GUIDE.md) · [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md) +[邮箱后台配置指南](docs/GUIDE.md) · [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md) ## 主要功能 @@ -35,8 +35,60 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install. bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh) ``` -脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件服务器域名、邮箱地址域名、管理员 -邮箱和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将 +### 管理面板 + +脚本会根据服务器当前状态显示不同菜单。空白服务器只显示安装和退出,避免误选尚不可用的更新、回滚或重启功能: + +```text +================================================== + NewSzxcn Email 管理面板 +================================================== +状态:尚未安装 +-------------------------------------------------- +1. 一键安装 NewSzxcn Email +0. 退出 +================================================== +请选择 [1]: +``` + +检测到已有安装后,会动态读取服务状态、实际镜像版本和访问地址,并默认选择安全更新: + +```text +================================================== + NewSzxcn Email 管理面板 +================================================== +状态:运行中 +版本:v1.2.19(示例,以实际安装版本为准) +地址:https://mail.example.com +-------------------------------------------------- +安装与维护 +1. 重新安装(完整备份,失败自动恢复) +2. 更新系统(自动备份,失败自动回滚) +3. 检查并修复现有安装 + +服务管理 +4. 查看运行状态 +5. 重启服务 +6. 查看实时日志 + +证书与恢复 +7. 管理 SSL 证书 +8. 回滚到上次更新前版本 + +账号与帮助 +9. 邮箱后台配置指南 +10. 查看管理员登录信息 +11. 重置管理员登录密码 + +危险操作 +12. 卸载服务(保留数据) + +0. 退出 +================================================== +请选择 [2]: +``` + +容器停止后菜单会显示“已停止”;配置存在但运行文件残缺时会显示“安装不完整”并默认选择修复。空白服务器进入安装后,会依次引导配置防火墙、邮件服务器域名、邮箱地址域名、管理员邮箱和 Web 部署方式。选择重新安装会先将 `/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会 校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复。 @@ -44,7 +96,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i - 安装或检查 Docker Engine 与 Docker Compose v2 - 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置 -- 分开确认邮件服务器域名和邮箱地址域名,创建唯一管理员邮箱;默认 `admin@邮箱地址域名`,回车自动生成 12 位密码,自定义密码最少 6 位 +- 自动检测并确认邮箱地址域名;创建管理员邮箱时可选择默认 `admin` 前缀或自行输入前缀,例如服务器域名 `mail.example.com`、前缀 `admin` 会创建 `admin@example.com`;回车自动生成 12 位密码,自定义密码最少 6 位 - 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式 - 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程 - 创建 `/opt/newszxcn-email` 持久化目录 @@ -87,6 +139,7 @@ sudo ns sudo newszxcn-email guide sudo newszxcn-email credentials sudo newszxcn-email reset-password +sudo newszxcn-email repair sudo newszxcn-email status sudo newszxcn-email logs sudo newszxcn-email restart diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 3547417..e41547d 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -705,6 +705,9 @@ func (a *App) migrate(ctx context.Context) error { if err := a.migrateTelegramNotifications(ctx); err != nil { return err } + if err := a.migrateDefaultMailLabels(ctx); err != nil { + return err + } if err := a.ensureDefaultPermissionGroups(ctx); err != nil { return err } @@ -722,6 +725,48 @@ func (a *App) migrateTelegramNotifications(ctx context.Context) error { return nil } +func (a *App) migrateDefaultMailLabels(ctx context.Context) error { + const marker = "defaultMailLabelsInitialized" + var initialized int + if err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM system_settings WHERE key=?`, marker).Scan(&initialized); err != nil { + return err + } + if initialized > 0 { + return nil + } + rows, err := a.db.QueryContext(ctx, `SELECT id FROM mailboxes ORDER BY id`) + if err != nil { + return err + } + var mailboxIDs []string + for rows.Next() { + var mailboxID string + if err := rows.Scan(&mailboxID); err != nil { + rows.Close() + return err + } + mailboxIDs = append(mailboxIDs, mailboxID) + } + if err := rows.Close(); err != nil { + return err + } + tx, err := a.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + now := a.now().UTC().Format(time.RFC3339Nano) + for _, mailboxID := range mailboxIDs { + if err := insertDefaultMailLabels(ctx, tx, mailboxID, now); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, marker, "true", now); err != nil { + return err + } + return tx.Commit() +} + func (a *App) initializeTelegramNotificationDefaults(ctx context.Context) error { now := a.now().UTC().Format(time.RFC3339Nano) var mailboxSettingExists int @@ -1769,6 +1814,30 @@ func defaultFolderDefs() []struct{ name, role string } { } } +type defaultMailLabel struct { + name string + color string +} + +func defaultMailLabelDefs() []defaultMailLabel { + return []defaultMailLabel{ + {name: "个人", color: "#10b981"}, + {name: "家人", color: "#ec4899"}, + {name: "朋友", color: "#06b6d4"}, + {name: "工作", color: "#3b82f6"}, + {name: "重要", color: "#f59e0b"}, + } +} + +func insertDefaultMailLabels(ctx context.Context, tx *sql.Tx, mailboxID, now string) error { + for _, label := range defaultMailLabelDefs() { + if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO mail_labels(id,mailbox_id,name,color,created_at,updated_at) VALUES(?,?,?,?,?,?)`, newID("lbl"), mailboxID, label.name, label.color, now, now); err != nil { + return err + } + } + return nil +} + func (a *App) createMailbox(ctx context.Context, userID, domainID, localPart, displayName, password string, quotaMB int, status string) (string, error) { passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { @@ -1826,6 +1895,9 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u return "", err } } + if err := insertDefaultMailLabels(ctx, tx, id, now); err != nil { + return "", err + } return id, nil } diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 658a6ca..52202b8 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -549,16 +549,26 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) { var labels struct { Items []MailLabel `json:"items"` } - if code := bob.do("GET", "/api/mail/labels?mailboxId="+mb2.ID, nil, &labels); code != http.StatusOK || len(labels.Items) != 1 || labels.Items[0].MessageCount != 1 { + if code := bob.do("GET", "/api/mail/labels?mailboxId="+mb2.ID, nil, &labels); code != http.StatusOK || len(labels.Items) != len(defaultMailLabelDefs()) { t.Fatalf("labels code=%d items=%+v", code, labels.Items) } + var importantLabel MailLabel + for _, label := range labels.Items { + if label.Name == "重要" { + importantLabel = label + break + } + } + if importantLabel.ID == "" || importantLabel.MessageCount != 1 { + t.Fatalf("important label missing or count is wrong: %+v", labels.Items) + } var labeled struct { Items []MailMessage `json:"items"` } - if code := bob.do("GET", "/api/mail/messages?mailboxId="+mb2.ID+"&labelId="+labels.Items[0].ID, nil, &labeled); code != http.StatusOK || len(labeled.Items) != 1 || labeled.Items[0].ID != detail.ID { + if code := bob.do("GET", "/api/mail/messages?mailboxId="+mb2.ID+"&labelId="+importantLabel.ID, nil, &labeled); code != http.StatusOK || len(labeled.Items) != 1 || labeled.Items[0].ID != detail.ID { t.Fatalf("labeled messages code=%d items=%+v", code, labeled.Items) } - if code := bob.do("DELETE", "/api/mail/messages/"+detail.ID+"/labels/"+labels.Items[0].ID, nil, &labelUpdate); code != http.StatusOK || len(labelUpdate.Labels) != 0 { + if code := bob.do("DELETE", "/api/mail/messages/"+detail.ID+"/labels/"+importantLabel.ID, nil, &labelUpdate); code != http.StatusOK || len(labelUpdate.Labels) != 0 { t.Fatalf("remove label code=%d labels=%+v", code, labelUpdate.Labels) } var starred struct { @@ -2020,6 +2030,16 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) { insertMessage("msg_multi_primary_read", primary.ID, primaryInboxID, "primary read", 1) insertMessage("msg_multi_primary_archived", primary.ID, primaryArchiveID, "primary archived unread", 0) insertMessage("msg_multi_secondary_unread", secondary.ID, secondaryInboxID, "secondary unread", 0) + var primaryImportantID, secondaryImportantID string + if err := a.db.QueryRowContext(ctx, `SELECT id FROM mail_labels WHERE mailbox_id=? AND name='重要'`, primary.ID).Scan(&primaryImportantID); err != nil { + t.Fatal(err) + } + if err := a.db.QueryRowContext(ctx, `SELECT id FROM mail_labels WHERE mailbox_id=? AND name='重要'`, secondary.ID).Scan(&secondaryImportantID); err != nil { + t.Fatal(err) + } + if _, err := a.db.ExecContext(ctx, `INSERT INTO message_labels(message_id,label_id,created_at) VALUES(?,?,?),(?,?,?)`, "msg_multi_primary_unread_1", primaryImportantID, now, "msg_multi_secondary_unread", secondaryImportantID, now); err != nil { + t.Fatal(err) + } userClient := &testClient{t: t, server: ts} if code := userClient.do("POST", "/api/auth/login", map[string]string{"email": primary.Address, "password": "Password123!"}, &login); code != http.StatusOK { @@ -2031,6 +2051,28 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) { if code := userClient.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 2 { t.Fatalf("my mailboxes code=%d items=%d", code, len(mine.Items)) } + var allLabels struct { + Items []MailLabel `json:"items"` + } + if code := userClient.do("GET", "/api/mail/labels?mailboxId=all", nil, &allLabels); code != http.StatusOK || len(allLabels.Items) != len(defaultMailLabelDefs()) { + t.Fatalf("all labels code=%d items=%+v", code, allLabels.Items) + } + var allImportant MailLabel + for _, label := range allLabels.Items { + if label.Name == "重要" { + allImportant = label + break + } + } + if allImportant.ID == "" || allImportant.MailboxID != "" || allImportant.MessageCount != 2 { + t.Fatalf("aggregated important label=%+v", allImportant) + } + var importantMessages struct { + Items []MailMessage `json:"items"` + } + if code := userClient.do("GET", "/api/mail/messages?mailboxId=all&labelId="+url.QueryEscape(allImportant.ID), nil, &importantMessages); code != http.StatusOK || len(importantMessages.Items) != 2 { + t.Fatalf("all important messages code=%d items=%+v", code, importantMessages.Items) + } unreadByAddress := map[string]int{} for _, item := range mine.Items { unreadByAddress[item.Address] = item.UnreadCount @@ -4760,6 +4802,72 @@ func TestDNSRecords(t *testing.T) { } } +func TestDefaultMailLabelsBackfillOrderAndDeletion(t *testing.T) { + a := newTestApp(t) + var mailboxID string + if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&mailboxID); err != nil { + t.Fatal(err) + } + if _, err := a.db.Exec(`DELETE FROM system_settings WHERE key='defaultMailLabelsInitialized'`); err != nil { + t.Fatal(err) + } + if _, err := a.db.Exec(`DELETE FROM mail_labels WHERE mailbox_id=?`, mailboxID); err != nil { + t.Fatal(err) + } + if err := a.migrateDefaultMailLabels(context.Background()); err != nil { + t.Fatal(err) + } + labels, err := a.labelsForMailbox(context.Background(), mailboxID) + if err != nil { + t.Fatal(err) + } + defaults := defaultMailLabelDefs() + if len(labels) != len(defaults) { + t.Fatalf("labels=%+v", labels) + } + for index, expected := range defaults { + if labels[index].Name != expected.name || labels[index].Color != expected.color { + t.Fatalf("label %d=%+v want name=%q color=%q", index, labels[index], expected.name, expected.color) + } + } + if _, err := a.db.Exec(`DELETE FROM mail_labels WHERE id=?`, labels[1].ID); err != nil { + t.Fatal(err) + } + if err := a.migrateDefaultMailLabels(context.Background()); err != nil { + t.Fatal(err) + } + labels, err = a.labelsForMailbox(context.Background(), mailboxID) + if err != nil { + t.Fatal(err) + } + if len(labels) != len(defaults)-1 { + t.Fatalf("deleted default label was restored: %+v", labels) + } +} + +func TestCheckDKIMRecordRequiresMatchingPublicKey(t *testing.T) { + tests := []struct { + name string + records []string + key string + ok bool + message string + }{ + {name: "matching", records: []string{"v=DKIM1; k=rsa; p=ABC123"}, key: "ABC123", ok: true, message: "DKIM 公钥匹配"}, + {name: "split whitespace", records: []string{"v=DKIM1; k=rsa; p=ABC 123\n456"}, key: "ABC123456", ok: true, message: "DKIM 公钥匹配"}, + {name: "wrong key", records: []string{"v=DKIM1; k=rsa; p=WRONG"}, key: "EXPECTED", ok: false, message: "DKIM 公钥与后台生成的记录不一致"}, + {name: "unrelated TXT", records: []string{"google-site-verification=token"}, key: "EXPECTED", ok: false, message: "未找到 DKIM 记录"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status := checkDKIMRecord(tt.records, tt.key) + if status.OK != tt.ok || status.Message != tt.message { + t.Fatalf("status=%+v", status) + } + }) + } +} + func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) { a := newTestApp(t) ts := httptest.NewServer(a.Router()) diff --git a/apps/api/internal/app/dns_handlers.go b/apps/api/internal/app/dns_handlers.go index 412d91b..b01ddfe 100644 --- a/apps/api/internal/app/dns_handlers.go +++ b/apps/api/internal/app/dns_handlers.go @@ -70,7 +70,7 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult { dkimName := d.DKIMSelector + "._domainkey." + d.Name dkimTXT, _ := resolver.LookupTXT(ctx, dkimName) - checks["dkim"] = txtContains(dkimTXT, "v=DKIM1", "DKIM 记录存在", "未找到 DKIM 记录") + checks["dkim"] = checkDKIMRecord(dkimTXT, d.DKIMPublicKey) dmarcTXT, _ := resolver.LookupTXT(ctx, "_dmarc."+d.Name) checks["dmarc"] = txtContains(dmarcTXT, "v=DMARC1", "DMARC 记录存在", "未找到 DMARC 记录") @@ -85,6 +85,42 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult { return DNSCheckResult{Domain: d.Name, Status: status, Checks: checks} } +func checkDKIMRecord(records []string, expectedPublicKey string) DNSCheckStatus { + found := append([]string{}, records...) + expectedPublicKey = compactDKIMPublicKey(expectedPublicKey) + dkimFound := false + for _, record := range records { + tags := map[string]string{} + for _, part := range strings.Split(record, ";") { + key, value, ok := strings.Cut(part, "=") + if !ok { + continue + } + tags[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value) + } + if !strings.EqualFold(tags["v"], "DKIM1") { + continue + } + dkimFound = true + if expectedPublicKey != "" && compactDKIMPublicKey(tags["p"]) == expectedPublicKey { + return DNSCheckStatus{OK: true, Message: "DKIM 公钥匹配", Found: found} + } + } + if dkimFound { + return DNSCheckStatus{OK: false, Message: "DKIM 公钥与后台生成的记录不一致", Found: found} + } + return DNSCheckStatus{OK: false, Message: "未找到 DKIM 记录", Found: found} +} + +func compactDKIMPublicKey(value string) string { + return strings.Map(func(r rune) rune { + if r == ' ' || r == '\t' || r == '\r' || r == '\n' { + return -1 + } + return r + }, value) +} + func txtContains(records []string, needle, okMsg, failMsg string) DNSCheckStatus { found := append([]string{}, records...) for _, item := range records { diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index 88e0994..0368e05 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -546,11 +546,12 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) { if isAllMailboxID(r.URL.Query().Get("mailboxId")) { user := currentUser(r) if labelID := strings.TrimSpace(r.URL.Query().Get("labelId")); labelID != "" { - if !a.labelBelongsToUser(r.Context(), labelID, user.ID) { + labelName, ok := a.labelNameForUser(r.Context(), labelID, user.ID) + if !ok { respondError(w, http.StatusNotFound, "label not found") return } - a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)`, []any{user.ID, labelID}) + a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id WHERE ml.message_id=m.id AND lower(l.name)=lower(?))`, []any{user.ID, labelName}) return } folder := r.URL.Query().Get("folder") @@ -2605,7 +2606,7 @@ func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLab FROM mail_labels l LEFT JOIN message_labels ml ON ml.label_id=l.id WHERE l.mailbox_id=? GROUP BY l.id,l.mailbox_id,l.name,l.color - ORDER BY lower(l.name)`, mailboxID) + ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, mailboxID) if err != nil { return nil, err } @@ -2622,13 +2623,13 @@ func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLab } func (a *App) labelsForUser(ctx context.Context, userID string) ([]MailLabel, error) { - rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color,COUNT(ml.message_id) + rows, err := a.db.QueryContext(ctx, `SELECT MIN(l.id),'',MIN(l.name),MIN(l.color),COUNT(ml.message_id) FROM mail_labels l JOIN mailboxes mb ON mb.id=l.mailbox_id LEFT JOIN message_labels ml ON ml.label_id=l.id WHERE mb.user_id=? AND mb.status='active' - GROUP BY l.id,l.mailbox_id,l.name,l.color - ORDER BY lower(l.name)`, userID) + GROUP BY lower(l.name) + ORDER BY `+mailLabelNameOrderSQL("MIN(l.name)")+`, lower(MIN(l.name))`, userID) if err != nil { return nil, err } @@ -2648,7 +2649,7 @@ func (a *App) labelsForMessage(ctx context.Context, messageID string) ([]MailLab rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color FROM mail_labels l JOIN message_labels ml ON ml.label_id=l.id WHERE ml.message_id=? - ORDER BY lower(l.name)`, messageID) + ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, messageID) if err != nil { return nil, err } @@ -2679,7 +2680,7 @@ func (a *App) attachLabelsToMessages(ctx context.Context, items []MailMessage) e rows, err := a.db.QueryContext(ctx, `SELECT ml.message_id,l.id,l.mailbox_id,l.name,l.color FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id WHERE ml.message_id IN (`+strings.Join(ids, ",")+`) - ORDER BY lower(l.name)`, args...) + ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, args...) if err != nil { return err } @@ -2697,6 +2698,14 @@ func (a *App) attachLabelsToMessages(ctx context.Context, items []MailMessage) e return rows.Err() } +func mailLabelOrderSQL(alias string) string { + return mailLabelNameOrderSQL(alias + `.name`) +} + +func mailLabelNameOrderSQL(expression string) string { + return `CASE ` + expression + ` WHEN '个人' THEN 10 WHEN '家人' THEN 20 WHEN '朋友' THEN 30 WHEN '工作' THEN 40 WHEN '重要' THEN 50 ELSE 100 END` +} + func (a *App) ensureLabel(ctx context.Context, mailboxID, name, color string) (MailLabel, error) { name = normalizeLabelName(name) if name == "" { @@ -2740,6 +2749,14 @@ func (a *App) labelBelongsToUser(ctx context.Context, labelID, userID string) bo return count > 0 } +func (a *App) labelNameForUser(ctx context.Context, labelID, userID string) (string, bool) { + var name string + if err := a.db.QueryRowContext(ctx, `SELECT l.name FROM mail_labels l JOIN mailboxes mb ON mb.id=l.mailbox_id WHERE l.id=? AND mb.user_id=? AND mb.status='active'`, labelID, userID).Scan(&name); err != nil { + return "", false + } + return name, true +} + func normalizeLabelName(name string) string { name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ") if len([]rune(name)) > 32 { diff --git a/apps/api/internal/app/mail_transfer_handlers.go b/apps/api/internal/app/mail_transfer_handlers.go index 1f605ff..01c31e8 100644 --- a/apps/api/internal/app/mail_transfer_handlers.go +++ b/apps/api/internal/app/mail_transfer_handlers.go @@ -122,8 +122,17 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) { if labelID == "" || !a.labelBelongsToUser(r.Context(), labelID, user.ID) { return nil, sql.ErrNoRows } - where = append(where, "EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)") - args = append(args, labelID) + if isAllMailboxID(mailboxID) { + labelName, ok := a.labelNameForUser(r.Context(), labelID, user.ID) + if !ok { + return nil, sql.ErrNoRows + } + where = append(where, "EXISTS (SELECT 1 FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id WHERE ml.message_id=m.id AND lower(l.name)=lower(?))") + args = append(args, labelName) + } else { + where = append(where, "EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)") + args = append(args, labelID) + } default: return nil, errors.New("unsupported mail view") } diff --git a/apps/api/internal/app/telegram.go b/apps/api/internal/app/telegram.go index fb55686..7d62dd9 100644 --- a/apps/api/internal/app/telegram.go +++ b/apps/api/internal/app/telegram.go @@ -473,6 +473,7 @@ func sanitizeTelegramAttachmentName(value string) string { var ( telegramOTPKeywordRe = regexp.MustCompile(`(?i)(验证码|校验码|动态码|登录码|安全码|一次性密码|otp|verification[ -]?code|security[ -]?code|login[ -]?code|passcode|one[ -]?time[ -]?(?:password|code))`) telegramOTPCandidateRe = regexp.MustCompile(`(?i)[a-z0-9]{4,10}`) + telegramEmailRe = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`) telegramURLRe = regexp.MustCompile(`(?i)https?://[^\s<>"']+`) ) @@ -489,7 +490,11 @@ func detectTelegramOTP(subject, body string) string { } scores := map[string]candidateScore{} subjectEnd := len(strings.TrimSpace(subject)) + excludedRanges := append(telegramEmailRe.FindAllStringIndex(text, -1), telegramURLRe.FindAllStringIndex(text, -1)...) for _, match := range telegramOTPCandidateRe.FindAllStringIndex(text, -1) { + if telegramRangeOverlaps(match, excludedRanges) { + continue + } if match[0] > 0 && isTelegramOTPAlphaNumeric(rune(text[match[0]-1])) { continue } @@ -561,6 +566,15 @@ func detectTelegramOTP(subject, body string) string { return items[0].value } +func telegramRangeOverlaps(candidate []int, ranges [][]int) bool { + for _, item := range ranges { + if len(item) == 2 && candidate[0] < item[1] && candidate[1] > item[0] { + return true + } + } + return false +} + func isTelegramOTPNonCode(value string) bool { if len(value) == 4 { if year, err := strconv.Atoi(value); err == nil && year >= 1900 && year <= 2099 { diff --git a/apps/api/internal/app/telegram_test.go b/apps/api/internal/app/telegram_test.go index 328f3cf..6694457 100644 --- a/apps/api/internal/app/telegram_test.go +++ b/apps/api/internal/app/telegram_test.go @@ -223,6 +223,19 @@ func TestTelegramOTPDetectionAndMessageBudget(t *testing.T) { } } +func TestTelegramIQiyiOTPDetection(t *testing.T) { + subject := "825534 是您的动态安全验证码" + body := "哈喽 iqiyi02@newszxcn.com 您正在进行爱奇艺账号的安全验证,以下是您的动态验证码:825534 如果这不是您的邮件,请忽略此邮件,请勿回复 手机·电视 其他 APP 在 LG, Samsung 等应用商店搜索 iQiyi 即可获得 Copyright © 2021 iQiyi All Rights Reserved" + otp := detectTelegramOTP(subject, body) + if otp != "825534" { + t.Fatalf("iQiyi OTP not detected: %q", otp) + } + message := formatTelegramMailMessage(telegramMailPayload{Subject: subject, From: "no_reply_intl@iq.com", Recipient: "iqiyi02@newszxcn.com", ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), Body: body, OTP: otp}) + if !strings.Contains(message.HTML, "825534") || telegramCopyMarkup(message.OTP) == nil { + t.Fatalf("iQiyi OTP section or copy button missing: %+v", message) + } +} + func TestTelegramForwardedGateOTPAndLinks(t *testing.T) { body := `---------- Forwarded message --------- Date: 2026年8月6日周四 17:59 diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 1203854..4f7bec9 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -2171,18 +2171,32 @@ function dnsDescription(record: DNSRecord): string { } function DNSRecordRow({ record }: { record: DNSRecord }) { - const { toast } = useToast(); const text = `${record.type} ${record.name} ${record.value}` + const { toast } = useToast() const desc = dnsDescription(record) + async function copyField(label: string, value: string) { + await navigator.clipboard.writeText(value) + toast({ title: `${label}已复制` }) + } return
-
+
{record.type} -
{desc &&

{desc}

} -
-
Name: {record.name}
-
Value: {record.value}
-
TTL: {record.ttl}s
+
+
+ 主机记录 + {record.name} + +
+
+ 记录值 + {record.value} + +
+
+ TTL + {record.ttl} 秒 +
} diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 706ccbf..d2a1eab 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -208,7 +208,13 @@ export function MailPage() { }, [mailboxList.data?.items, selectedMailboxId]) const isAllMailboxSelected = selectedMailboxId === "all" const activeMailboxId = selectedMailboxId === "all" ? "all" : selectedMailbox?.id || "" - const selectedComposeMailbox = selectedMailbox || (isAllMailboxSelected ? mailboxList.data?.items?.[0] : undefined) + const composeMailboxes = React.useMemo(() => (mailboxList.data?.items || []).filter((item) => item.status === "active"), [mailboxList.data?.items]) + const accountMailbox = React.useMemo(() => { + const loginEmail = user?.email.trim().toLowerCase() + if (!loginEmail) return undefined + return composeMailboxes.find((item) => item.address.trim().toLowerCase() === loginEmail) + }, [composeMailboxes, user?.email]) + const selectedComposeMailbox = selectedMailbox?.status === "active" ? selectedMailbox : (isAllMailboxSelected ? accountMailbox || composeMailboxes[0] : undefined) const hasMailboxes = (mailboxList.data?.items.length || 0) > 0 const canManageFolders = canOrganizeMail && hasMailboxes const showMailboxCopy = !!selectedMailbox && !isAllMailboxSelected @@ -797,11 +803,11 @@ export function MailPage() { } function openReply(message: MailMessage) { if (!canSendMail) return - openCompose({ key: `reply-${message.id}-${Date.now()}`, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) }) + openCompose({ key: `reply-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) }) } function openForward(message: MailMessage) { if (!canSendMail) return - openCompose({ key: `forward-${message.id}-${Date.now()}`, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) }) + openCompose({ key: `forward-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) }) } async function openDraft(message: MailMessage) { if (!canManageDrafts) return @@ -1549,7 +1555,7 @@ export function MailPage() { ) : compactMailLayout ? ( {selectedLabel.name} : undefined} + icon={mailView === "label" && selectedLabel ? {selectedLabel.name} : undefined} messages={visibleMessages} total={allMessages.length} selectedIds={compactSelectedIds} @@ -1739,7 +1745,7 @@ export function MailPage() {
{sidebarContent}
-
{mailView === "label" && selectedLabel ? {selectedLabel.name} : viewTitle}
+
{mailView === "label" && selectedLabel ? {selectedLabel.name} : viewTitle}
{mailTransferTools} {canSendMail && }
@@ -1762,7 +1768,7 @@ export function MailPage() { )} - { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }); qc.invalidateQueries({ queryKey: ["send-queue"] }) }} /> + { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }); qc.invalidateQueries({ queryKey: ["send-queue"] }) }} /> {labels.map((label) => { const active = (message.labels || []).some((item) => item.id === label.id) - const colors = generateLabelColor(label.name) return ( ) @@ -3445,10 +3450,9 @@ function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel,
{labels.map((label) => { - const colors = generateLabelColor(label.name) return ( - + {label.name} - {canSchedule && } - {canSend && } + {canSchedule && } + {canSend && } diff --git a/deploy/README.md b/deploy/README.md index f3d7eab..350a0c2 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -23,7 +23,7 @@ sudo newszxcn-email reset-2fa 一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。 -首次安装会依次询问防火墙模式、邮件服务器域名、邮箱地址域名、管理员邮箱/密码和 Web 部署方式。防火墙可以选择自动添加邮局必要端口规则或保留现有规则,不会清空服务器已有防火墙。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。管理员邮箱默认 `admin@邮箱地址域名`,自定义管理员密码最少 6 位,留空则生成 12 位密码。 +首次安装会依次询问防火墙模式和邮件服务器域名,自动检测邮箱地址域名,再选择默认 `admin` 前缀或自行创建管理员邮箱前缀,最后输入密码并选择 Web 部署方式。防火墙可以选择自动添加邮局必要端口规则或保留现有规则,不会清空服务器已有防火墙。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。例如服务器域名 `mail.newszxcn.com`、选择默认前缀会创建 `admin@newszxcn.com`;自定义管理员密码最少 6 位,留空则生成 12 位密码。 安装后输入 `ns` 可以打开统一管理菜单。更新前会创建包含数据库、镜像、Compose、环境、安装脚本和 Nginx 的回滚快照;更新或健康检查失败时会自动恢复。手动完整回滚前还会单独备份当前数据库,回滚镜像会保持锁定到下一次更新。 @@ -181,7 +181,7 @@ TELEGRAM_RELEASE_CHAT_ID - Dovecot 读取同一个 SQLite 数据库进行邮箱认证,并使用 `/var/mail/vhosts` 作为 Maildir 根目录。 - 第三方客户端可使用 IMAP SSL `993`、POP3 SSL `995`、SMTP SSL `465` 或 Submission `587`。 - Rspamd 通过 milter 接入 Postfix,负责 DKIM 签名和垃圾邮件标记。 -- Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim`。 +- Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim`;仅当密钥内容变化时重新载入签名配置,避免继续使用内存中的旧密钥。 - Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。 - Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。 - 第三方客户端可通过 LanQin API 提供的 SMTP `465/587` 发信;Webmail/API 和第三方客户端的“已发送”都由 API 写入,外发投递进入发送队列并由 API worker relay/retry,客户端后续 IMAP APPEND 到 Sent 会按 `Message-ID` 去重。 diff --git a/deploy/rspamd/sync-dkim.sh b/deploy/rspamd/sync-dkim.sh index af2bf41..cfa11a2 100644 --- a/deploy/rspamd/sync-dkim.sh +++ b/deploy/rspamd/sync-dkim.sh @@ -13,8 +13,20 @@ chown_dkim_dir() { fi } +reload_rspamd() { + if command -v rspamadm >/dev/null 2>&1 && rspamadm control reload >/dev/null 2>&1; then + echo "Rspamd reloaded after DKIM key update" + return 0 + fi + if command -v pkill >/dev/null 2>&1 && pkill -HUP -x rspamd 2>/dev/null; then + echo "Rspamd reloaded after DKIM key update" + fi +} + sync_keys() { + changed_marker="$LANQIN_RSPAMD_DKIM_DIR/.reload-required.$$" mkdir -p "$LANQIN_RSPAMD_DKIM_DIR" + rm -f "$changed_marker" if [ ! -f "$LANQIN_DB_PATH" ]; then chown_dkim_dir return 0 @@ -24,13 +36,22 @@ sync_keys() { [ -n "$domain" ] || continue [ -n "$selector" ] || selector="lanqin" keyfile="$LANQIN_RSPAMD_DKIM_DIR/${domain}.${selector}.key" - tmpfile="${keyfile}.tmp" + tmpfile="${keyfile}.tmp.$$" printf '%s' "$private_key" | base64 -d > "$tmpfile" chmod 0640 "$tmpfile" - mv "$tmpfile" "$keyfile" + if [ -f "$keyfile" ] && cmp -s "$tmpfile" "$keyfile"; then + rm -f "$tmpfile" + else + mv "$tmpfile" "$keyfile" + : > "$changed_marker" + fi done chown_dkim_dir + if [ -f "$changed_marker" ]; then + rm -f "$changed_marker" + reload_rspamd + fi } if [ "${1:-}" = "--once" ]; then diff --git a/docs/GUIDE.md b/docs/GUIDE.md index bccdd6f..ca275c7 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -1,4 +1,4 @@ -# NewSzxcn 邮箱指南 +# NewSzxcn 邮箱后台配置指南 本指南介绍 NewSzxcn Email 的安装入口、首次配置、邮箱申请、无人收件、SSL 证书和日常更新。管理员密码等敏感信息不会保存在本文档中。 @@ -10,7 +10,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh) ``` -安装脚本会依次询问防火墙配置、邮件服务器域名、邮箱地址域名、管理员邮箱和密码,以及 Web 部署方式。选择“自动配置 Nginx + SSL”时,脚本会安装 Nginx,并使用官方 `acme.sh` 申请 Let's Encrypt 证书。 +安装脚本会依次询问防火墙配置和邮件服务器域名,自动检测邮箱地址域名,再让你选择默认 `admin` 前缀或自定义管理员邮箱前缀,最后输入密码并选择 Web 部署方式。例如输入服务器域名 `mail.newszxcn.com`,确认检测结果 `@newszxcn.com`,选择 `1. 使用默认前缀 admin` 会创建 `admin@newszxcn.com`;选择 `2. 自定义管理员邮箱前缀` 后才需要输入邮箱账号前缀。选择“自动配置 Nginx + SSL”时,脚本会安装 Nginx,并使用官方 `acme.sh` 申请 Let's Encrypt 证书。 安装完成后,请记录终端中显示的访问地址、管理员邮箱和初始密码。初始密码仅在安装时显示;如果以后在后台修改密码,请以新密码为准。 diff --git a/docs/ISSUE_LEDGER.md b/docs/ISSUE_LEDGER.md index a7b37eb..75a4ac3 100644 --- a/docs/ISSUE_LEDGER.md +++ b/docs/ISSUE_LEDGER.md @@ -42,6 +42,12 @@ | NSX-20260806-005 | 2026-08-06 | 已完成 | 后端/通知;前端/设置;部署运维/CI | Telegram 私聊邮件通知与 Release 频道通知 | S3 | v1.2.16 | 随 v1.2.16 发布 | | NSX-20260806-006 | 2026-08-06 | 已完成 | 后端/通知;邮件核心;前端/设置;质量复核 | Telegram 邮件通知安全、验证码复制和可靠性复核 | S2 | v1.2.17 | 随 v1.2.17 发布 | | NSX-20260806-007 | 2026-08-06 | 已完成 | 前端/UI;后端/通知;部署运维/CI | 邮箱下拉层越界、验证码漏识别、邮件与版本通知链接样式 | S3 | v1.2.18 | 随 v1.2.18 发布 | +| NSX-20260807-008 | 2026-08-07 | 待验收 | 前端/UI;邮件发送;部署运维/安装 | 全部邮箱写信无法选择发件邮箱且默认项错误,写信窗口过宽;安装管理员邮箱流程需明确 | S3 | v1.2.19 | 待发布 | +| NSX-20260807-009 | 2026-08-07 | 待验收 | 部署运维/安装;文档;质量复核 | 一键安装主菜单未按安装状态区分,命令前置条件和状态显示需复核 | S2 | v1.2.19 | 待发布 | +| NSX-20260807-010 | 2026-08-07 | 待验收 | 邮件投递;DKIM;部署运维;质量复核 | Rspamd 在域名密钥变化后继续使用旧私钥,导致外发邮件 DKIM 验证失败 | S2 | v1.2.19 | 待发布 | +| NSX-20260807-011 | 2026-08-07 | 待验收 | 后端/通知;验证码识别;质量复核 | 邮件地址中的字母数字片段触发验证码候选冲突,导致 Telegram 不显示验证码与复制按钮 | S3 | v1.2.19 | 待发布 | +| NSX-20260807-012 | 2026-08-07 | 待验收 | 前端/UI;域名管理;质量复核 | DNS 记录复制按钮把类型、名称和值拼成整行,无法直接粘贴到域名服务商对应字段 | S3 | v1.2.19 | 待发布 | +| NSX-20260807-013 | 2026-08-07 | 待验收 | 邮件核心;前端/标签;数据迁移;质量复核 | 邮箱创建后没有常用默认标签,需要手动逐个建立 | S3 | v1.2.19 | 待发布 | ## NSX-20260804-001 @@ -276,3 +282,137 @@ | 2026-08-06 | 用户提供三张截图并确认本批修改范围,问题进入处理中。 | | 2026-08-06 | 完成实现、自动化回归和桌面端视觉验收,状态流转为待验收。 | | 2026-08-06 | v1.2.18 检查、六个 Docker 镜像、GitHub Release 和 Telegram 频道通知全部成功,状态流转为已完成。 | + +## NSX-20260807-008 + +| 字段 | 内容 | +| --- | --- | +| 编号 | NSX-20260807-008 | +| 日期 | 2026-08-07 | +| 状态 | 待验收 | +| 模块 | 前端/UI;邮件发送;部署运维/安装;质量复核 | +| 现象 | “全部邮箱”状态写信时固定使用列表第一项,无法选择发件邮箱;写信窗口在桌面端过宽;安装时邮件服务器域名、邮箱地址域名和管理员邮箱前缀的关系不够直观。 | +| 根因 | 全部邮箱状态直接将邮箱列表第一项传入写信组件,组件只支持只读展示单个邮箱;窗口最大宽度为 82rem。 | +| 实现 | “全部邮箱”写信默认优先匹配当前登录邮箱,并可在全部有效邮箱中切换;回复、转发沿用原邮件所属邮箱;发送、定时发送和草稿自动保存统一使用当前选择的发件邮箱;桌面写信窗口参照 Seek 收窄至最大 72rem;表单仅在新写信会话开始时初始化,切换邮箱或加载签名不会清空已填写内容;安装脚本回归测试明确覆盖 mail.newszxcn.com 对应 admin@newszxcn.com。 | +| 目标版本 | v1.2.19 | +| 测试结果 | 前端 check/build、Go 全量测试与 go vet、安装脚本测试、bash 语法检查、ShellCheck、git diff 检查均通过;本地双邮箱页面实测默认选择登录邮箱,下拉项完整,切换邮箱后收件人、主题、正文均保留,数据库确认草稿保存到新选择的邮箱;桌面截图确认写信窗口无溢出和遮挡。 | +| 发布状态 | 待发布。 | + +### 历史 + +| 时间 | 记录 | +| --- | --- | +| 2026-08-07 | 用户反馈全部邮箱写信的默认发件箱、邮箱选择和窗口宽度问题,进入处理中。 | +| 2026-08-07 | 完成写信邮箱选择、默认项、回复/转发邮箱、草稿归属、窗口宽度和安装管理员邮箱交互修改;自动化与页面验收通过,状态流转为待验收。 | + +## NSX-20260807-009 + +| 字段 | 内容 | +| --- | --- | +| 编号 | NSX-20260807-009 | +| 日期 | 2026-08-07 | +| 状态 | 待验收 | +| 模块 | 部署运维/安装;文档;质量复核 | +| 现象 | 空白服务器仍显示更新、回滚、重启等不可用操作;已安装菜单没有运行状态和实际版本;部分直接命令缺少统一安装前置检查;安装残缺时没有明确修复入口。 | +| 根因 | 主菜单只根据 `.env` 切换默认选项,所有状态共用一套菜单;运行状态、镜像版本和安装完整性没有独立判断;部分前置检查散落在菜单分发层。 | +| 实现 | 未安装服务器只显示一键安装和退出;已安装服务器按安装维护、服务管理、证书恢复、账号帮助、危险操作分组,动态显示运行状态、镜像版本和访问地址;安装残缺时默认进入修复;新增 `repair` 命令;服务命令统一校验 `.env` 与 Compose 文件;日志、状态、回滚和卸载的 Docker 检查收回各自函数;修复成功文案和缺失版本标签显示已纠正;README 加入两套主菜单示例。 | +| 兼容性 | 保持既有菜单编号 `1–12` 和 `ns` 快捷命令;现有配置、数据库、邮件、证书和更新流程不变。 | +| 测试结果 | Bash 语法检查、ShellCheck、安装脚本全量测试、菜单渲染/范围/分发/状态/版本/残缺安装/前置条件测试、前端 check/build、Go 全量测试和 go vet 均通过。 | +| 目标版本 | v1.2.19 | +| 发布状态 | 待发布。 | + +### 历史 + +| 时间 | 记录 | +| --- | --- | +| 2026-08-07 | 用户确认按安装状态拆分主菜单,并要求写入仓库介绍、复核全部命令逻辑。 | +| 2026-08-07 | 完成动态菜单、命令前置条件、残缺安装修复入口、README 和自动化回归,状态流转为待验收。 | + +## NSX-20260807-010 + +| 字段 | 内容 | +| --- | --- | +| 编号 | NSX-20260807-010 | +| 日期 | 2026-08-07 | +| 状态 | 待验收 | +| 模块 | 邮件投递;DKIM;部署运维;质量复核 | +| 现象 | Gmail 显示 SPF 和 DMARC 通过,但 NewSzxcn 发出的邮件 DKIM 验证失败;同内容的 NodeSeek 对照邮件三项认证均通过。 | +| 诊断 | NewSzxcn 邮件的 relaxed 正文哈希与 Gmail 收到的 `bh` 精确一致,排除正文传输修改;邮件签名无法由当前 DNS 公钥验证,说明发信时使用了不同私钥。`key not secure` 仅表示 DNSSEC 未验证,TXT 分段也属于正常 DNS 表示。 | +| 根因 | DKIM 同步任务会覆盖容器内密钥文件,但 Rspamd 已载入的签名密钥不会随文件替换自动更新,域名重建或密钥变化后可能继续使用内存中的旧私钥。 | +| 实现 | DKIM 同步改为先比较密钥内容;相同密钥不再重复替换,密钥新增或变化后立即重新载入 Rspamd;后台 DNS 检查从“仅判断 DKIM 记录存在”升级为核对实际 `p=` 公钥,明确区分缺失与公钥不一致;新增密钥同步和 DNS 公钥匹配回归,并纳入 CI 与发布检查。 | +| 兼容性 | 不轮换现有 DKIM 密钥、不修改 DNS;已有数据库、邮件和域名配置保持不变。 | +| 测试结果 | DKIM 同步专项测试、DNS 公钥匹配测试、Go 全量测试与 vet、安装脚本回归均通过;线上仍需升级后重新发信,确认 Gmail 原始邮件显示 `dkim=pass`。 | +| 目标版本 | v1.2.19 | +| 发布状态 | 待发布。 | + +### 历史 + +| 时间 | 记录 | +| --- | --- | +| 2026-08-07 | 用户提供 Gmail 原始邮件和 NodeSeek 对照邮件,完成正文哈希、签名公钥和认证结果比对。 | +| 2026-08-07 | 完成 DKIM 密钥热更新修复和专项回归,等待服务器实发验收。 | + +## NSX-20260807-011 + +| 字段 | 内容 | +| --- | --- | +| 编号 | NSX-20260807-011 | +| 日期 | 2026-08-07 | +| 状态 | 待验收 | +| 模块 | 后端/通知;验证码识别;质量复核 | +| 现象 | 爱奇艺邮件的主题和正文均包含验证码 `825534`,Telegram 通知却没有独立验证码区域和“复制验证码”按钮。 | +| 根因 | 正文开头的收件地址 `iqiyi02@newszxcn.com` 被拆成 `iqiyi02`、`newszxcn` 两个候选;它们与主题中的“验证码”距离较近,触发多候选歧义保护后返回空结果。 | +| 实现 | 验证码评分前排除邮箱地址和 HTTP/HTTPS 链接范围内的字母数字片段,保留真实正文与主题候选;新增爱奇艺原始场景回归,同时检查独立验证码区域和复制按钮。 | +| 测试结果 | 爱奇艺原始场景、Gate 验证码与链接、Telegram 消息预算专项测试及 Go 全量测试均通过。 | +| 目标版本 | v1.2.19 | +| 发布状态 | 待发布。 | + +### 历史 + +| 时间 | 记录 | +| --- | --- | +| 2026-08-07 | 用户提供 Telegram 实际通知截图,完成邮箱地址候选冲突复现。 | +| 2026-08-07 | 修复邮箱和链接候选排除逻辑,加入爱奇艺验证码专项回归。 | + +## NSX-20260807-012 + +| 字段 | 内容 | +| --- | --- | +| 编号 | NSX-20260807-012 | +| 日期 | 2026-08-07 | +| 状态 | 待验收 | +| 模块 | 前端/UI;域名管理;质量复核 | +| 现象 | DNS 记录顶部“复制”会得到 `TXT newszxcn.com v=spf1 mx -all`,不能直接粘贴到域名服务商的主机记录和记录值输入框。 | +| 实现 | 删除整行复制;每条记录明确展示记录类型、主机记录、记录值和 TTL;主机记录与记录值分别提供图标复制和对应成功提示,TTL 仅展示。 | +| 测试结果 | 前端 check/build 通过;本地页面确认 SPF 主机记录和记录值分别复制为 `lanqin.local` 与 `v=spf1 mx -all`,DKIM 长记录正常换行且弹窗没有横向溢出。 | +| 目标版本 | v1.2.19 | +| 发布状态 | 待发布。 | + +### 历史 + +| 时间 | 记录 | +| --- | --- | +| 2026-08-07 | 用户提供 DNS 弹窗截图并指出整行复制无法直接用于 DNS 面板。 | +| 2026-08-07 | 完成主机记录和记录值分离复制。 | + +## NSX-20260807-013 + +| 字段 | 内容 | +| --- | --- | +| 编号 | NSX-20260807-013 | +| 日期 | 2026-08-07 | +| 状态 | 待验收 | +| 模块 | 邮件核心;前端/标签;数据迁移;质量复核 | +| 需求 | 邮箱默认增加个人、家人、朋友、工作、重要五个常用标签,并按名称使用容易辨认的颜色。 | +| 实现 | 新邮箱创建时由后端事务生成五个标签;已有邮箱升级时一次性补齐;固定顺序为个人、家人、朋友、工作、重要,颜色依次为绿色、玫红、青色、蓝色、橙色;用户后续删除标签不会在重启时恢复;“全部邮箱”按名称合并同名标签并汇总数量,点击或导出时覆盖用户名下所有邮箱。 | +| 兼容性 | 使用 `INSERT OR IGNORE` 保留已有同名标签及其颜色和邮件关联;不修改用户自建标签。 | +| 测试结果 | 新邮箱创建、旧邮箱补齐、删除后不重建、多邮箱同名汇总与跨邮箱筛选专项测试,以及 Go 全量测试和 vet 均通过;页面确认五个标签各显示一次、顺序正确,颜色与侧栏宽度正确且无越界。 | +| 目标版本 | v1.2.19 | +| 发布状态 | 待发布。 | + +### 历史 + +| 时间 | 记录 | +| --- | --- | +| 2026-08-07 | 用户提供标签侧栏参考并指定五个默认标签。 | +| 2026-08-07 | 完成新邮箱默认生成、已有邮箱一次性补齐、固定排序和颜色回归;页面复核时发现全部邮箱重复显示,继续完成同名汇总、跨邮箱筛选与导出回归。 | diff --git a/install.sh b/install.sh index c7e9b95..12f4019 100755 --- a/install.sh +++ b/install.sh @@ -29,12 +29,13 @@ NewSzxcn Email 管理命令 menu 显示安装与运维菜单 install 首次安装;已有安装会先完整备份再重新安装 update 备份数据库并更新到最新版 + repair 检查并修复现有安装 status 查看容器与健康状态 logs 持续查看运行日志 restart 重启服务并重载 Nginx certificate 申请或续期自动模式的 SSL 证书 rollback 回滚到上次更新前版本 - guide 显示并更新 NewSzxcn 邮箱指南 + guide 显示并更新邮箱后台配置指南 credentials 查看管理员登录信息和记录密码 reset-password 重置管理员统一登录密码(含名下邮箱) reset-2fa 应急关闭唯一管理员双因素认证 @@ -202,6 +203,18 @@ env_value() { sed -n "s/^${key}=//p" "${INSTALL_DIR}/.env" | tail -n 1 } +installation_configured() { + [[ -f "${INSTALL_DIR}/.env" ]] +} + +installation_complete() { + installation_configured && [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] +} + +require_installation() { + installation_complete || fail "尚未完成安装,请先运行 newszxcn-email install;如果配置残缺,请运行 newszxcn-email repair。" +} + prompt_value() { local variable="$1" prompt="$2" default_value="$3" secret="${4:-false}" local value="${!variable:-}" @@ -318,8 +331,8 @@ prompt_mail_domain() { suggestion="$(suggest_mail_domain "${hostname}")" value="${LANQIN_MAIL_DOMAIN:-}" if [[ -z "${value}" ]] && has_tty; then - prompt_text "[提示] 邮件服务器域名是 ${hostname};邮箱地址域名可以使用 ${suggestion},请确认。\n" - read -r -p "邮箱地址域名 [${suggestion}]: " value /dev/null || warn "安装成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。" - success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)" + generate_guide >/dev/null || warn "修复成功,但邮箱后台配置指南生成失败,可稍后执行 newszxcn-email guide 重试。" + success "修复完成:$(env_value LANQIN_PUBLIC_BASE_URL)" warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。" - warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱指南。" + warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱后台配置指南。" } do_install() { @@ -1031,14 +1071,14 @@ do_install() { compose up -d --remove-orphans wait_for_health 90 || fail "服务未能通过健康检查,请执行 newszxcn-email logs 查看日志。" configure_web_mode - generate_guide >/dev/null || warn "安装成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。" + generate_guide >/dev/null || warn "安装成功,但邮箱后台配置指南生成失败,可稍后执行 newszxcn-email guide 重试。" success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)" warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。" - warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱指南。" + warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱后台配置指南。" } do_update() { - [[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装,请先执行 install。" + require_installation ensure_docker create_update_snapshot || fail "更新前备份失败,未修改现有安装。" stage_assets @@ -1063,11 +1103,12 @@ do_update() { fail "更新失败,已恢复到更新前版本。" fi ensure_cli_alias - generate_guide >/dev/null || warn "更新成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。" + generate_guide >/dev/null || warn "更新成功,但邮箱后台配置指南生成失败,可稍后执行 newszxcn-email guide 重试。" success "系统已更新,配置、邮件、证书和数据库均已保留。" } do_rollback() { + require_installation [[ -f "${ROLLBACK_POINTER}" ]] || fail "没有可用的完整回滚快照。" local confirm="${LANQIN_ROLLBACK_CONFIRM:-}" image timestamp emergency_backup if [[ -z "${confirm}" ]] && has_tty; then @@ -1085,7 +1126,7 @@ do_rollback() { } reload_services() { - [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || return 0 + require_installation ensure_docker compose restart lanqin-email >/dev/null if [[ -f "${NGINX_CONFIG}" ]]; then @@ -1100,14 +1141,14 @@ do_restart() { } do_certificate() { - [[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。" + require_installation [[ "$(env_value LANQIN_INSTALL_WEB_MODE || true)" == "1" ]] || fail "只有自动 Nginx + SSL 模式可使用此命令。" ensure_nginx write_nginx_http_config install_certificate write_nginx_https_config reload_services - generate_guide >/dev/null || warn "证书已应用,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。" + generate_guide >/dev/null || warn "证书已应用,但邮箱后台配置指南生成失败,可稍后执行 newszxcn-email guide 重试。" success "SSL 证书已安装并应用。" } @@ -1140,7 +1181,7 @@ generate_guide() { tmp="$(mktemp)" cat > "${tmp}" </dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then + printf '状态未知' + return + fi + container_id="$(compose ps -q lanqin-email 2>/dev/null | head -n 1 || true)" + if [[ -n "${container_id}" ]] && [[ "$(docker inspect --format '{{.State.Running}}' "${container_id}" 2>/dev/null || true)" == "true" ]]; then + printf '运行中' else - prompt_text ' 状态:未安装\n' + printf '已停止' fi - prompt_text '--------------------------------------------------\n' - prompt_text ' 1. 安装 / 重新安装(完整备份,失败自动恢复)\n' - prompt_text ' 2. 更新系统(数据库备份,失败自动回滚)\n' - prompt_text ' 3. 检查并修复现有安装\n' - prompt_text ' 4. 查看运行状态\n' - prompt_text ' 5. 重启服务\n' - prompt_text ' 6. 查看实时日志\n' - prompt_text ' 7. 申请、检查或续期 SSL 证书\n' - prompt_text ' 8. 回滚到上次更新前版本\n' - prompt_text ' 9. NewSzxcn 邮箱指南\n' - prompt_text ' 10. 查看管理员登录信息\n' - prompt_text ' 11. 重置管理员统一登录密码\n' - prompt_text ' 12. 卸载服务(保留数据)\n' - prompt_text ' 0. 退出\n' +} + +menu_installed_version() { + local image version + if ! installation_complete || ! command -v docker >/dev/null 2>&1; then + printf '未知' + return + fi + image="$(current_image_id 2>/dev/null || true)" + if [[ -n "${image}" ]]; then + version="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "${image}" 2>/dev/null || true)" + fi + [[ "${version:-}" == "" ]] && version="" + printf '%s' "${version:-未知}" +} + +render_uninstalled_menu() { + prompt_text '\n==================================================\n' + prompt_text ' NewSzxcn Email 管理面板\n' prompt_text '==================================================\n' + prompt_text '状态:尚未安装\n' + prompt_text '--------------------------------------------------\n' + prompt_text '1. 一键安装 NewSzxcn Email\n' + prompt_text '0. 退出\n' + prompt_text '==================================================\n' +} - choice="$(prompt_menu_choice "${default_choice}" "12")" - if [[ "${choice}" != "0" && "${choice}" != "1" && "${installed}" != "true" ]]; then - fail "尚未安装,请先选择 1。" +render_installed_menu() { + local status="$1" version="$2" public_url="$3" + prompt_text '\n==================================================\n' + prompt_text ' NewSzxcn Email 管理面板\n' + prompt_text '==================================================\n' + prompt_text "状态:${status}\n" + prompt_text "版本:${version}\n" + prompt_text "地址:${public_url:-未配置}\n" + prompt_text '--------------------------------------------------\n' + prompt_text '安装与维护\n' + prompt_text '1. 重新安装(完整备份,失败自动恢复)\n' + prompt_text '2. 更新系统(自动备份,失败自动回滚)\n' + prompt_text '3. 检查并修复现有安装\n\n' + prompt_text '服务管理\n' + prompt_text '4. 查看运行状态\n' + prompt_text '5. 重启服务\n' + prompt_text '6. 查看实时日志\n\n' + prompt_text '证书与恢复\n' + prompt_text '7. 管理 SSL 证书\n' + prompt_text '8. 回滚到上次更新前版本\n\n' + prompt_text '账号与帮助\n' + prompt_text '9. 邮箱后台配置指南\n' + prompt_text '10. 查看管理员登录信息\n' + prompt_text '11. 重置管理员登录密码\n\n' + prompt_text '危险操作\n' + prompt_text '12. 卸载服务(保留数据)\n\n' + prompt_text '0. 退出\n' + prompt_text '==================================================\n' +} + +do_menu() { + local default_choice="2" public_url="" choice status version + if ! installation_configured; then + render_uninstalled_menu + choice="$(prompt_menu_choice "1" "1")" || return 1 + case "${choice}" in + 0) success "已退出,未作任何修改。" ;; + 1) do_install ;; + esac + return fi + public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)" + status="$(menu_service_status)" + version="$(menu_installed_version)" + [[ "${status}" == "安装不完整" ]] && default_choice="3" + render_installed_menu "${status}" "${version}" "${public_url}" + + choice="$(prompt_menu_choice "${default_choice}" "12")" || return 1 case "${choice}" in 0) success "已退出,未作任何修改。" ;; 1) do_install ;; 2) do_update ;; 3) do_repair_install ;; - 4) ensure_docker; do_status ;; + 4) do_status ;; 5) do_restart ;; - 6) ensure_docker; compose logs -f --tail=200 lanqin-email updater ;; + 6) do_logs ;; 7) do_certificate ;; - 8) ensure_docker; do_rollback ;; + 8) do_rollback ;; 9) do_guide ;; 10) do_show_admin_credentials ;; 11) do_reset_admin_password ;; - 12) ensure_docker; do_uninstall ;; + 12) do_uninstall ;; esac } @@ -1486,16 +1589,17 @@ case "${COMMAND}" in menu) require_root; require_curl; do_menu ;; install) require_root; require_curl; do_install ;; update) require_root; require_curl; do_update ;; - status) require_root; require_curl; ensure_docker; do_status ;; - logs) require_root; require_curl; ensure_docker; compose logs -f --tail=200 lanqin-email updater ;; + repair) require_root; require_curl; do_repair_install ;; + status) require_root; require_curl; do_status ;; + logs) require_root; require_curl; do_logs ;; restart) require_root; require_curl; do_restart ;; reload) require_root; require_curl; reload_services ;; certificate) require_root; require_curl; do_certificate ;; - rollback) require_root; require_curl; ensure_docker; do_rollback ;; + rollback) require_root; require_curl; do_rollback ;; guide) require_root; require_curl; do_guide ;; credentials) require_root; require_curl; do_show_admin_credentials ;; reset-password) require_root; require_curl; do_reset_admin_password ;; reset-2fa) require_root; require_curl; do_reset_admin_two_factor ;; - uninstall) require_root; require_curl; ensure_docker; do_uninstall ;; + uninstall) require_root; require_curl; do_uninstall ;; *) usage; fail "未知命令:${COMMAND}" ;; esac diff --git a/tests/dkim_sync_test.sh b/tests/dkim_sync_test.sh new file mode 100755 index 0000000..edb1a9d --- /dev/null +++ b/tests/dkim_sync_test.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "${TEMP_DIR}"' EXIT + +fail_test() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +mkdir -p "${TEMP_DIR}/bin" "${TEMP_DIR}/keys" +touch "${TEMP_DIR}/lanqin.db" "${TEMP_DIR}/reload.log" + +cat > "${TEMP_DIR}/bin/sqlite3" <<'EOF' +#!/bin/sh +printf 'example.com|lanqin|%s\n' "$(cat "${FAKE_PRIVATE_KEY_FILE}")" +EOF +cat > "${TEMP_DIR}/bin/id" <<'EOF' +#!/bin/sh +exit 1 +EOF +cat > "${TEMP_DIR}/bin/rspamadm" <<'EOF' +#!/bin/sh +printf '%s\n' "$*" >> "${FAKE_RELOAD_LOG}" +EOF +cat > "${TEMP_DIR}/bin/pkill" <<'EOF' +#!/bin/sh +printf 'unexpected pkill fallback\n' >&2 +exit 1 +EOF +chmod 0755 "${TEMP_DIR}/bin/sqlite3" "${TEMP_DIR}/bin/id" "${TEMP_DIR}/bin/rspamadm" "${TEMP_DIR}/bin/pkill" + +export PATH="${TEMP_DIR}/bin:${PATH}" +export LANQIN_DB_PATH="${TEMP_DIR}/lanqin.db" +export LANQIN_RSPAMD_DKIM_DIR="${TEMP_DIR}/keys" +export FAKE_PRIVATE_KEY_FILE="${TEMP_DIR}/private-key.b64" +export FAKE_RELOAD_LOG="${TEMP_DIR}/reload.log" + +printf 'first-private-key' | base64 > "${FAKE_PRIVATE_KEY_FILE}" +sh "${ROOT_DIR}/deploy/rspamd/sync-dkim.sh" --once +[[ "$(cat "${TEMP_DIR}/keys/example.com.lanqin.key")" == "first-private-key" ]] || fail_test "initial DKIM key was not exported" +[[ "$(wc -l < "${FAKE_RELOAD_LOG}" | tr -d ' ')" == "1" ]] || fail_test "initial DKIM key did not reload Rspamd" + +sh "${ROOT_DIR}/deploy/rspamd/sync-dkim.sh" --once +[[ "$(wc -l < "${FAKE_RELOAD_LOG}" | tr -d ' ')" == "1" ]] || fail_test "unchanged DKIM key reloaded Rspamd" + +printf 'second-private-key' | base64 > "${FAKE_PRIVATE_KEY_FILE}" +sh "${ROOT_DIR}/deploy/rspamd/sync-dkim.sh" --once +[[ "$(cat "${TEMP_DIR}/keys/example.com.lanqin.key")" == "second-private-key" ]] || fail_test "changed DKIM key was not exported" +[[ "$(wc -l < "${FAKE_RELOAD_LOG}" | tr -d ' ')" == "2" ]] || fail_test "changed DKIM key did not reload Rspamd" + +printf 'DKIM sync tests passed.\n' diff --git a/tests/install_test.sh b/tests/install_test.sh index 90e4ce6..9fcb86a 100644 --- a/tests/install_test.sh +++ b/tests/install_test.sh @@ -46,6 +46,10 @@ test_password_validation() { test_mail_domain_and_admin_email_validation() { assert_eq "example.com" "$(suggest_mail_domain "mail.example.com")" "mail host domain suggestion" assert_eq "example.co.uk" "$(suggest_mail_domain "mail.example.co.uk")" "multi-label mail host domain suggestion" + assert_eq "newszxcn.com" "$(suggest_mail_domain "mail.newszxcn.com")" "NewSzxcn mail host domain suggestion" + assert_eq "admin@newszxcn.com" "$(LANQIN_ADMIN_EMAIL='' LANQIN_ADMIN_PREFIX='' prompt_admin_email "newszxcn.com")" "NewSzxcn default administrator email" + assert_eq "newszxcn.cm" "$(suggest_mail_domain "mail.newszxcn.cm")" "two-label suffix mail host domain suggestion" + assert_eq "admin@newszxcn.cm" "$(LANQIN_ADMIN_EMAIL='' LANQIN_ADMIN_PREFIX='' prompt_admin_email "newszxcn.cm")" "matching administrator email for entered domain" LANQIN_MAIL_DOMAIN="example.com" LANQIN_ADMIN_EMAIL="admin@example.com" assert_eq "example.com" "$(prompt_mail_domain "mail.example.com")" "explicit mail domain" @@ -147,6 +151,122 @@ test_menu_choice() { unset LANQIN_MENU_ACTION } +test_menu_rendering() ( + local output + prompt_text() { printf '%b' "$1"; } + + output="$(render_uninstalled_menu)" + [[ "${output}" == *'NewSzxcn Email 管理面板'* ]] || fail_test "uninstalled menu title missing" + [[ "${output}" == *'状态:尚未安装'* ]] || fail_test "uninstalled menu status missing" + [[ "${output}" == *'1. 一键安装 NewSzxcn Email'* ]] || fail_test "uninstalled menu install action missing" + [[ "${output}" != *'更新系统'* ]] || fail_test "uninstalled menu exposes update action" + [[ "${output}" != *'卸载服务'* ]] || fail_test "uninstalled menu exposes uninstall action" + + output="$(render_installed_menu "运行中" "v1.2.19" "https://mail.example.com")" + for expected in \ + '状态:运行中' \ + '版本:v1.2.19' \ + '地址:https://mail.example.com' \ + '安装与维护' \ + '服务管理' \ + '证书与恢复' \ + '账号与帮助' \ + '危险操作' \ + '9. 邮箱后台配置指南' \ + '12. 卸载服务(保留数据)'; do + [[ "${output}" == *"${expected}"* ]] || fail_test "installed menu item missing: ${expected}" + done +) + +test_menu_dispatch() ( + local temp_dir action_file LANQIN_MENU_ACTION=1 + temp_dir="$(mktemp -d)" + INSTALL_DIR="${temp_dir}/install" + action_file="${temp_dir}/action" + mkdir -p "${INSTALL_DIR}" + prompt_text() { :; } + do_install() { printf 'install\n' > "${action_file}"; } + + do_menu + grep -Fq 'install' "${action_file}" || fail_test "uninstalled menu did not dispatch install" + if (LANQIN_MENU_ACTION=2 do_menu >/dev/null 2>&1); then + fail_test "uninstalled menu accepted unavailable update action" + fi + + printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env" + printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml" + menu_service_status() { printf '运行中'; } + menu_installed_version() { printf 'v1.2.19'; } + do_update() { printf 'update\n' > "${action_file}"; } + LANQIN_MENU_ACTION=2 + do_menu + grep -Fq 'update' "${action_file}" || fail_test "installed menu did not dispatch update" + unset LANQIN_MENU_ACTION +) + +test_menu_runtime_metadata() ( + local temp_dir running="true" image_version="v1.2.19" + temp_dir="$(mktemp -d)" + INSTALL_DIR="${temp_dir}/install" + mkdir -p "${INSTALL_DIR}" + printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env" + printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml" + compose() { + if [[ "$*" == 'ps -q lanqin-email' ]]; then + printf 'container-id\n' + fi + } + current_image_id() { printf 'sha256:test-image\n'; } + docker() { + if [[ "$*" == 'compose version' ]]; then + return 0 + fi + if [[ "$*" == *'.State.Running'* ]]; then + printf '%s\n' "${running}" + return 0 + fi + if [[ "$*" == *'org.opencontainers.image.version'* ]]; then + printf '%s\n' "${image_version}" + fi + } + + assert_eq "运行中" "$(menu_service_status)" "running menu service status" + assert_eq "v1.2.19" "$(menu_installed_version)" "installed menu version" + running="false" + assert_eq "已停止" "$(menu_service_status)" "stopped menu service status" + image_version="" + assert_eq "未知" "$(menu_installed_version)" "missing image version label" +) + +test_incomplete_install_defaults_to_repair() ( + local temp_dir action_file LANQIN_MENU_ACTION=3 + temp_dir="$(mktemp -d)" + INSTALL_DIR="${temp_dir}/install" + action_file="${temp_dir}/action" + mkdir -p "${INSTALL_DIR}" + printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env" + prompt_text() { :; } + menu_installed_version() { printf '未知'; } + do_repair_install() { printf 'repair\n' > "${action_file}"; } + do_menu + grep -Fq 'repair' "${action_file}" || fail_test "incomplete installation did not dispatch repair" + unset LANQIN_MENU_ACTION +) + +test_service_commands_require_complete_installation() ( + local temp_dir command_name + temp_dir="$(mktemp -d)" + INSTALL_DIR="${temp_dir}/install" + mkdir -p "${INSTALL_DIR}" + # shellcheck disable=SC2329 + ensure_docker() { fail_test "service command checked Docker before installation"; } + for command_name in do_update do_status do_logs do_restart do_certificate do_rollback do_reset_admin_password do_reset_admin_two_factor do_uninstall; do + if ("${command_name}" >/dev/null 2>&1); then + fail_test "${command_name} accepted missing installation" + fi + done +) + test_admin_credentials() ( local temp_dir output temp_dir="$(mktemp -d)" @@ -186,6 +306,7 @@ LANQIN_MAIL_DOMAIN=example.com LANQIN_ADMIN_EMAIL=admin@example.com LANQIN_ADMIN_PASSWORD=old-password EOF + printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml" printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db" ensure_docker() { return 0; } @@ -226,6 +347,7 @@ LANQIN_PUBLIC_HOSTNAME=mail.example.com LANQIN_MAIL_DOMAIN=example.com LANQIN_ADMIN_EMAIL=admin@example.com EOF + printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml" printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db" ensure_docker() { return 0; } @@ -571,6 +693,11 @@ test_nginx_configuration test_compose_configuration test_legacy_configuration_is_preserved test_menu_choice +test_menu_rendering +test_menu_dispatch +test_menu_runtime_metadata +test_incomplete_install_defaults_to_repair +test_service_commands_require_complete_installation test_admin_credentials test_admin_password_hash_parsing test_admin_password_reset_only_updates_admin_account