feat: prepare v1.2.19 release

This commit is contained in:
zxyszx
2026-08-07 13:29:03 +08:00
parent a9ec9360a8
commit 70dd2cec4e
20 changed files with 956 additions and 141 deletions
+6
View File
@@ -0,0 +1,6 @@
- 优化“全部邮箱”写信:默认使用登录邮箱,可切换其他发件邮箱,切换时保留收件人、主题、正文和附件;写信窗口宽度同步调整。
- 优化一键安装管理菜单:根据安装状态显示可用功能,补充运行状态、实际版本、访问地址和修复入口,并加强备份、回滚及命令检查。
- 修复域名密钥变化后 Rspamd 可能继续使用旧 DKIM 私钥的问题;后台 DNS 检测现在会核对实际 DKIM 公钥。
- 修复部分验证码邮件因收件邮箱或链接内容干扰而不显示验证码及复制按钮的问题。
- 优化 DNS 记录复制:主机记录和记录值可分别复制,长 DKIM 记录能够正常换行显示。
- 新邮箱默认创建“个人、家人、朋友、工作、重要”五个标签;已有邮箱升级后自动补齐,“全部邮箱”会合并同名标签并支持跨邮箱筛选与导出。
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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
+57 -4
View File
@@ -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
+72
View File
@@ -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
}
+111 -3
View File
@@ -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())
+37 -1
View File
@@ -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 {
+25 -8
View File
@@ -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 {
@@ -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")
}
+14
View File
@@ -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 {
+13
View File
@@ -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, "<code>825534</code>") || 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
+21 -7
View File
@@ -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 <div className="rounded-lg border bg-card p-3">
<div className="mb-2 flex items-center justify-between">
<div className="mb-2 flex items-center">
<Badge variant="outline" className="font-mono">{record.type}</Badge>
<Button size="sm" variant="ghost" className="h-7 gap-1 text-xs" onClick={() => { navigator.clipboard.writeText(text); toast({ title: "已复制" }) }}><Copy className="h-3.5 w-3.5" /></Button>
</div>
{desc && <p className="mb-2 text-xs text-muted-foreground">{desc}</p>}
<div className="break-all font-mono text-xs text-muted-foreground">
<div><span className="text-foreground">Name:</span> {record.name}</div>
<div><span className="text-foreground">Value:</span> {record.value}</div>
<div><span className="text-foreground">TTL:</span> {record.ttl}s</div>
<div className="space-y-1 font-mono text-xs text-muted-foreground">
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)_1.75rem] items-start gap-2">
<span className="pt-1 text-foreground"></span>
<code className="break-all pt-1 font-mono">{record.name}</code>
<Button type="button" size="icon" variant="ghost" className="h-7 w-7" aria-label="复制主机记录" title="复制主机记录" onClick={() => copyField("主机记录", record.name)}><Copy className="h-3.5 w-3.5" /></Button>
</div>
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)_1.75rem] items-start gap-2">
<span className="pt-1 text-foreground"></span>
<code className="break-all pt-1 font-mono">{record.value}</code>
<Button type="button" size="icon" variant="ghost" className="h-7 w-7" aria-label="复制记录值" title="复制记录值" onClick={() => copyField("记录值", record.value)}><Copy className="h-3.5 w-3.5" /></Button>
</div>
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-2">
<span className="text-foreground">TTL</span>
<code className="font-mono">{record.ttl} </code>
</div>
</div>
</div>
}
+55 -30
View File
@@ -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 ? (
<CompactMailView
title={viewTitle}
icon={mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: generateLabelColor(selectedLabel.name).backgroundColor }} />{selectedLabel.name}</Badge> : undefined}
icon={mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(selectedLabel) }} />{selectedLabel.name}</Badge> : undefined}
messages={visibleMessages}
total={allMessages.length}
selectedIds={compactSelectedIds}
@@ -1739,7 +1745,7 @@ export function MailPage() {
<div className="h-svh">{sidebarContent}</div>
</SheetContent>
</Sheet>
<div className="min-w-0 flex-1 text-sm font-semibold">{mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: generateLabelColor(selectedLabel.name).backgroundColor }} />{selectedLabel.name}</Badge> : viewTitle}</div>
<div className="min-w-0 flex-1 text-sm font-semibold">{mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(selectedLabel) }} />{selectedLabel.name}</Badge> : viewTitle}</div>
{mailTransferTools}
{canSendMail && <Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedComposeMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>}
<div className="relative basis-full">
@@ -1762,7 +1768,7 @@ export function MailPage() {
)}
</SidebarProvider>
<ComposeDialog mailbox={selectedComposeMailbox} open={composeOpen} draft={composeDraft} limits={user?.limits} canSend={canSendMail} canManageDrafts={canManageDrafts} canSchedule={canScheduleMail} canManageSignatures={canManageSignatures} onOpenChange={(open) => { 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"] }) }} />
<ComposeDialog mailboxes={composeMailboxes} mailbox={selectedComposeMailbox} open={composeOpen} draft={composeDraft} limits={user?.limits} canSend={canSendMail} canManageDrafts={canManageDrafts} canSchedule={canScheduleMail} canManageSignatures={canManageSignatures} onOpenChange={(open) => { 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"] }) }} />
<Input ref={mailImportInputRef} type="file" accept=".eml,.mbox,message/rfc822,application/mbox" multiple className="hidden" onChange={importSelectedMailFiles} />
<SendQueueAuditDialog
open={!!sendQueueAuditId}
@@ -2625,11 +2631,10 @@ function MessageContextMenu({ state, labels, folders, canSend, canOrganize, canM
<div className="max-h-44 overflow-y-auto">
{labels.map((label) => {
const active = (message.labels || []).some((item) => item.id === label.id)
const colors = generateLabelColor(label.name)
return (
<Button key={label.id} type="button" variant="ghost" className={itemClass} disabled={labelPending} onClick={() => toggleLabel(label)}>
<Check className={cn("h-4 w-4", active ? "opacity-100" : "opacity-0")} />
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: colors.backgroundColor }} />
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(label) }} />
<span className="min-w-0 flex-1 truncate text-left">{active ? `移除 ${label.name}` : label.name}</span>
</Button>
)
@@ -3445,10 +3450,9 @@ function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel,
<MessageMetaRow label="标签">
<div className="flex flex-wrap items-center gap-1.5">
{labels.map((label) => {
const colors = generateLabelColor(label.name)
return (
<Badge key={label.id} variant="outline" className="label-badge group/badge gap-1.5 rounded-md font-normal">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: colors.backgroundColor }} />
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(label) }} />
<span>{label.name}</span>
<button
type="button"
@@ -3476,7 +3480,6 @@ function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel,
{availableLabels.length === 0 && <DropdownMenuItem disabled></DropdownMenuItem>}
{availableLabels.map((label) => {
const active = labels.some((l) => l.id === label.id)
const colors = generateLabelColor(label.name)
return (
<DropdownMenuCheckboxItem
key={label.id}
@@ -3486,7 +3489,7 @@ function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel,
active ? onRemoveLabel(label.id) : onAddLabel(label)
}}
>
<span className="mr-2 h-2 w-2 rounded-full" style={{ backgroundColor: colors.backgroundColor }} />
<span className="mr-2 h-2 w-2 rounded-full" style={{ backgroundColor: labelDotColor(label) }} />
<span>{label.name}</span>
</DropdownMenuCheckboxItem>
)
@@ -3618,10 +3621,9 @@ function MessageRow({
}
function MailLabelBadge({ label }: { label: MailLabel }) {
const colors = generateLabelColor(label.name)
return (
<Badge variant="outline" className="shrink-0 gap-1.5 rounded-md font-normal">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(label) || colors.backgroundColor }} />
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(label) }} />
{label.name}
</Badge>
)
@@ -3631,9 +3633,12 @@ function labelDotColor(label: MailLabel) {
return label.color?.trim() || generateLabelColor(label.name).backgroundColor
}
function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts, canSchedule, canManageSignatures, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; limits?: PermissionLimits; canSend: boolean; canManageDrafts: boolean; canSchedule: boolean; canManageSignatures: boolean; onOpenChange: (v: boolean) => void; onSent: () => void }) {
function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canManageDrafts, canSchedule, canManageSignatures, onOpenChange, onSent }: { mailboxes: Mailbox[]; mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; limits?: PermissionLimits; canSend: boolean; canManageDrafts: boolean; canSchedule: boolean; canManageSignatures: boolean; onOpenChange: (v: boolean) => void; onSent: () => void }) {
const { toast } = useToast()
const qc = useQueryClient()
const initialMailboxId = mailboxes.find((item) => item.id === draft?.mailboxId)?.id || mailboxes.find((item) => item.id === mailbox?.id)?.id || mailboxes[0]?.id || ""
const [senderMailboxId, setSenderMailboxId] = React.useState(initialMailboxId)
const senderMailbox = React.useMemo(() => mailboxes.find((item) => item.id === senderMailboxId) || mailboxes[0], [mailboxes, senderMailboxId])
const [files, setFiles] = React.useState<File[]>([])
const [draftAttachments, setDraftAttachments] = React.useState<SendPayload["attachments"]>([])
const [attachmentsTouched, setAttachmentsTouched] = React.useState(false)
@@ -3648,14 +3653,15 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
const [sendIntent, setSendIntent] = React.useState<ComposeSendIntent | null>(null)
const sendStartedRef = React.useRef(false)
const lastSavedPayloadRef = React.useRef("")
const initializedSessionRef = React.useRef("")
const [showCc, setShowCc] = React.useState(Boolean(draft?.cc))
const [showBcc, setShowBcc] = React.useState(Boolean(draft?.bcc))
const [sendSeparately, setSendSeparately] = React.useState(false)
const defaultSignature = useQuery({ queryKey: ["signature", "default", mailbox?.id], queryFn: () => api.defaultSignature(mailbox?.id), enabled: open && !!mailbox?.id && canManageSignatures })
const defaultSignature = useQuery({ queryKey: ["signature", "default", senderMailbox?.id], queryFn: () => api.defaultSignature(senderMailbox?.id), enabled: open && !!senderMailbox?.id && canManageSignatures })
const signatureText = defaultSignature.data?.signature?.content || ""
const composerText = draft?.html || (draft?.text !== undefined ? draft.text : signatureText ? `\n\n-- \n${signatureText}` : "")
const [body, setBody] = React.useState<ComposerValue>(() => draft?.html !== undefined ? htmlComposerValue(draft.html) : plainTextComposerValue(composerText))
const activeMailboxId = draft?.mailboxId || mailbox?.id || ""
const activeMailboxId = senderMailbox?.id || ""
const maxAttachmentBytes = attachmentLimitBytes(limits)
const maxAttachmentText = maxAttachmentBytes > 0 ? formatBytes(maxAttachmentBytes) : "不限"
const composePayload = React.useMemo<DraftPayload>(() => ({
@@ -3707,13 +3713,20 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
})
React.useEffect(() => {
if (!open) return
if (!open) {
initializedSessionRef.current = ""
return
}
const sessionKey = draft?.key || draft?.id || "new"
if (initializedSessionRef.current === sessionKey) return
initializedSessionRef.current = sessionKey
sendStartedRef.current = false
const nextMailboxId = mailboxes.find((item) => item.id === draft?.mailboxId)?.id || mailboxes.find((item) => item.id === mailbox?.id)?.id || mailboxes[0]?.id || ""
const nextShowCc = Boolean(draft?.cc)
const nextShowBcc = Boolean(draft?.bcc)
const nextBody = draft?.html !== undefined ? htmlComposerValue(draft.html) : plainTextComposerValue(composerText)
const nextBody = draft?.html !== undefined ? htmlComposerValue(draft.html) : plainTextComposerValue(draft?.text || "")
lastSavedPayloadRef.current = JSON.stringify({
mailboxId: draft?.mailboxId || mailbox?.id || "",
mailboxId: nextMailboxId,
to: splitEmails(draft?.to || ""),
cc: nextShowCc ? splitEmails(draft?.cc || "") : [],
bcc: nextShowBcc ? splitEmails(draft?.bcc || "") : [],
@@ -3722,6 +3735,7 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
html: nextBody.html || plainTextToHtml(nextBody.text),
draftId: draft?.id || "",
})
setSenderMailboxId(nextMailboxId)
setDraftId(draft?.id || "")
setToValue(draft?.to || "")
setCcValue(draft?.cc || "")
@@ -3736,7 +3750,7 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
setFiles(draft?.files || [])
setDraftAttachments([])
setAttachmentsTouched(false)
}, [open, draft?.key, draft?.id, draft?.mailboxId, draft?.to, draft?.cc, draft?.bcc, draft?.subject, draft?.html, draft?.files, mailbox?.id, composerText])
}, [open, draft?.key, draft?.id, draft?.mailboxId, draft?.to, draft?.cc, draft?.bcc, draft?.subject, draft?.text, draft?.html, draft?.files, mailbox?.id, mailboxes])
React.useEffect(() => {
let cancelled = false
@@ -3814,7 +3828,7 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
async function prepareSend() {
if (!canSend) return
if (!mailbox) return
if (!senderMailbox) return
if (!attachmentsWithinLimit()) return
const attachments = await Promise.all(files.map(fileToAttachment))
const to = splitEmails(toValue)
@@ -3822,7 +3836,7 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
const bcc = showBcc ? splitEmails(bccValue) : []
const text = body.text
const html = body.html || plainTextToHtml(text)
const payload: SendPayload = { mailboxId: mailbox.id, to, cc, bcc, subject: subjectValue, text, html, attachments }
const payload: SendPayload = { mailboxId: senderMailbox.id, to, cc, bcc, subject: subjectValue, text, html, attachments }
const separateRecipients = Array.from(new Set([...to, ...cc, ...bcc]))
const payloads = sendSeparately && separateRecipients.length > 0
? separateRecipients.map((recipient): SendPayload => ({ ...payload, to: [recipient], cc: [], bcc: [] }))
@@ -3841,7 +3855,7 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
async function submit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!mailbox) {
if (!senderMailbox) {
toast({ title: "请选择发件邮箱" })
return
}
@@ -3849,14 +3863,14 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
}
async function scheduleAt(sendAt: string) {
if (!canSchedule) return
if (!mailbox) {
if (!senderMailbox) {
toast({ title: "请选择发件邮箱" })
return
}
if (!attachmentsWithinLimit()) return
const attachments = await Promise.all(files.map(fileToAttachment))
const payload: SendPayload & { draftId?: string; sendAt: string } = {
mailboxId: mailbox.id,
mailboxId: senderMailbox.id,
to: splitEmails(toValue),
cc: showCc ? splitEmails(ccValue) : [],
bcc: showBcc ? splitEmails(bccValue) : [],
@@ -3882,7 +3896,7 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex h-svh w-screen max-w-none overflow-hidden p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(96vw,82rem)]"
className="flex h-svh w-screen max-w-none overflow-hidden p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(92vw,72rem)]"
onInteractOutside={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
>
@@ -3897,7 +3911,18 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<ComposeField label="发件邮箱">
<Input value={mailbox?.address || "未选择"} readOnly className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" />
{mailboxes.length > 1 ? (
<Select value={senderMailbox?.id || ""} onValueChange={setSenderMailboxId}>
<SelectTrigger aria-label="发件邮箱" className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus:ring-0">
<SelectValue placeholder="选择发件邮箱" />
</SelectTrigger>
<SelectContent className="max-w-[calc(100vw-2rem)]">
{mailboxes.map((item) => <SelectItem key={item.id} value={item.id}>{item.address}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input value={senderMailbox?.address || "未选择"} readOnly className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" />
)}
</ComposeField>
<ComposeField
label="收件人"
@@ -3940,8 +3965,8 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
</div>
<DialogFooter className="grid grid-cols-3 gap-2 border-t bg-background px-4 py-3 sm:flex sm:flex-row sm:justify-end sm:px-6 sm:py-4">
<Button type="button" variant="outline" className="min-h-10 px-3" onClick={() => onOpenChange(false)}></Button>
{canSchedule && <Button type="button" variant="outline" className="min-h-10 px-3" disabled={send.isPending || scheduleSend.isPending || !mailbox} onClick={() => setScheduleDialogOpen(true)}><Calendar className="h-4 w-4" /></Button>}
{canSend && <Button className="min-h-10 px-4" disabled={send.isPending || !mailbox}><Send className="h-4 w-4" />{send.isPending ? "发送中..." : "发送"}</Button>}
{canSchedule && <Button type="button" variant="outline" className="min-h-10 px-3" disabled={send.isPending || scheduleSend.isPending || !senderMailbox} onClick={() => setScheduleDialogOpen(true)}><Calendar className="h-4 w-4" /></Button>}
{canSend && <Button className="min-h-10 px-4" disabled={send.isPending || !senderMailbox}><Send className="h-4 w-4" />{send.isPending ? "发送中..." : "发送"}</Button>}
</DialogFooter>
</form>
<ScheduleSendDialog open={scheduleDialogOpen} pending={scheduleSend.isPending} onOpenChange={setScheduleDialogOpen} onConfirm={scheduleAt} />
+2 -2
View File
@@ -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` 去重。
+23 -2
View File
@@ -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
+2 -2
View File
@@ -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 证书。
安装完成后,请记录终端中显示的访问地址、管理员邮箱和初始密码。初始密码仅在安装时显示;如果以后在后台修改密码,请以新密码为准。
+140
View File
@@ -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 加入两套主菜单示例。 |
| 兼容性 | 保持既有菜单编号 `112``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 | 完成新邮箱默认生成、已有邮箱一次性补齐、固定排序和颜色回归;页面复核时发现全部邮箱重复显示,继续完成同名汇总、跨邮箱筛选与导出回归。 |
+180 -76
View File
@@ -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/tty
prompt_text "[检测] 邮件服务器域名${hostname}\n[检测] 邮箱地址域名@${suggestion}\n"
read -r -p "邮箱地址域名 [${suggestion}](直接回车确认): " value </dev/tty
fi
value="${value:-${suggestion}}"
if [[ -z "${LANQIN_MAIL_DOMAIN:-}" && -z "${admin_email}" ]] && ! has_tty; then
@@ -342,10 +355,10 @@ prompt_admin_email() {
return
fi
if has_tty; then
prompt_text "\n创建管理员邮箱 [1]:\n1. 默认 admin,自动创建 admin@${mail_domain}\n2. 自定义前缀\n"
prompt_text "\n检测到邮箱地址域名:@${mail_domain}\n创建管理员邮箱 [1]:\n1. 使用默认前缀 admin\n2. 自定义管理员邮箱前缀\n"
choice="$(prompt_choice LANQIN_ADMIN_EMAIL_MODE "请选择 [1]: " "1" "2")"
if [[ "${choice}" == "2" ]]; then
prefix="$(prompt_value LANQIN_ADMIN_PREFIX "管理员邮箱前缀" "admin")"
prefix="$(prompt_value LANQIN_ADMIN_PREFIX "管理员邮箱账号前缀" "admin")"
else
prefix="admin"
fi
@@ -353,7 +366,9 @@ prompt_admin_email() {
prefix="${LANQIN_ADMIN_PREFIX:-admin}"
fi
valid_mail_local_part "${prefix}" || fail "管理员邮箱前缀格式不正确。"
printf '%s@%s' "$(lowercase "${prefix}")" "${mail_domain}"
email="$(lowercase "${prefix}")@${mail_domain}"
prompt_text "[提示] 将创建管理员邮箱:${email}\n"
printf '%s' "${email}"
}
ensure_admin_email_config() {
@@ -974,42 +989,67 @@ restore_update_snapshot() {
}
do_repair_install() {
installation_configured || fail "尚未安装,无法执行修复。"
local snapshot_created="false"
ensure_docker
create_update_snapshot || fail "修复前备份失败,未修改现有安装。"
if [[ -f "${INSTALL_DIR}/docker-compose.yml" ]]; then
create_update_snapshot || fail "修复前备份失败,未修改现有安装。"
snapshot_created="true"
else
warn "安装缺少 docker-compose.yml,将保留现有配置和数据并重新生成运行文件。"
fi
stage_assets
clear_runtime_image_pin
if ! apply_staged_assets || ! ensure_update_token || ! ensure_admin_email_config || ! configure_runtime_bindings; then
restore_update_snapshot "" false || true
fail "修复准备失败,已恢复原安装。"
if [[ "${snapshot_created}" == "true" ]]; then
restore_update_snapshot "" false || true
fail "修复准备失败,已恢复原安装。"
fi
fail "修复准备失败,原配置和数据未删除。"
fi
if ! (configure_firewall && prepare_directories); then
restore_update_snapshot "" false || true
fail "修复环境准备失败,已恢复原安装。"
if [[ "${snapshot_created}" == "true" ]]; then
restore_update_snapshot "" false || true
fail "修复环境准备失败,已恢复原安装。"
fi
fail "修复环境准备失败,原配置和数据未删除。"
fi
log "正在拉取并修复 NewSzxcn Email 服务..."
if ! compose pull; then
restore_update_snapshot "" false || true
fail "修复镜像拉取失败,已恢复原安装。"
if [[ "${snapshot_created}" == "true" ]]; then
restore_update_snapshot "" false || true
fail "修复镜像拉取失败,已恢复原安装。"
fi
fail "修复镜像拉取失败,原配置和数据未删除。"
fi
log "正在启动服务..."
if ! compose up -d --remove-orphans; then
warn "修复后容器启动失败,正在自动回滚。"
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已恢复到修复前版本。"
if [[ "${snapshot_created}" == "true" ]]; then
warn "修复后容器启动失败,正在自动回滚。"
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已恢复到修复前版本。"
fi
fail "修复后容器启动失败,请查看实时日志;原配置和数据未删除。"
fi
if ! wait_for_health 90; then
warn "修复后健康检查失败,正在自动回滚。"
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已恢复到修复前版本。"
if [[ "${snapshot_created}" == "true" ]]; then
warn "修复后健康检查失败,正在自动回滚。"
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已恢复到修复前版本。"
fi
fail "修复后健康检查失败,请查看实时日志;原配置和数据未删除。"
fi
if ! (configure_web_mode); then
restore_update_snapshot || fail "Web 配置失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "Web 配置失败,已恢复到修复前版本。"
if [[ "${snapshot_created}" == "true" ]]; then
restore_update_snapshot || fail "Web 配置失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "Web 配置失败,已恢复到修复前版本。"
fi
fail "Web 配置修复失败,原配置和数据未删除。"
fi
generate_guide >/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}" <<EOF
==================================================
NewSzxcn 邮箱指南
NewSzxcn 邮箱后台配置指南
==================================================
【安装信息】
@@ -1199,7 +1240,7 @@ EOF
}
do_guide() {
generate_guide || fail "尚未安装,无法生成邮箱指南。"
generate_guide || fail "尚未安装,无法生成邮箱后台配置指南。"
cat "${GUIDE_FILE}"
success "指南已更新并保存到 ${GUIDE_FILE}"
}
@@ -1233,7 +1274,7 @@ generate_admin_password_hash() {
}
do_reset_admin_password() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
require_installation
local admin_email password user_id hash image timestamp backup env_backup result user_changes mailbox_changes
ensure_docker
ensure_admin_email_config
@@ -1277,7 +1318,7 @@ do_reset_admin_password() {
}
do_reset_admin_two_factor() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
require_installation
local admin_email user_id image timestamp backup result user_changes recovery_changes challenge_changes
ensure_docker
ensure_admin_email_config
@@ -1306,7 +1347,8 @@ do_reset_admin_two_factor() {
}
do_status() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
require_installation
ensure_docker
compose ps
if wait_for_health 1; then
success "Web 与 API 健康检查正常。"
@@ -1315,8 +1357,15 @@ do_status() {
fi
}
do_logs() {
require_installation
ensure_docker
compose logs -f --tail=200 lanqin-email updater
}
do_uninstall() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
require_installation
ensure_docker
local confirm="${LANQIN_UNINSTALL_CONFIRM:-}" remove_renewal="${LANQIN_REMOVE_CERT_RENEWAL:-}" hostname
if [[ -z "${confirm}" ]] && has_tty; then
read -r -p "确认停止并卸载服务吗?邮件和配置将保留。[y/N]: " confirm </dev/tty
@@ -1415,58 +1464,112 @@ do_backup_reinstall() {
fail "重新安装失败,旧安装已自动恢复。失败的新安装保存在 ${failed_dir}"
}
do_menu() {
local installed="false" default_choice="1" public_url="" choice
if [[ -f "${INSTALL_DIR}/.env" ]]; then
installed="true"
default_choice="2"
public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)"
menu_service_status() {
local container_id
if ! installation_complete; then
printf '安装不完整'
return
fi
prompt_text '\n==================================================\n'
prompt_text ' NewSzxcn Email 一键安装与管理\n'
prompt_text '==================================================\n'
if [[ "${installed}" == "true" ]]; then
prompt_text " 状态:已安装\n 路径:${INSTALL_DIR}\n"
[[ -n "${public_url}" ]] && prompt_text " 地址:${public_url}\n"
if ! command -v docker >/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:-}" == "<no value>" ]] && 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
+54
View File
@@ -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'
+127
View File
@@ -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="<no value>"
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