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 与管理后
[](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
[](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}