Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6439b3c2dd | |||
| b982c30cd0 | |||
| aeaa151e90 | |||
| 60d87a6960 | |||
| 70dd2cec4e | |||
| a9ec9360a8 | |||
| 942605b2b6 |
@@ -0,0 +1,4 @@
|
||||
- 修复邮箱选择列表超过侧栏边框的问题,展开列表现在与上方选择框保持相同宽度。
|
||||
- 修复含日期年份的邮件可能漏识别验证码的问题,Gate 等验证码邮件可正常显示一键复制按钮。
|
||||
- 邮件通知中的网址改为可点击链接,超长追踪地址使用简短文字显示,阅读更清晰。
|
||||
- 版本频道通知移除底部按钮,改为正文中的“查看本次更新”文字链接。
|
||||
@@ -0,0 +1,6 @@
|
||||
- 优化“全部邮箱”写信:默认使用登录邮箱,可切换其他发件邮箱,切换时保留收件人、主题、正文和附件;写信窗口宽度同步调整。
|
||||
- 优化一键安装管理菜单:根据安装状态显示可用功能,补充运行状态、实际版本、访问地址和修复入口,并加强备份、回滚及命令检查。
|
||||
- 修复域名密钥变化后 Rspamd 可能继续使用旧 DKIM 私钥的问题;后台 DNS 检测现在会核对实际 DKIM 公钥。
|
||||
- 修复部分验证码邮件因收件邮箱或链接内容干扰而不显示验证码及复制按钮的问题。
|
||||
- 优化 DNS 记录复制:主机记录和记录值可分别复制,长 DKIM 记录能够正常换行显示。
|
||||
- 新邮箱默认创建“个人、家人、朋友、工作、重要”五个标签;已有邮箱升级后自动补齐,“全部邮箱”会合并同名标签并支持跨邮箱筛选与导出。
|
||||
@@ -0,0 +1,3 @@
|
||||
- 调整邮件转发规则:账号级转发固定作用于所有邮箱,并与单个邮箱追加的转发目标同时生效。
|
||||
- 优化单个邮箱转发设置:账号级目标默认勾选、置顶并锁定,邮箱及转发地址按数字和字母排序,目标较多时可查看完整列表。
|
||||
- 优化 Telegram 版本频道通知排版:使用精简标题、分层说明和完整更新链接,提升手机端阅读体验。
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -288,17 +289,21 @@ jobs:
|
||||
|
||||
sections = []
|
||||
for index, entry in enumerate(entries, 1):
|
||||
parts = re.split(r"[::]", entry, maxsplit=1)
|
||||
if len(parts) == 1:
|
||||
parts = re.split(r"[,;。]", entry, maxsplit=1)
|
||||
title = parts[0].strip()
|
||||
description = parts[1].strip() if len(parts) > 1 else ""
|
||||
section = f"<b>{index:02d} {html.escape(title)}</b>"
|
||||
section = f"<b>{index:02d} · {html.escape(title)}</b>"
|
||||
if description:
|
||||
section += "\n" + html.escape(description.rstrip("。") + "。")
|
||||
section += "\n<blockquote>" + html.escape(description.rstrip("。") + "。") + "</blockquote>"
|
||||
sections.append(section)
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
prefix = f"<b>NewSzxcn Email {html.escape(tag)}</b>\n新版本现已发布\n\n<b>本次更新</b>\n\n"
|
||||
available = max(0, 3600 - len(prefix))
|
||||
prefix = f"<b>NewSzxcn Email {html.escape(tag)}</b>\n<i>新版本现已发布</i>\n\n<b>更新内容 · {len(sections)} 项</b>\n\n"
|
||||
release_url = html.escape(os.environ["RELEASE_URL"], quote=True)
|
||||
footer = f'\n\n<a href="{release_url}">查看完整更新说明 ↗</a>'
|
||||
available = max(0, 3600 - len(prefix) - len(footer))
|
||||
visible_sections = []
|
||||
used = 0
|
||||
for section in sections:
|
||||
@@ -309,20 +314,18 @@ jobs:
|
||||
used += added
|
||||
body = "\n\n".join(visible_sections)
|
||||
if len(visible_sections) < len(sections):
|
||||
body += "\n\n更新内容较长,请点击下方按钮查看完整内容。"
|
||||
open("telegram-release-message.txt", "w", encoding="utf-8").write(prefix + body)
|
||||
body += "\n\n更新内容较长,请打开下方链接查看完整内容。"
|
||||
open("telegram-release-message.txt", "w", encoding="utf-8").write(prefix + body + footer)
|
||||
PY
|
||||
|
||||
jq -n \
|
||||
--arg chat_id "${TELEGRAM_CHAT_ID}" \
|
||||
--arg release_url "${RELEASE_URL}" \
|
||||
--rawfile text telegram-release-message.txt \
|
||||
'{
|
||||
chat_id:$chat_id,
|
||||
text:$text,
|
||||
parse_mode:"HTML",
|
||||
disable_web_page_preview:true,
|
||||
reply_markup:{inline_keyboard:[[{text:"查看本次更新 ↗",url:$release_url}]]}
|
||||
disable_web_page_preview:true
|
||||
}' > telegram-release-payload.json
|
||||
|
||||
http_code="$(curl -sS --retry 2 --retry-all-errors --connect-timeout 10 --max-time 30 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -2714,8 +2756,20 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
|
||||
if err := a.db.QueryRow(`SELECT recipients_json FROM send_queue WHERE source=? AND sent_message_id=?`, sendSourceForwarding, secondID).Scan(&recipientsJSON); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") || strings.Contains(recipientsJSON, "account-forward@example.test") || strings.Contains(recipientsJSON, "account-forward-two@example.test") {
|
||||
t.Fatalf("mailbox forwarding should override account target, recipients=%s", recipientsJSON)
|
||||
if !strings.Contains(recipientsJSON, "account-forward@example.test") || !strings.Contains(recipientsJSON, "account-forward-two@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") {
|
||||
t.Fatalf("mailbox forwarding should include account and mailbox targets, recipients=%s", recipientsJSON)
|
||||
}
|
||||
|
||||
if code := admin.do("POST", "/api/me/forwarding/account", map[string]any{"targetEmails": []string{"account-forward-two@example.test"}}, &settings); code != http.StatusOK {
|
||||
t.Fatalf("update account forwarding after mailbox forwarding code=%d settings=%+v", code, settings)
|
||||
}
|
||||
raw = []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: account changed\r\nMessage-ID: <account-changed@example.test>\r\n\r\nbody")
|
||||
thirdID := insertInbound("<account-changed@example.test>", "account changed", raw)
|
||||
if err := a.db.QueryRow(`SELECT recipients_json FROM send_queue WHERE source=? AND sent_message_id=?`, sendSourceForwarding, thirdID).Scan(&recipientsJSON); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(recipientsJSON, "account-forward@example.test") || !strings.Contains(recipientsJSON, "account-forward-two@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") {
|
||||
t.Fatalf("changing account forwarding should preserve mailbox targets, recipients=%s", recipientsJSON)
|
||||
}
|
||||
|
||||
loopRaw := []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: loop\r\n" + forwardingHeaderName + ": mail.example.test\r\nMessage-ID: <forward-loop@example.test>\r\n\r\nbody")
|
||||
@@ -2724,8 +2778,8 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
|
||||
if err := a.db.QueryRow(`SELECT COUNT(1) FROM send_queue WHERE source=?`, sendSourceForwarding).Scan(&queueCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if queueCount != 2 {
|
||||
t.Fatalf("forwarding queue count=%d, want 2", queueCount)
|
||||
if queueCount != 3 {
|
||||
t.Fatalf("forwarding queue count=%d, want 3", queueCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4760,6 +4814,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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -138,10 +138,7 @@ func (a *App) inboundForwardingTargets(ctx context.Context, mailboxID string) (t
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
targets := forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)
|
||||
if len(targets) == 0 {
|
||||
targets = forwardingTargetsFromStored(accountTarget, accountTargetsJSON)
|
||||
}
|
||||
targets := dedupeEmails(append(forwardingTargetsFromStored(accountTarget, accountTargetsJSON), forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)...))
|
||||
if len(targets) == 0 {
|
||||
return nil, userID, mailboxAddress, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -473,6 +473,8 @@ 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<>"']+`)
|
||||
)
|
||||
|
||||
func detectTelegramOTP(subject, body string) string {
|
||||
@@ -488,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
|
||||
}
|
||||
@@ -506,6 +512,9 @@ func detectTelegramOTP(subject, body string) string {
|
||||
if !hasDigit || telegramOTPKeywordRe.MatchString(value) {
|
||||
continue
|
||||
}
|
||||
if isTelegramOTPNonCode(value) {
|
||||
continue
|
||||
}
|
||||
best := 0
|
||||
for _, keyword := range keywords {
|
||||
distance := match[0] - keyword[1]
|
||||
@@ -557,6 +566,29 @@ 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 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(value) == 8 {
|
||||
if _, err := time.Parse("20060102", value); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isTelegramOTPAlphaNumeric(r rune) bool {
|
||||
return r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r))
|
||||
}
|
||||
@@ -723,7 +755,7 @@ func formatTelegramMailMessage(payload telegramMailPayload) telegramFormattedMes
|
||||
}
|
||||
prefix := strings.Join(lines, "\n") + "\n\n<b>" + label + "</b>\n<blockquote>"
|
||||
suffix := "</blockquote>"
|
||||
body = escapeTelegramWithinBudget(body, telegramMessageBudget-utf8.RuneCountInString(prefix)-utf8.RuneCountInString(suffix))
|
||||
body = formatTelegramBodyHTML(body, telegramMessageBudget-utf8.RuneCountInString(prefix)-utf8.RuneCountInString(suffix))
|
||||
lines = []string{prefix + body + suffix}
|
||||
}
|
||||
htmlText := strings.Join(lines, "\n")
|
||||
@@ -731,6 +763,73 @@ func formatTelegramMailMessage(payload telegramMailPayload) telegramFormattedMes
|
||||
return telegramFormattedMessage{HTML: htmlText, PlainText: plain, OTP: payload.OTP}
|
||||
}
|
||||
|
||||
func formatTelegramBodyHTML(value string, budget int) string {
|
||||
if budget <= 3 {
|
||||
return ""
|
||||
}
|
||||
var out strings.Builder
|
||||
used := 0
|
||||
truncated := false
|
||||
appendEscaped := func(text string) bool {
|
||||
for _, r := range text {
|
||||
escaped := html.EscapeString(string(r))
|
||||
length := utf8.RuneCountInString(escaped)
|
||||
if used+length > budget-3 {
|
||||
return false
|
||||
}
|
||||
out.WriteString(escaped)
|
||||
used += length
|
||||
}
|
||||
return true
|
||||
}
|
||||
last := 0
|
||||
for _, match := range telegramURLRe.FindAllStringIndex(value, -1) {
|
||||
if !appendEscaped(value[last:match[0]]) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
rawURL, trailing := trimTelegramURL(value[match[0]:match[1]])
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
if !appendEscaped(value[match[0]:match[1]]) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
last = match[1]
|
||||
continue
|
||||
}
|
||||
display := rawURL
|
||||
if utf8.RuneCountInString(display) > 72 {
|
||||
display = "🔗 " + parsed.Hostname() + " 链接"
|
||||
}
|
||||
anchor := `<a href="` + html.EscapeString(rawURL) + `">` + html.EscapeString(display) + `</a>`
|
||||
length := utf8.RuneCountInString(anchor)
|
||||
if used+length > budget-3 {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
out.WriteString(anchor)
|
||||
used += length
|
||||
if !appendEscaped(trailing) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
last = match[1]
|
||||
}
|
||||
if !truncated && last < len(value) && !appendEscaped(value[last:]) {
|
||||
truncated = true
|
||||
}
|
||||
if truncated {
|
||||
out.WriteString("...")
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func trimTelegramURL(value string) (string, string) {
|
||||
trimmed := strings.TrimRight(value, ".,;:!?)]},。;:!?)》】")
|
||||
return trimmed, value[len(trimmed):]
|
||||
}
|
||||
|
||||
func (a *App) sendTelegramMessage(ctx context.Context, token, chatID, text string) error {
|
||||
_, err := a.sendTelegramPayload(ctx, token, map[string]any{
|
||||
"chat_id": chatID,
|
||||
|
||||
@@ -223,6 +223,55 @@ 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
|
||||
Subject: 登录验证码 (https://www.gate.com)
|
||||
|
||||
Gate 检测到您的账号正试图从此 IP 获得登录验证码:
|
||||
IP: 87.83.105.229
|
||||
如为您本人登录,请输入如下验证码完成操作:
|
||||
311665
|
||||
如非本人操作,请点击此处禁用账户 <https://data.gate.com/track/click?token=abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789>`
|
||||
if otp := detectTelegramOTP("Fwd: 登录验证码 (https://www.gate.com)", body); otp != "311665" {
|
||||
t.Fatalf("forwarded Gate OTP not detected: %q", otp)
|
||||
}
|
||||
if otp := detectTelegramOTP("登录验证码", "日期 2026-08-06,验证码将在稍后发送"); otp != "" {
|
||||
t.Fatalf("year was incorrectly detected as OTP: %q", otp)
|
||||
}
|
||||
message := formatTelegramMailMessage(telegramMailPayload{
|
||||
From: "no-reply@alert.gate.com", Recipient: "admin@example.com", Subject: "登录验证码",
|
||||
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), Body: body, BodyMode: "full", OTP: "311665",
|
||||
})
|
||||
if !strings.Contains(message.HTML, `<a href="https://www.gate.com">https://www.gate.com</a>`) {
|
||||
t.Fatalf("normal URL was not linkified: %s", message.HTML)
|
||||
}
|
||||
if !strings.Contains(message.HTML, `>🔗 data.gate.com 链接</a>`) {
|
||||
t.Fatalf("long tracking URL was not shortened: %s", message.HTML)
|
||||
}
|
||||
if strings.Contains(message.HTML, "<a href=") || utf8.RuneCountInString(message.HTML) > telegramMessageBudget {
|
||||
t.Fatalf("generated Telegram HTML is invalid or too long: %s", message.HTML)
|
||||
}
|
||||
markup := telegramCopyMarkup(message.OTP)
|
||||
buttons, ok := markup["inline_keyboard"].([][]map[string]any)
|
||||
if !ok || len(buttons) != 1 || len(buttons[0]) != 1 || buttons[0][0]["text"] != "复制验证码" {
|
||||
t.Fatalf("copy OTP button missing: %#v", markup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramPseudoHTMLAndBodyCharset(t *testing.T) {
|
||||
pseudo := `<html><head><style>.hidden{display:none}</style></head><body><p>验证码:778899</p><div>欢迎登录</div></body></html>`
|
||||
text := telegramMessageBody(storedMessage{BodyText: pseudo})
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
+56
-31
@@ -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>
|
||||
)
|
||||
@@ -3292,7 +3297,7 @@ function MailboxSwitcher({ collapsed, mailboxes, loading, selectedMailboxId, sel
|
||||
align="start"
|
||||
className={cn(
|
||||
"max-w-[calc(100vw-32px)] p-1",
|
||||
collapsed ? "w-[204px]" : "w-[21rem] min-w-[var(--radix-dropdown-menu-trigger-width)]"
|
||||
collapsed ? "w-[204px]" : "w-[var(--radix-dropdown-menu-trigger-width)] min-w-0"
|
||||
)}
|
||||
>
|
||||
{mailboxes.length > 0 && (
|
||||
@@ -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} />
|
||||
|
||||
+119
-26
@@ -50,6 +50,8 @@ const accountSettingTabs: { key: AccountSettingsTab; label: string }[] = [
|
||||
{ key: "clients", label: "通知与客户端" },
|
||||
{ key: "security", label: "安全" },
|
||||
]
|
||||
const forwardingTargetCollator = new Intl.Collator("en", { sensitivity: "base", numeric: true })
|
||||
|
||||
export function ProfilePage() {
|
||||
const me = useMe()
|
||||
const qc = useQueryClient()
|
||||
@@ -1093,13 +1095,14 @@ function MailboxManagement({
|
||||
const [mailboxSearch, setMailboxSearch] = React.useState("")
|
||||
const [forwardingMailbox, setForwardingMailbox] = React.useState<Mailbox | null>(null)
|
||||
const [forwardDraft, setForwardDraft] = React.useState<string[]>([])
|
||||
const [forwardingTargetPreview, setForwardingTargetPreview] = React.useState<{ title: string; subtitle: string; source: string; targets: string[] } | null>(null)
|
||||
const [accountForwardTargets, setAccountForwardTargets] = React.useState<string[]>([])
|
||||
const [verifiedDialogOpen, setVerifiedDialogOpen] = React.useState(false)
|
||||
const [verifiedEmailDraft, setVerifiedEmailDraft] = React.useState("")
|
||||
const [pendingExternalDelete, setPendingExternalDelete] = React.useState<ExternalImapAccount | null>(null)
|
||||
const forwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: mailboxes.length > 0 })
|
||||
const verifiedEmailItems = forwarding.data?.verifiedEmails || []
|
||||
const verifiedEmails = React.useMemo(() => verifiedEmailItems.filter((item) => item.verified).map((item) => item.email), [verifiedEmailItems])
|
||||
const verifiedEmailItems = React.useMemo(() => [...(forwarding.data?.verifiedEmails || [])].sort((a, b) => forwardingTargetCollator.compare(a.email, b.email)), [forwarding.data?.verifiedEmails])
|
||||
const verifiedEmails = React.useMemo(() => sortForwardingTargets(verifiedEmailItems.filter((item) => item.verified).map((item) => item.email)), [verifiedEmailItems])
|
||||
const hasPendingVerifiedEmails = verifiedEmailItems.some((item) => !item.verified)
|
||||
const mailboxForwards = React.useMemo<Record<string, string[]>>(() => {
|
||||
const next: Record<string, string[]> = {}
|
||||
@@ -1113,9 +1116,10 @@ function MailboxManagement({
|
||||
const domainOptions = applyOptions?.domains || []
|
||||
const selectedDomain = domainOptions.find((domain) => domain.id === domainId) || domainOptions[0]
|
||||
const selectedMailbox = mailboxes.find((mailbox) => mailbox.id === selectedMailboxId) || mailboxes[0]
|
||||
const sortedMailboxes = React.useMemo(() => [...mailboxes].sort((a, b) => forwardingTargetCollator.compare(a.address, b.address)), [mailboxes])
|
||||
const filteredMailboxes = normalizedMailboxSearch
|
||||
? mailboxes.filter((mailbox) => mailbox.address.toLowerCase().includes(normalizedMailboxSearch))
|
||||
: mailboxes
|
||||
? sortedMailboxes.filter((mailbox) => mailbox.address.toLowerCase().includes(normalizedMailboxSearch))
|
||||
: sortedMailboxes
|
||||
const setForwardingCache = React.useCallback((settings: ForwardingSettings) => {
|
||||
qc.setQueryData(["forwarding-settings"], settings)
|
||||
}, [qc])
|
||||
@@ -1203,13 +1207,14 @@ function MailboxManagement({
|
||||
}
|
||||
|
||||
function openMailboxForward(mailbox: Mailbox) {
|
||||
const mailboxTargets = mailboxForwards[mailbox.id]
|
||||
setForwardingMailbox(mailbox)
|
||||
setForwardDraft(mailboxForwards[mailbox.id] || [])
|
||||
setForwardDraft(withoutForwardingTargets(mailboxTargets || [], accountForwardTargets))
|
||||
}
|
||||
|
||||
function saveMailboxForward() {
|
||||
if (!forwardingMailbox) return
|
||||
saveMailboxForwarding.mutate({ mailboxId: forwardingMailbox.id, targetEmails: forwardDraft })
|
||||
saveMailboxForwarding.mutate({ mailboxId: forwardingMailbox.id, targetEmails: withoutForwardingTargets(forwardDraft, accountForwardTargets) })
|
||||
}
|
||||
|
||||
function submitVerifiedEmail(event: React.FormEvent<HTMLFormElement>) {
|
||||
@@ -1275,9 +1280,11 @@ function MailboxManagement({
|
||||
<div className="divide-y">
|
||||
{filteredMailboxes.map((mailbox) => {
|
||||
const forwardTargets = mailboxForwards[mailbox.id] || []
|
||||
const accountForwardTargetActive = forwardTargets.length === 0 && accountForwardTargets.length > 0
|
||||
const effectiveForwardTargets = forwardTargets.length > 0 ? forwardTargets : accountForwardTargetActive ? accountForwardTargets : []
|
||||
const effectiveForwardTargets = mergeForwardingTargets(accountForwardTargets, forwardTargets)
|
||||
const forwardingActive = effectiveForwardTargets.length > 0
|
||||
const forwardingSource = accountForwardTargets.length > 0 && forwardTargets.length > 0 ? "账号级 + 邮箱单独" : accountForwardTargets.length > 0 ? "账号级" : "邮箱单独"
|
||||
const forwardingSubtitle = accountForwardTargets.length > 0 && forwardTargets.length > 0 ? "账号级目标固定生效,并追加邮箱单独目标" : accountForwardTargets.length > 0 ? "继承账号级转发" : "邮箱单独转发"
|
||||
const forwardingPrefix = accountForwardTargets.length > 0 && forwardTargets.length > 0 ? "转发:账号级+单独" : accountForwardTargets.length > 0 ? "转发:使用账号级" : "转发:"
|
||||
return (
|
||||
<div key={mailbox.id} className={cn("grid gap-3 px-6 py-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center", selectedMailboxId === mailbox.id && "bg-muted/50")}>
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
@@ -1291,8 +1298,13 @@ function MailboxManagement({
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>创建于 {formatDateTime(mailbox.createdAt)}</span>
|
||||
{forwardTargets.length > 0 && <span className="max-w-full truncate font-semibold text-foreground">转发:{forwardTargets.join("、")}</span>}
|
||||
{accountForwardTargetActive && <span className="max-w-full truncate font-semibold text-foreground">转发:使用账号级 {accountForwardTargets.join("、")}</span>}
|
||||
{effectiveForwardTargets.length > 0 && (
|
||||
<ForwardingTargetSummary
|
||||
prefix={forwardingPrefix}
|
||||
targets={effectiveForwardTargets}
|
||||
onView={() => setForwardingTargetPreview({ title: mailbox.address, subtitle: forwardingSubtitle, source: forwardingSource, targets: effectiveForwardTargets })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1313,7 +1325,7 @@ function MailboxManagement({
|
||||
</div>
|
||||
<div className="rounded-xl bg-muted/20 px-5 py-5">
|
||||
<div className="mb-3 text-sm font-medium">账号级转发</div>
|
||||
<div className="mb-4 text-sm text-muted-foreground">对所有邮箱生效,可同时转发到多个已验证邮箱;邮箱单独设置优先级更高</div>
|
||||
<div className="mb-4 text-sm text-muted-foreground">对所有邮箱生效,可同时转发到多个已验证邮箱;单个邮箱可继续追加自己的转发目标</div>
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_72px] md:items-start">
|
||||
<ForwardingTargetPicker emails={verifiedEmails} selected={accountForwardTargets} onChange={setAccountForwardTargets} disabled={forwardingBusy} />
|
||||
<Button type="button" className="h-[37px]" disabled={forwardingBusy} onClick={() => saveAccountForwarding.mutate(accountForwardTargets)}>{saveAccountForwarding.isPending ? "保存中" : "保存"}</Button>
|
||||
@@ -1333,7 +1345,7 @@ function MailboxManagement({
|
||||
</div>
|
||||
)}
|
||||
{verifiedEmails.length === 0 && <p className="mt-4 text-sm text-muted-foreground">暂未添加验证邮箱,请先点击「管理验证邮箱」添加。</p>}
|
||||
<p className="mt-3 text-sm text-muted-foreground">提示:每个邮箱可单独设置多个转发目标(点击邮箱列表中的「转发」按钮),单独设置会覆盖账号级配置。</p>
|
||||
<p className="mt-3 text-sm text-muted-foreground">提示:点击邮箱列表中的「转发」按钮,可在账号级目标之外追加该邮箱自己的转发目标。</p>
|
||||
</section>
|
||||
|
||||
{externalImapEnabled && (
|
||||
@@ -1390,16 +1402,42 @@ function MailboxManagement({
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">转发到</Label>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_64px_64px] items-start gap-2">
|
||||
<ForwardingTargetPicker emails={verifiedEmails} selected={forwardDraft} onChange={setForwardDraft} disabled={forwardingBusy} />
|
||||
<ForwardingTargetPicker emails={verifiedEmails} selected={forwardDraft} lockedSelected={accountForwardTargets} lockedLabel="账号级" onChange={(targets) => setForwardDraft(withoutForwardingTargets(targets, accountForwardTargets))} disabled={forwardingBusy} />
|
||||
<Button type="button" variant="outline" className="h-[37px] px-0" disabled={forwardingBusy} onClick={() => setForwardingMailbox(null)}>取消</Button>
|
||||
<Button type="button" className="h-[37px] px-0" disabled={forwardingBusy} onClick={saveMailboxForward}>{saveMailboxForwarding.isPending ? "保存中" : "保存"}</Button>
|
||||
</div>
|
||||
{accountForwardTargets.length > 0 && <p className="text-xs text-muted-foreground">账号级目标已置顶锁定,不能在单个邮箱里取消;下方可追加邮箱单独目标。</p>}
|
||||
</div>
|
||||
{verifiedEmails.length === 0 && <p className="text-sm text-muted-foreground">暂未添加验证邮箱,请先点击「管理验证邮箱」添加。</p>}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!forwardingTargetPreview} onOpenChange={(open) => { if (!open) setForwardingTargetPreview(null) }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader><DialogTitle>转发目标</DialogTitle></DialogHeader>
|
||||
{forwardingTargetPreview && (
|
||||
<div className="space-y-4">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm text-muted-foreground">{forwardingTargetPreview.title}</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Badge variant="secondary">{forwardingTargetPreview.source}</Badge>
|
||||
<span className="text-sm text-muted-foreground">{forwardingTargetPreview.subtitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[320px] overflow-y-auto rounded-lg border">
|
||||
{forwardingTargetPreview.targets.map((email) => (
|
||||
<div key={email} className="flex min-h-11 items-center gap-3 border-b px-3 py-2 text-sm last:border-b-0">
|
||||
<span className="size-2 shrink-0 rounded-full bg-emerald-500" />
|
||||
<span className="min-w-0 break-all font-medium">{email}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={verifiedDialogOpen} onOpenChange={setVerifiedDialogOpen}>
|
||||
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-[640px]">
|
||||
<DialogHeader className="px-8 pt-8">
|
||||
@@ -1632,36 +1670,81 @@ function formatDateTime(value: string) {
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
function ForwardingTargetSummary({ targets, prefix = "转发:", onView }: { targets: string[]; prefix?: string; onView: () => void }) {
|
||||
if (targets.length === 0) return null
|
||||
const prefixText = prefix.endsWith(":") ? prefix : `${prefix} `
|
||||
return (
|
||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold text-foreground" title={`${prefixText}${targets.join("、")}`}>
|
||||
<span className="min-w-0 truncate">{prefixText}{targets[0]}</span>
|
||||
{targets.length > 1 && (
|
||||
<Button type="button" variant="link" className="h-auto shrink-0 px-1 py-0 text-xs font-semibold" onClick={onView}>
|
||||
查看全部 {targets.length} 个
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeForwardingTarget(value: string) {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function sortForwardingTargets(targets: string[]) {
|
||||
return Array.from(new Set(targets.map((item) => item.trim()).filter(Boolean))).sort((a, b) => forwardingTargetCollator.compare(a, b))
|
||||
}
|
||||
|
||||
function mergeForwardingTargets(...groups: string[][]) {
|
||||
const seen = new Set<string>()
|
||||
const merged: string[] = []
|
||||
for (const target of groups.flat()) {
|
||||
const value = target.trim()
|
||||
const key = normalizeForwardingTarget(value)
|
||||
if (!value || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
merged.push(value)
|
||||
}
|
||||
return sortForwardingTargets(merged)
|
||||
}
|
||||
|
||||
function withoutForwardingTargets(targets: string[], lockedTargets: string[]) {
|
||||
const locked = new Set(lockedTargets.map(normalizeForwardingTarget))
|
||||
return sortForwardingTargets(targets.filter((target) => !locked.has(normalizeForwardingTarget(target))))
|
||||
}
|
||||
|
||||
function forwardingTargetsFromRule(rule: { targetEmail?: string; targetEmails?: string[] }) {
|
||||
const targets = rule.targetEmails?.length ? rule.targetEmails : rule.targetEmail ? [rule.targetEmail] : []
|
||||
return Array.from(new Set(targets.map((item) => item.trim()).filter(Boolean)))
|
||||
return sortForwardingTargets(targets)
|
||||
}
|
||||
|
||||
function forwardingTargetsFromSettings(settings?: ForwardingSettings) {
|
||||
const targets = settings?.accountTargetEmails?.length ? settings.accountTargetEmails : settings?.accountTargetEmail ? [settings.accountTargetEmail] : []
|
||||
return Array.from(new Set(targets.map((item) => item.trim()).filter(Boolean)))
|
||||
return sortForwardingTargets(targets)
|
||||
}
|
||||
|
||||
function ForwardingTargetPicker({ emails, selected, onChange, disabled, placement = "bottom" }: { emails: string[]; selected: string[]; onChange: (targets: string[]) => void; disabled?: boolean; placement?: "bottom" | "top" }) {
|
||||
function ForwardingTargetPicker({ emails, selected, lockedSelected = [], lockedLabel = "账号级", onChange, disabled, placement = "bottom" }: { emails: string[]; selected: string[]; lockedSelected?: string[]; lockedLabel?: string; onChange: (targets: string[]) => void; disabled?: boolean; placement?: "bottom" | "top" }) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [query, setQuery] = React.useState("")
|
||||
const selectedSet = React.useMemo(() => new Set(selected), [selected])
|
||||
const lockedEmails = React.useMemo(() => sortForwardingTargets(lockedSelected), [lockedSelected])
|
||||
const lockedSet = React.useMemo(() => new Set(lockedEmails.map(normalizeForwardingTarget)), [lockedEmails])
|
||||
const selectedEmailsOnly = React.useMemo(() => withoutForwardingTargets(selected, lockedEmails), [lockedEmails, selected])
|
||||
const selectedSet = React.useMemo(() => new Set(selectedEmailsOnly.map(normalizeForwardingTarget)), [selectedEmailsOnly])
|
||||
const sortedEmails = React.useMemo(() => {
|
||||
const collator = new Intl.Collator("en", { sensitivity: "base", numeric: true })
|
||||
return Array.from(new Set(emails.map((email) => email.trim()).filter(Boolean))).sort((a, b) => collator.compare(a, b))
|
||||
return sortForwardingTargets(emails)
|
||||
}, [emails])
|
||||
const selectedEmails = React.useMemo(() => sortedEmails.filter((email) => selectedSet.has(email)), [selectedSet, sortedEmails])
|
||||
const regularEmails = React.useMemo(() => sortedEmails.filter((email) => !lockedSet.has(normalizeForwardingTarget(email))), [lockedSet, sortedEmails])
|
||||
const selectedEmails = React.useMemo(() => [...lockedEmails, ...regularEmails.filter((email) => selectedSet.has(normalizeForwardingTarget(email)))], [lockedEmails, regularEmails, selectedSet])
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
const filteredEmails = normalizedQuery ? sortedEmails.filter((email) => email.toLowerCase().includes(normalizedQuery)) : sortedEmails
|
||||
const filteredLockedEmails = normalizedQuery ? lockedEmails.filter((email) => email.toLowerCase().includes(normalizedQuery)) : lockedEmails
|
||||
const filteredEmails = normalizedQuery ? regularEmails.filter((email) => email.toLowerCase().includes(normalizedQuery)) : regularEmails
|
||||
const label = selectedEmails.length
|
||||
? `已选择 ${selectedEmails.length} 个:${selectedEmails.slice(0, 2).join("、")}${selectedEmails.length > 2 ? ` 等 ${selectedEmails.length} 个` : ""}`
|
||||
: "不转发,点击选择邮箱"
|
||||
function toggle(email: string, checked: boolean) {
|
||||
if (disabled) return
|
||||
const next = checked ? Array.from(new Set([...selected, email])) : selected.filter((item) => item !== email)
|
||||
onChange(next)
|
||||
const next = checked ? mergeForwardingTargets(selectedEmailsOnly, [email]) : selectedEmailsOnly.filter((item) => normalizeForwardingTarget(item) !== normalizeForwardingTarget(email))
|
||||
onChange(withoutForwardingTargets(next, lockedEmails))
|
||||
}
|
||||
if (emails.length === 0) {
|
||||
if (sortedEmails.length === 0 && lockedEmails.length === 0) {
|
||||
return <div className="rounded-md border border-dashed px-3 py-2 text-sm text-muted-foreground">暂无已验证邮箱</div>
|
||||
}
|
||||
return (
|
||||
@@ -1704,8 +1787,18 @@ function ForwardingTargetPicker({ emails, selected, onChange, disabled, placemen
|
||||
</div>
|
||||
<div className="mt-2 max-h-[132px] overflow-y-auto pr-1">
|
||||
<div className="space-y-1">
|
||||
{filteredLockedEmails.map((email) => (
|
||||
<label
|
||||
key={`locked-${email}`}
|
||||
className="flex min-h-[36px] cursor-not-allowed items-center gap-2 rounded-md border border-border bg-muted/70 px-2.5 py-1.5 text-sm font-semibold text-muted-foreground"
|
||||
>
|
||||
<Checkbox checked disabled />
|
||||
<span className="min-w-0 flex-1 truncate">{email}</span>
|
||||
<Badge variant="outline" className="h-5 shrink-0 rounded px-1.5 text-[10px]">{lockedLabel}</Badge>
|
||||
</label>
|
||||
))}
|
||||
{filteredEmails.map((email) => {
|
||||
const checked = selectedSet.has(email)
|
||||
const checked = selectedSet.has(normalizeForwardingTarget(email))
|
||||
return (
|
||||
<label
|
||||
key={email}
|
||||
@@ -1719,7 +1812,7 @@ function ForwardingTargetPicker({ emails, selected, onChange, disabled, placemen
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
{filteredEmails.length === 0 && <div className="px-2 py-8 text-center text-sm text-muted-foreground">没有匹配的已验证邮箱</div>}
|
||||
{filteredLockedEmails.length === 0 && filteredEmails.length === 0 && <div className="px-2 py-8 text-center text-sm text-muted-foreground">没有匹配的已验证邮箱</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -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` 去重。
|
||||
|
||||
@@ -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"
|
||||
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
@@ -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 证书。
|
||||
|
||||
安装完成后,请记录终端中显示的访问地址、管理员邮箱和初始密码。初始密码仅在安装时显示;如果以后在后台修改密码,请以新密码为准。
|
||||
|
||||
|
||||
@@ -41,6 +41,13 @@
|
||||
| NSX-20260806-004 | 2026-08-06 | 已完成 | 前端/UI/响应式布局 | “全部邮箱”选择器右侧存在复制按钮空白占位 | S3 | v1.2.15 | 随 v1.2.15 发布 |
|
||||
| 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 | 随 v1.2.19 发布 |
|
||||
| NSX-20260807-009 | 2026-08-07 | 已完成 | 部署运维/安装;文档;质量复核 | 一键安装主菜单未按安装状态区分,命令前置条件和状态显示需复核 | S2 | v1.2.19 | 随 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 | 随 v1.2.19 发布 |
|
||||
| NSX-20260807-012 | 2026-08-07 | 已完成 | 前端/UI;域名管理;质量复核 | DNS 记录复制按钮把类型、名称和值拼成整行,无法直接粘贴到域名服务商对应字段 | S3 | v1.2.19 | 随 v1.2.19 发布 |
|
||||
| NSX-20260807-013 | 2026-08-07 | 已完成 | 邮件核心;前端/标签;数据迁移;质量复核 | 邮箱创建后没有常用默认标签,需要手动逐个建立 | S3 | v1.2.19 | 随 v1.2.19 发布 |
|
||||
|
||||
## NSX-20260804-001
|
||||
|
||||
@@ -252,3 +259,166 @@
|
||||
| 2026-08-06 | 用户确认继续修改,并明确保留现有机器人 Token。 |
|
||||
| 2026-08-06 | 完成实现和自动化回归,状态流转为待验收。 |
|
||||
| 2026-08-06 | 完成桌面端与移动端页面验收及最终回归,状态流转为已完成。 |
|
||||
|
||||
## NSX-20260806-007
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 编号 | NSX-20260806-007 |
|
||||
| 日期 | 2026-08-06 |
|
||||
| 状态 | 已完成 |
|
||||
| 模块 | 前端/UI;后端/通知;部署运维/CI;质量复核 |
|
||||
| 现象 | 邮箱选择器展开层超过侧栏边框;含日期年份的验证码邮件未显示复制按钮;邮件长链接难以阅读;Release 通知底部按钮需改为正文文字链接。 |
|
||||
| 根因 | 展开层固定为 21rem,未跟随触发按钮;年份与真实验证码同时进入评分后触发歧义保护;正文仅转义未生成显式链接;Release 工作流使用 inline keyboard。 |
|
||||
| 实现 | 展开层宽度跟随触发按钮;排除年份和紧凑日期候选;正文 URL 安全转义并生成链接,长追踪地址缩短显示;Release 移除按钮并在正文末尾加入“查看本次更新”链接。 |
|
||||
| 目标版本 | v1.2.18 |
|
||||
| 测试结果 | Gate 转发邮件验证码与链接专项测试、Telegram 全部竞态测试、Go 全量测试和 vet、前端 check/build、工作流 YAML、差异格式检查均通过;桌面端“全部邮箱”和具体邮箱状态下触发按钮与下拉层均为 263px,左右边界一致且无控制台错误。 |
|
||||
| 发布状态 | 随 v1.2.18 发布。 |
|
||||
|
||||
### 历史
|
||||
|
||||
| 时间 | 记录 |
|
||||
| --- | --- |
|
||||
| 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 检查均通过;本地双邮箱页面实测默认选择登录邮箱,下拉项完整,切换邮箱后收件人、主题、正文均保留,数据库确认草稿保存到新选择的邮箱;桌面截图确认写信窗口无溢出和遮挡。 |
|
||||
| 发布状态 | 随 v1.2.19 发布。 |
|
||||
|
||||
### 历史
|
||||
|
||||
| 时间 | 记录 |
|
||||
| --- | --- |
|
||||
| 2026-08-07 | 用户反馈全部邮箱写信的默认发件箱、邮箱选择和窗口宽度问题,进入处理中。 |
|
||||
| 2026-08-07 | 完成写信邮箱选择、默认项、回复/转发邮箱、草稿归属、窗口宽度和安装管理员邮箱交互修改;自动化与页面验收通过,状态流转为待验收。 |
|
||||
| 2026-08-07 | v1.2.19 检查、六个 Docker 镜像、GitHub Release 和 Telegram 频道通知全部成功,状态流转为已完成。 |
|
||||
|
||||
## NSX-20260807-009
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 编号 | NSX-20260807-009 |
|
||||
| 日期 | 2026-08-07 |
|
||||
| 状态 | 已完成 |
|
||||
| 模块 | 部署运维/安装;文档;质量复核 |
|
||||
| 现象 | 空白服务器仍显示更新、回滚、重启等不可用操作;已安装菜单没有运行状态和实际版本;部分直接命令缺少统一安装前置检查;安装残缺时没有明确修复入口。 |
|
||||
| 根因 | 主菜单只根据 `.env` 切换默认选项,所有状态共用一套菜单;运行状态、镜像版本和安装完整性没有独立判断;部分前置检查散落在菜单分发层。 |
|
||||
| 实现 | 未安装服务器只显示一键安装和退出;已安装服务器按安装维护、服务管理、证书恢复、账号帮助、危险操作分组,动态显示运行状态、镜像版本和访问地址;安装残缺时默认进入修复;新增 `repair` 命令;服务命令统一校验 `.env` 与 Compose 文件;日志、状态、回滚和卸载的 Docker 检查收回各自函数;修复成功文案和缺失版本标签显示已纠正;README 加入两套主菜单示例。 |
|
||||
| 兼容性 | 保持既有菜单编号 `1–12` 和 `ns` 快捷命令;现有配置、数据库、邮件、证书和更新流程不变。 |
|
||||
| 测试结果 | Bash 语法检查、ShellCheck、安装脚本全量测试、菜单渲染/范围/分发/状态/版本/残缺安装/前置条件测试、前端 check/build、Go 全量测试和 go vet 均通过。 |
|
||||
| 目标版本 | v1.2.19 |
|
||||
| 发布状态 | 随 v1.2.19 发布。 |
|
||||
|
||||
### 历史
|
||||
|
||||
| 时间 | 记录 |
|
||||
| --- | --- |
|
||||
| 2026-08-07 | 用户确认按安装状态拆分主菜单,并要求写入仓库介绍、复核全部命令逻辑。 |
|
||||
| 2026-08-07 | 完成动态菜单、命令前置条件、残缺安装修复入口、README 和自动化回归,状态流转为待验收。 |
|
||||
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
|
||||
|
||||
## 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 |
|
||||
| 发布状态 | 已随 v1.2.19 发布,待服务器升级后实发验收。 |
|
||||
|
||||
### 历史
|
||||
|
||||
| 时间 | 记录 |
|
||||
| --- | --- |
|
||||
| 2026-08-07 | 用户提供 Gmail 原始邮件和 NodeSeek 对照邮件,完成正文哈希、签名公钥和认证结果比对。 |
|
||||
| 2026-08-07 | 完成 DKIM 密钥热更新修复和专项回归,等待服务器实发验收。 |
|
||||
| 2026-08-07 | 修复已随 v1.2.19 发布;保留待验收状态,需升级后确认 Gmail 原始邮件显示 `dkim=pass`。 |
|
||||
|
||||
## NSX-20260807-011
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 编号 | NSX-20260807-011 |
|
||||
| 日期 | 2026-08-07 |
|
||||
| 状态 | 已完成 |
|
||||
| 模块 | 后端/通知;验证码识别;质量复核 |
|
||||
| 现象 | 爱奇艺邮件的主题和正文均包含验证码 `825534`,Telegram 通知却没有独立验证码区域和“复制验证码”按钮。 |
|
||||
| 根因 | 正文开头的收件地址 `iqiyi02@newszxcn.com` 被拆成 `iqiyi02`、`newszxcn` 两个候选;它们与主题中的“验证码”距离较近,触发多候选歧义保护后返回空结果。 |
|
||||
| 实现 | 验证码评分前排除邮箱地址和 HTTP/HTTPS 链接范围内的字母数字片段,保留真实正文与主题候选;新增爱奇艺原始场景回归,同时检查独立验证码区域和复制按钮。 |
|
||||
| 测试结果 | 爱奇艺原始场景、Gate 验证码与链接、Telegram 消息预算专项测试及 Go 全量测试均通过。 |
|
||||
| 目标版本 | v1.2.19 |
|
||||
| 发布状态 | 随 v1.2.19 发布。 |
|
||||
|
||||
### 历史
|
||||
|
||||
| 时间 | 记录 |
|
||||
| --- | --- |
|
||||
| 2026-08-07 | 用户提供 Telegram 实际通知截图,完成邮箱地址候选冲突复现。 |
|
||||
| 2026-08-07 | 修复邮箱和链接候选排除逻辑,加入爱奇艺验证码专项回归。 |
|
||||
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
|
||||
|
||||
## 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 |
|
||||
| 发布状态 | 随 v1.2.19 发布。 |
|
||||
|
||||
### 历史
|
||||
|
||||
| 时间 | 记录 |
|
||||
| --- | --- |
|
||||
| 2026-08-07 | 用户提供 DNS 弹窗截图并指出整行复制无法直接用于 DNS 面板。 |
|
||||
| 2026-08-07 | 完成主机记录和记录值分离复制。 |
|
||||
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
|
||||
|
||||
## NSX-20260807-013
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 编号 | NSX-20260807-013 |
|
||||
| 日期 | 2026-08-07 |
|
||||
| 状态 | 已完成 |
|
||||
| 模块 | 邮件核心;前端/标签;数据迁移;质量复核 |
|
||||
| 需求 | 邮箱默认增加个人、家人、朋友、工作、重要五个常用标签,并按名称使用容易辨认的颜色。 |
|
||||
| 实现 | 新邮箱创建时由后端事务生成五个标签;已有邮箱升级时一次性补齐;固定顺序为个人、家人、朋友、工作、重要,颜色依次为绿色、玫红、青色、蓝色、橙色;用户后续删除标签不会在重启时恢复;“全部邮箱”按名称合并同名标签并汇总数量,点击或导出时覆盖用户名下所有邮箱。 |
|
||||
| 兼容性 | 使用 `INSERT OR IGNORE` 保留已有同名标签及其颜色和邮件关联;不修改用户自建标签。 |
|
||||
| 测试结果 | 新邮箱创建、旧邮箱补齐、删除后不重建、多邮箱同名汇总与跨邮箱筛选专项测试,以及 Go 全量测试和 vet 均通过;页面确认五个标签各显示一次、顺序正确,颜色与侧栏宽度正确且无越界。 |
|
||||
| 目标版本 | v1.2.19 |
|
||||
| 发布状态 | 随 v1.2.19 发布。 |
|
||||
|
||||
### 历史
|
||||
|
||||
| 时间 | 记录 |
|
||||
| --- | --- |
|
||||
| 2026-08-07 | 用户提供标签侧栏参考并指定五个默认标签。 |
|
||||
| 2026-08-07 | 完成新邮箱默认生成、已有邮箱一次性补齐、固定排序和颜色回归;页面复核时发现全部邮箱重复显示,继续完成同名汇总、跨邮箱筛选与导出回归。 |
|
||||
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
|
||||
|
||||
+163
-59
@@ -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
|
||||
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
|
||||
if [[ "${snapshot_created}" == "true" ]]; then
|
||||
restore_update_snapshot "" false || true
|
||||
fail "修复准备失败,已恢复原安装。"
|
||||
fi
|
||||
fail "修复准备失败,原配置和数据未删除。"
|
||||
fi
|
||||
if ! (configure_firewall && prepare_directories); then
|
||||
if [[ "${snapshot_created}" == "true" ]]; then
|
||||
restore_update_snapshot "" false || true
|
||||
fail "修复环境准备失败,已恢复原安装。"
|
||||
fi
|
||||
fail "修复环境准备失败,原配置和数据未删除。"
|
||||
fi
|
||||
log "正在拉取并修复 NewSzxcn Email 服务..."
|
||||
if ! compose pull; then
|
||||
if [[ "${snapshot_created}" == "true" ]]; then
|
||||
restore_update_snapshot "" false || true
|
||||
fail "修复镜像拉取失败,已恢复原安装。"
|
||||
fi
|
||||
fail "修复镜像拉取失败,原配置和数据未删除。"
|
||||
fi
|
||||
log "正在启动服务..."
|
||||
if ! compose up -d --remove-orphans; then
|
||||
if [[ "${snapshot_created}" == "true" ]]; then
|
||||
warn "修复后容器启动失败,正在自动回滚。"
|
||||
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
|
||||
fail "修复失败,已恢复到修复前版本。"
|
||||
fi
|
||||
fail "修复后容器启动失败,请查看实时日志;原配置和数据未删除。"
|
||||
fi
|
||||
if ! wait_for_health 90; then
|
||||
if [[ "${snapshot_created}" == "true" ]]; then
|
||||
warn "修复后健康检查失败,正在自动回滚。"
|
||||
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
|
||||
fail "修复失败,已恢复到修复前版本。"
|
||||
fi
|
||||
fail "修复后健康检查失败,请查看实时日志;原配置和数据未删除。"
|
||||
fi
|
||||
if ! (configure_web_mode); then
|
||||
if [[ "${snapshot_created}" == "true" ]]; then
|
||||
restore_update_snapshot || fail "Web 配置失败,且自动恢复未完成,请使用回滚快照手动恢复。"
|
||||
fail "Web 配置失败,已恢复到修复前版本。"
|
||||
fi
|
||||
generate_guide >/dev/null || warn "安装成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
|
||||
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
|
||||
fail "Web 配置修复失败,原配置和数据未删除。"
|
||||
fi
|
||||
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
|
||||
}
|
||||
|
||||
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. 安装 / 重新安装(完整备份,失败自动恢复)\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 '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
|
||||
|
||||
Executable
+54
@@ -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'
|
||||
@@ -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,123 @@ 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}"
|
||||
# Invoked indirectly by the service command functions under test.
|
||||
# shellcheck disable=SC2317,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 +307,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 +348,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 +694,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
|
||||
|
||||
Reference in New Issue
Block a user