Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60d87a6960 | |||
| 70dd2cec4e | |||
| a9ec9360a8 | |||
| 942605b2b6 | |||
| 635ab02b29 | |||
| 4c90c1de44 | |||
| 1fa029370d | |||
| 79f920bf0b |
@@ -0,0 +1,3 @@
|
||||
- 新增后台 Telegram 私聊新邮件通知,支持自动获取 Chat ID、测试通知、正文显示模式和失败自动重试。
|
||||
- 新增 GitHub Release 版本频道通知;仅首次创建 Release 时发送一次,工作流重跑不会重复推送。
|
||||
- Bot Token 不通过设置接口返回,Telegram 异常不会阻塞邮件接收或版本发布。
|
||||
@@ -0,0 +1,6 @@
|
||||
- Telegram 私聊改用 10 分钟一次性绑定码,避免自动获取 Chat ID 时绑定到错误账号。
|
||||
- 新增通知邮箱范围,可分别选择已启用邮箱和“未知收件”;升级后默认保留管理员邮箱范围。
|
||||
- 优化邮件通知排版,显示实际收件邮箱、正文摘要和附件数量;高可信验证码支持高亮与一键复制。
|
||||
- 完善邮件解析,支持 GBK 等字符集、伪 HTML 正文清理和历史引用过滤,减少乱码及旧验证码误识别。
|
||||
- 完善通知队列和错误处理:配置变化清理旧任务、发送租约、限流等待、格式降级、永久错误停止重试,并在任务结束后清除敏感正文。
|
||||
- 补齐本地互发、未知收件和外部 IMAP 新邮件通知;首次导入的历史邮件以及垃圾邮件、已删除邮件不会发送通知。
|
||||
@@ -0,0 +1,4 @@
|
||||
- 修复邮箱选择列表超过侧栏边框的问题,展开列表现在与上方选择框保持相同宽度。
|
||||
- 修复含日期年份的邮件可能漏识别验证码的问题,Gate 等验证码邮件可正常显示一键复制按钮。
|
||||
- 邮件通知中的网址改为可点击链接,超长追踪地址使用简短文字显示,阅读更清晰。
|
||||
- 版本频道通知移除底部按钮,改为正文中的“查看本次更新”文字链接。
|
||||
@@ -0,0 +1,6 @@
|
||||
- 优化“全部邮箱”写信:默认使用登录邮箱,可切换其他发件邮箱,切换时保留收件人、主题、正文和附件;写信窗口宽度同步调整。
|
||||
- 优化一键安装管理菜单:根据安装状态显示可用功能,补充运行状态、实际版本、访问地址和修复入口,并加强备份、回滚及命令检查。
|
||||
- 修复域名密钥变化后 Rspamd 可能继续使用旧 DKIM 私钥的问题;后台 DNS 检测现在会核对实际 DKIM 公钥。
|
||||
- 修复部分验证码邮件因收件邮箱或链接内容干扰而不显示验证码及复制按钮的问题。
|
||||
- 优化 DNS 记录复制:主机记录和记录值可分别复制,长 DKIM 记录能够正常换行显示。
|
||||
- 新邮箱默认创建“个人、家人、朋友、工作、重要”五个标签;已有邮箱升级后自动补齐,“全部邮箱”会合并同名标签并支持跨邮箱筛选与导出。
|
||||
@@ -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
|
||||
@@ -240,6 +241,7 @@ jobs:
|
||||
cp generated-release-notes.md release-notes.md
|
||||
|
||||
- name: Create or update GitHub release
|
||||
id: release_result
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
@@ -248,6 +250,90 @@ jobs:
|
||||
title="NewSzxcn Email ${tag}"
|
||||
if gh release view "${tag}" >/dev/null 2>&1; then
|
||||
gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest
|
||||
echo "created=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest
|
||||
echo "created=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Notify Telegram release channel
|
||||
if: steps.release_result.outputs.created == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_RELEASE_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_RELEASE_CHAT_ID }}
|
||||
RELEASE_TAG: ${{ needs.release.outputs.tag }}
|
||||
RELEASE_URL: ${{ needs.release.outputs.release_url }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -z "${TELEGRAM_BOT_TOKEN}" || -z "${TELEGRAM_CHAT_ID}" ]]; then
|
||||
echo "::notice::Telegram release notification is not configured; skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 - <<'PY'
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
|
||||
notes = open("release-notes.md", "r", encoding="utf-8").read().strip()
|
||||
entries = []
|
||||
for raw in notes.splitlines():
|
||||
line = re.sub(r"^#{1,6}\s+", "", raw).strip()
|
||||
line = re.sub(r"^[-*+]\s+", "", line)
|
||||
line = re.sub(r"\*\*([^*]+)\*\*", r"\1", line)
|
||||
line = re.sub(r"`([^`]+)`", r"\1", line)
|
||||
line = re.sub(r"\[([^]]+)\]\([^)]+\)", r"\1", line)
|
||||
if line:
|
||||
entries.append(line)
|
||||
|
||||
sections = []
|
||||
for index, entry in enumerate(entries, 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>"
|
||||
if description:
|
||||
section += "\n" + html.escape(description.rstrip("。") + "。")
|
||||
sections.append(section)
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
prefix = f"<b>NewSzxcn Email {html.escape(tag)}</b>\n新版本现已发布\n\n<b>本次更新</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:
|
||||
added = len(section) + (2 if visible_sections else 0)
|
||||
if used + added > available:
|
||||
break
|
||||
visible_sections.append(section)
|
||||
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 + footer)
|
||||
PY
|
||||
|
||||
jq -n \
|
||||
--arg chat_id "${TELEGRAM_CHAT_ID}" \
|
||||
--rawfile text telegram-release-message.txt \
|
||||
'{
|
||||
chat_id:$chat_id,
|
||||
text:$text,
|
||||
parse_mode:"HTML",
|
||||
disable_web_page_preview:true
|
||||
}' > telegram-release-payload.json
|
||||
|
||||
http_code="$(curl -sS --retry 2 --retry-all-errors --connect-timeout 10 --max-time 30 \
|
||||
-o telegram-release-response.json -w '%{http_code}' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @telegram-release-payload.json \
|
||||
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage")"
|
||||
if [[ "${http_code}" != "200" ]] || ! jq -e '.ok == true' telegram-release-response.json >/dev/null 2>&1; then
|
||||
description="$(jq -r '.description // "unknown Telegram error"' telegram-release-response.json 2>/dev/null || echo "unknown Telegram error")"
|
||||
echo "::warning::Telegram release notification failed (HTTP ${http_code}): ${description}"
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::Telegram release notification sent."
|
||||
|
||||
@@ -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
|
||||
|
||||
+171
-12
@@ -23,17 +23,21 @@ import (
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg Config
|
||||
cfgMu sync.RWMutex
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
policy *HTMLPolicy
|
||||
workerCancel context.CancelFunc
|
||||
workerWG sync.WaitGroup
|
||||
maildirHealth *maildirSyncHealthTracker
|
||||
externalIMAP externalIMAPClientFactory
|
||||
turnstileURL string
|
||||
cfg Config
|
||||
cfgMu sync.RWMutex
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
policy *HTMLPolicy
|
||||
workerCancel context.CancelFunc
|
||||
workerWG sync.WaitGroup
|
||||
maildirHealth *maildirSyncHealthTracker
|
||||
externalIMAP externalIMAPClientFactory
|
||||
turnstileURL string
|
||||
telegramURL string
|
||||
telegramPairMu sync.Mutex
|
||||
telegramPairs map[string]telegramPairing
|
||||
telegramDeliveryMu sync.Mutex
|
||||
}
|
||||
|
||||
func (a *App) config() Config {
|
||||
@@ -71,7 +75,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker()}
|
||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker(), telegramURL: "https://api.telegram.org", telegramPairs: map[string]telegramPairing{}}
|
||||
a.externalIMAP = a
|
||||
if err := a.configureSQLite(context.Background()); err != nil {
|
||||
db.Close()
|
||||
@@ -93,6 +97,14 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := a.initializeTelegramNotificationDefaults(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := a.loadPersistedSystemSettings(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := a.enforceSingleAdministratorIndex(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
@@ -107,6 +119,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
a.startWorker(func() { a.externalIMAPWorker(workerCtx) })
|
||||
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
|
||||
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
|
||||
a.startWorker(func() { a.telegramMailWorker(workerCtx) })
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -433,6 +446,20 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_due ON status_webhook_outbox(delivered_at,next_attempt_at,created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_mailbox ON status_webhook_outbox(mailbox_id,created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS telegram_mail_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL UNIQUE,
|
||||
payload_json TEXT NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TEXT NOT NULL,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
delivered_at TEXT,
|
||||
lease_until TEXT NOT NULL DEFAULT '',
|
||||
telegram_message_id INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_telegram_mail_outbox_due ON telegram_mail_outbox(delivered_at,next_attempt_at,created_at)`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_mailbox_delete_status_webhook_outbox
|
||||
AFTER DELETE ON mailboxes BEGIN
|
||||
DELETE FROM status_webhook_outbox WHERE mailbox_id=OLD.id;
|
||||
@@ -675,12 +702,117 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateAPITokenScopes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateTelegramNotifications(ctx context.Context) error {
|
||||
if err := a.ensureTableColumn(ctx, "telegram_mail_outbox", "lease_until", `ALTER TABLE telegram_mail_outbox ADD COLUMN lease_until TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureTableColumn(ctx, "telegram_mail_outbox", "telegram_message_id", `ALTER TABLE telegram_mail_outbox ADD COLUMN telegram_message_id INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM system_settings WHERE key='telegramMailboxIds'`).Scan(&mailboxSettingExists); err != nil {
|
||||
return err
|
||||
}
|
||||
if mailboxSettingExists == 0 {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM mailboxes m JOIN users u ON u.id=m.user_id WHERE u.role='admin' AND m.status='active' ORDER BY m.address`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var mailboxIDs []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
mailboxIDs = append(mailboxIDs, id)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('telegramMailboxIds',?,?)`, strings.Join(mailboxIDs, ","), now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var includeSettingExists int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM system_settings WHERE key='telegramIncludeUnregistered'`).Scan(&includeSettingExists); err != nil {
|
||||
return err
|
||||
}
|
||||
if includeSettingExists == 0 {
|
||||
var enabled string
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='telegramMailEnabled'`).Scan(&enabled)
|
||||
includeUnregistered := "false"
|
||||
if strings.EqualFold(enabled, "true") {
|
||||
includeUnregistered = "true"
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('telegramIncludeUnregistered',?,?)`, includeUnregistered, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateForwardingVerification(ctx context.Context) error {
|
||||
columns := []struct {
|
||||
name string
|
||||
@@ -1682,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 {
|
||||
@@ -1739,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
|
||||
}
|
||||
|
||||
|
||||
@@ -459,6 +459,12 @@ func systemSettingsPayload(settings SystemSettings) map[string]any {
|
||||
"externalImapGmailClientSecret": "",
|
||||
"externalImapOutlookClientId": settings.ExternalIMAPOutlookClientID,
|
||||
"externalImapOutlookClientSecret": "",
|
||||
"telegramMailEnabled": settings.TelegramMailEnabled,
|
||||
"telegramBotToken": "",
|
||||
"telegramPrivateChatId": settings.TelegramPrivateChatID,
|
||||
"telegramBodyMode": settings.TelegramBodyMode,
|
||||
"telegramMailboxIds": settings.TelegramMailboxIDs,
|
||||
"telegramIncludeUnregistered": settings.TelegramIncludeUnregistered,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,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 {
|
||||
@@ -2014,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 {
|
||||
@@ -2025,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
|
||||
@@ -4754,6 +4802,72 @@ func TestDNSRecords(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultMailLabelsBackfillOrderAndDeletion(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
var mailboxID string
|
||||
if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&mailboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.Exec(`DELETE FROM system_settings WHERE key='defaultMailLabelsInitialized'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.Exec(`DELETE FROM mail_labels WHERE mailbox_id=?`, mailboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.migrateDefaultMailLabels(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels, err := a.labelsForMailbox(context.Background(), mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defaults := defaultMailLabelDefs()
|
||||
if len(labels) != len(defaults) {
|
||||
t.Fatalf("labels=%+v", labels)
|
||||
}
|
||||
for index, expected := range defaults {
|
||||
if labels[index].Name != expected.name || labels[index].Color != expected.color {
|
||||
t.Fatalf("label %d=%+v want name=%q color=%q", index, labels[index], expected.name, expected.color)
|
||||
}
|
||||
}
|
||||
if _, err := a.db.Exec(`DELETE FROM mail_labels WHERE id=?`, labels[1].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.migrateDefaultMailLabels(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels, err = a.labelsForMailbox(context.Background(), mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(labels) != len(defaults)-1 {
|
||||
t.Fatalf("deleted default label was restored: %+v", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDKIMRecordRequiresMatchingPublicKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
records []string
|
||||
key string
|
||||
ok bool
|
||||
message string
|
||||
}{
|
||||
{name: "matching", records: []string{"v=DKIM1; k=rsa; p=ABC123"}, key: "ABC123", ok: true, message: "DKIM 公钥匹配"},
|
||||
{name: "split whitespace", records: []string{"v=DKIM1; k=rsa; p=ABC 123\n456"}, key: "ABC123456", ok: true, message: "DKIM 公钥匹配"},
|
||||
{name: "wrong key", records: []string{"v=DKIM1; k=rsa; p=WRONG"}, key: "EXPECTED", ok: false, message: "DKIM 公钥与后台生成的记录不一致"},
|
||||
{name: "unrelated TXT", records: []string{"google-site-verification=token"}, key: "EXPECTED", ok: false, message: "未找到 DKIM 记录"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
status := checkDKIMRecord(tt.records, tt.key)
|
||||
if status.OK != tt.ok || status.Message != tt.message {
|
||||
t.Fatalf("status=%+v", status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
|
||||
@@ -52,6 +52,12 @@ type Config struct {
|
||||
ExternalIMAPGmailClientSecret string
|
||||
ExternalIMAPOutlookClientID string
|
||||
ExternalIMAPOutlookClientSecret string
|
||||
TelegramMailEnabled bool
|
||||
TelegramBotToken string
|
||||
TelegramPrivateChatID string
|
||||
TelegramBodyMode string
|
||||
TelegramMailboxIDs string
|
||||
TelegramIncludeUnregistered bool
|
||||
MailTranslateEnabled bool
|
||||
MailTranslateMaxChars int
|
||||
DeliveryWebhookSecret string
|
||||
@@ -110,6 +116,12 @@ func LoadConfig() Config {
|
||||
ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""),
|
||||
ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""),
|
||||
ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""),
|
||||
TelegramMailEnabled: getenvBool("LANQIN_TELEGRAM_MAIL_ENABLED", false),
|
||||
TelegramBotToken: getenv("LANQIN_TELEGRAM_BOT_TOKEN", ""),
|
||||
TelegramPrivateChatID: getenv("LANQIN_TELEGRAM_PRIVATE_CHAT_ID", ""),
|
||||
TelegramBodyMode: normalizeTelegramBodyMode(getenv("LANQIN_TELEGRAM_BODY_MODE", "summary")),
|
||||
TelegramMailboxIDs: getenv("LANQIN_TELEGRAM_MAILBOX_IDS", ""),
|
||||
TelegramIncludeUnregistered: getenvBool("LANQIN_TELEGRAM_INCLUDE_UNREGISTERED", false),
|
||||
MailTranslateEnabled: getenvBool("LANQIN_MAIL_TRANSLATE_ENABLED", true),
|
||||
MailTranslateMaxChars: getenvInt("LANQIN_MAIL_TRANSLATE_MAX_CHARS", 8000),
|
||||
DeliveryWebhookSecret: getenv("LANQIN_DELIVERY_WEBHOOK_SECRET", ""),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1181,6 +1181,9 @@ func (a *App) syncExternalIMAPFolder(ctx context.Context, account externalIMAPAc
|
||||
if err := a.writeStoredMessageToMaildir(ctx, msgID, stored, attachments); err != nil {
|
||||
a.log.Warn("failed to write external imap message to maildir", "message", msgID, "error", err)
|
||||
}
|
||||
if state.Initialized && strings.EqualFold(localFolderName, "Inbox") {
|
||||
a.enqueueTelegramMailNotification(ctx, msgID, stored, attachments)
|
||||
}
|
||||
imported++
|
||||
} else {
|
||||
skipped++
|
||||
@@ -1194,12 +1197,15 @@ func (a *App) syncExternalIMAPFolder(ctx context.Context, account externalIMAPAc
|
||||
}
|
||||
|
||||
type externalIMAPFolderState struct {
|
||||
LastUID uint32
|
||||
LastUID uint32
|
||||
Initialized bool
|
||||
}
|
||||
|
||||
func (a *App) loadExternalIMAPFolderState(ctx context.Context, accountID, folder string) externalIMAPFolderState {
|
||||
var state externalIMAPFolderState
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT last_uid FROM external_imap_folder_states WHERE account_id=? AND remote_folder=?`, accountID, folder).Scan(&state.LastUID)
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT last_uid FROM external_imap_folder_states WHERE account_id=? AND remote_folder=?`, accountID, folder).Scan(&state.LastUID); err == nil {
|
||||
state.Initialized = true
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -1131,6 +1132,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
|
||||
copyMsg.IsRead = false
|
||||
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
||||
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
|
||||
a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -1144,6 +1146,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
|
||||
copyMsg.IsRead = false
|
||||
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
||||
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
|
||||
a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
|
||||
}
|
||||
}
|
||||
continue
|
||||
@@ -1155,12 +1158,16 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
|
||||
copyMsg := base
|
||||
copyMsg.MailboxID = rcptMailbox.ID
|
||||
copyMsg.FolderID = inboxID
|
||||
copyMsg.RecipientAddr = normalizeEmail(rcpt)
|
||||
copyMsg.MessageUID = newID("uid")
|
||||
copyMsg.IsRead = false
|
||||
if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
||||
_ = a.writeStoredMessageToMaildir(ctx, inboxMsgID, copyMsg, req.Attachments)
|
||||
a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject)
|
||||
a.processInboundForwarding(ctx, inboxMsgID, rcptMailbox.ID, mimeBytes)
|
||||
if a.shouldNotifyTelegramMessage(ctx, inboxMsgID) {
|
||||
a.enqueueTelegramMailNotification(ctx, inboxMsgID, copyMsg, req.Attachments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2599,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
|
||||
}
|
||||
@@ -2616,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
|
||||
}
|
||||
@@ -2642,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
|
||||
}
|
||||
@@ -2673,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
|
||||
}
|
||||
@@ -2691,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 == "" {
|
||||
@@ -2734,6 +2749,14 @@ func (a *App) labelBelongsToUser(ctx context.Context, labelID, userID string) bo
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (a *App) labelNameForUser(ctx context.Context, labelID, userID string) (string, bool) {
|
||||
var name string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT l.name FROM mail_labels l JOIN mailboxes mb ON mb.id=l.mailbox_id WHERE l.id=? AND mb.user_id=? AND mb.status='active'`, labelID, userID).Scan(&name); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
|
||||
func normalizeLabelName(name string) string {
|
||||
name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ")
|
||||
if len([]rune(name)) > 32 {
|
||||
|
||||
@@ -122,8 +122,17 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
|
||||
if labelID == "" || !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)")
|
||||
args = append(args, labelID)
|
||||
if isAllMailboxID(mailboxID) {
|
||||
labelName, ok := a.labelNameForUser(r.Context(), labelID, user.ID)
|
||||
if !ok {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id WHERE ml.message_id=m.id AND lower(l.name)=lower(?))")
|
||||
args = append(args, labelName)
|
||||
} else {
|
||||
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)")
|
||||
args = append(args, labelID)
|
||||
}
|
||||
default:
|
||||
return nil, errors.New("unsupported mail view")
|
||||
}
|
||||
|
||||
@@ -292,7 +292,10 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox
|
||||
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
|
||||
return false, nil
|
||||
}
|
||||
_, err = a.insertMessage(ctx, msg, attachments)
|
||||
id, err := a.insertMessage(ctx, msg, attachments)
|
||||
if err == nil {
|
||||
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
@@ -333,6 +336,9 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
||||
}
|
||||
msg.MailboxID = mb.ID
|
||||
msg.FolderID = folder.ID
|
||||
if strings.TrimSpace(msg.RecipientAddr) == "" {
|
||||
msg.RecipientAddr = mb.Address
|
||||
}
|
||||
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
|
||||
msg.RawPath = path
|
||||
if msg.MessageUID == "" {
|
||||
@@ -367,6 +373,9 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
||||
if err == nil && strings.EqualFold(folder.Name, "Inbox") {
|
||||
a.applyInboundControls(ctx, id, mb.ID, msg.From, msg.Subject)
|
||||
a.processInboundForwarding(ctx, id, mb.ID, raw)
|
||||
if a.shouldNotifyTelegramMessage(ctx, id) {
|
||||
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
|
||||
}
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
@@ -585,6 +594,9 @@ func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, ra
|
||||
|
||||
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
|
||||
domain = normalizeDomain(domain)
|
||||
if address := normalizeEmail(msg.RecipientAddr); strings.HasSuffix(address, "@"+domain) {
|
||||
return address
|
||||
}
|
||||
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
|
||||
address = normalizeEmail(address)
|
||||
if strings.HasSuffix(address, "@"+domain) {
|
||||
@@ -609,11 +621,18 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
||||
if len(to) == 0 {
|
||||
to = []string{fallbackTo}
|
||||
}
|
||||
recipientAddr := originalMailRecipient(m.Header)
|
||||
sentAt := parseMailDate(m.Header.Get("Date"))
|
||||
parsed := &parsedMail{}
|
||||
if err := parseMailPart(textproto.MIMEHeader(m.Header), m.Body, parsed); err != nil {
|
||||
return storedMessage{}, nil, err
|
||||
}
|
||||
if looksLikeHTMLDocument(parsed.Text) {
|
||||
if strings.TrimSpace(parsed.HTML) == "" {
|
||||
parsed.HTML = parsed.Text
|
||||
}
|
||||
parsed.Text = telegramHTMLToText(parsed.Text)
|
||||
}
|
||||
bodyHTML := a.policy.Sanitize(parsed.HTML)
|
||||
bodyText := parsed.Text
|
||||
if strings.TrimSpace(bodyText) == "" {
|
||||
@@ -629,6 +648,7 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
||||
return storedMessage{
|
||||
MessageUID: newID("uid"),
|
||||
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
|
||||
RecipientAddr: recipientAddr,
|
||||
Subject: subject,
|
||||
From: from,
|
||||
FromName: fromName,
|
||||
@@ -644,6 +664,21 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
||||
}, parsed.Attachments, nil
|
||||
}
|
||||
|
||||
func originalMailRecipient(header netmail.Header) string {
|
||||
for _, key := range []string{"X-Original-To", "Delivered-To", "Envelope-To", "Original-Recipient"} {
|
||||
value := strings.TrimSpace(header.Get(key))
|
||||
if key == "Original-Recipient" {
|
||||
if _, suffix, ok := strings.Cut(value, ";"); ok {
|
||||
value = strings.TrimSpace(suffix)
|
||||
}
|
||||
}
|
||||
if address, _ := firstAddressParts(value); strings.Contains(address, "@") {
|
||||
return address
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseMailPart(header textproto.MIMEHeader, body io.Reader, parsed *parsedMail) error {
|
||||
contentType := header.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
@@ -682,6 +717,16 @@ func parseMailPart(header textproto.MIMEHeader, body io.Reader, parsed *parsedMa
|
||||
parsed.Attachments = append(parsed.Attachments, AttachmentInput{Filename: filename, ContentType: mediaType, ContentBase64: base64.StdEncoding.EncodeToString(decoded)})
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(mediaType), "text/") {
|
||||
if charset := strings.TrimSpace(params["charset"]); charset != "" && !strings.EqualFold(charset, "utf-8") && !strings.EqualFold(charset, "us-ascii") {
|
||||
if reader, decodeErr := charsetReader(charset, bytes.NewReader(decoded)); decodeErr == nil {
|
||||
if converted, readErr := io.ReadAll(reader); readErr == nil {
|
||||
decoded = converted
|
||||
}
|
||||
}
|
||||
}
|
||||
decoded = []byte(strings.ToValidUTF8(string(decoded), "�"))
|
||||
}
|
||||
switch strings.ToLower(mediaType) {
|
||||
case "text/html":
|
||||
if parsed.HTML == "" {
|
||||
|
||||
@@ -171,6 +171,9 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/maildir-sync/health", a.handleMaildirSyncHealth)
|
||||
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
|
||||
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
|
||||
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/pair", a.handleCreateTelegramPairing)
|
||||
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/discover", a.handleDiscoverTelegramChat)
|
||||
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/test", a.handleTestTelegram)
|
||||
r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
|
||||
r.With(a.requirePermission(PermissionTemplatesUpdate)).Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
|
||||
r.With(a.requirePermission(PermissionTemplatesReset)).Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
|
||||
|
||||
@@ -40,6 +40,12 @@ type SystemSettings struct {
|
||||
ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"`
|
||||
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
|
||||
ExternalIMAPOutlookClientSecretSet bool `json:"externalImapOutlookClientSecretSet"`
|
||||
TelegramMailEnabled bool `json:"telegramMailEnabled"`
|
||||
TelegramBotTokenSet bool `json:"telegramBotTokenSet"`
|
||||
TelegramPrivateChatID string `json:"telegramPrivateChatId"`
|
||||
TelegramBodyMode string `json:"telegramBodyMode"`
|
||||
TelegramMailboxIDs []string `json:"telegramMailboxIds"`
|
||||
TelegramIncludeUnregistered bool `json:"telegramIncludeUnregistered"`
|
||||
}
|
||||
|
||||
type systemSettingsUpdate struct {
|
||||
@@ -73,6 +79,12 @@ type systemSettingsUpdate struct {
|
||||
ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"`
|
||||
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
|
||||
ExternalIMAPOutlookClientSecret string `json:"externalImapOutlookClientSecret"`
|
||||
TelegramMailEnabled bool `json:"telegramMailEnabled"`
|
||||
TelegramBotToken string `json:"telegramBotToken"`
|
||||
TelegramPrivateChatID string `json:"telegramPrivateChatId"`
|
||||
TelegramBodyMode string `json:"telegramBodyMode"`
|
||||
TelegramMailboxIDs []string `json:"telegramMailboxIds"`
|
||||
TelegramIncludeUnregistered bool `json:"telegramIncludeUnregistered"`
|
||||
}
|
||||
|
||||
type PublicSettings struct {
|
||||
@@ -127,6 +139,8 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
|
||||
a.telegramDeliveryMu.Lock()
|
||||
defer a.telegramDeliveryMu.Unlock()
|
||||
var req systemSettingsUpdate
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
@@ -204,8 +218,32 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
|
||||
badRequest(w, errors.New("外部 IMAP 加密密钥未设置"))
|
||||
return
|
||||
}
|
||||
next.TelegramMailEnabled = req.TelegramMailEnabled
|
||||
if strings.TrimSpace(req.TelegramBotToken) != "" {
|
||||
next.TelegramBotToken = strings.TrimSpace(req.TelegramBotToken)
|
||||
}
|
||||
next.TelegramPrivateChatID = strings.TrimSpace(req.TelegramPrivateChatID)
|
||||
next.TelegramBodyMode = normalizeTelegramBodyMode(req.TelegramBodyMode)
|
||||
next.TelegramMailboxIDs = strings.Join(a.activeTelegramMailboxIDs(r.Context(), req.TelegramMailboxIDs), ",")
|
||||
next.TelegramIncludeUnregistered = req.TelegramIncludeUnregistered
|
||||
if next.TelegramMailEnabled {
|
||||
if next.TelegramBotToken == "" {
|
||||
badRequest(w, errors.New("Telegram Bot Token 未设置"))
|
||||
return
|
||||
}
|
||||
if !validTelegramPrivateChatID(next.TelegramPrivateChatID) {
|
||||
badRequest(w, errors.New("Telegram 私聊 Chat ID 无效"))
|
||||
return
|
||||
}
|
||||
if next.TelegramMailboxIDs == "" && !next.TelegramIncludeUnregistered {
|
||||
badRequest(w, errors.New("请至少选择一个 Telegram 通知邮箱或开启未知收件通知"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.saveSystemSettings(r.Context(), next); err != nil {
|
||||
previous := a.config()
|
||||
telegramDestinationChanged := previous.TelegramMailEnabled != next.TelegramMailEnabled || previous.TelegramBotToken != next.TelegramBotToken || previous.TelegramPrivateChatID != next.TelegramPrivateChatID || previous.TelegramMailboxIDs != next.TelegramMailboxIDs || previous.TelegramIncludeUnregistered != next.TelegramIncludeUnregistered
|
||||
if err := a.saveSystemSettings(r.Context(), next, telegramDestinationChanged); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save settings")
|
||||
return
|
||||
}
|
||||
@@ -318,6 +356,12 @@ func (a *App) systemSettingsSnapshot() SystemSettings {
|
||||
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "",
|
||||
ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID,
|
||||
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPOutlookClientSecret) != "",
|
||||
TelegramMailEnabled: cfg.TelegramMailEnabled,
|
||||
TelegramBotTokenSet: strings.TrimSpace(cfg.TelegramBotToken) != "",
|
||||
TelegramPrivateChatID: cfg.TelegramPrivateChatID,
|
||||
TelegramBodyMode: normalizeTelegramBodyMode(cfg.TelegramBodyMode),
|
||||
TelegramMailboxIDs: cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")),
|
||||
TelegramIncludeUnregistered: cfg.TelegramIncludeUnregistered,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +446,18 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
|
||||
cfg.ExternalIMAPOutlookClientID = value
|
||||
case "externalImapOutlookClientSecret":
|
||||
cfg.ExternalIMAPOutlookClientSecret = value
|
||||
case "telegramMailEnabled":
|
||||
cfg.TelegramMailEnabled = value == "true"
|
||||
case "telegramBotToken":
|
||||
cfg.TelegramBotToken = value
|
||||
case "telegramPrivateChatId":
|
||||
cfg.TelegramPrivateChatID = value
|
||||
case "telegramBodyMode":
|
||||
cfg.TelegramBodyMode = normalizeTelegramBodyMode(value)
|
||||
case "telegramMailboxIds":
|
||||
cfg.TelegramMailboxIDs = strings.Join(cleanIDList(strings.Split(value, ",")), ",")
|
||||
case "telegramIncludeUnregistered":
|
||||
cfg.TelegramIncludeUnregistered = value == "true"
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -411,7 +467,7 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
|
||||
func (a *App) saveSystemSettings(ctx context.Context, cfg Config, clearPendingTelegram bool) error {
|
||||
values := map[string]string{
|
||||
"publicHostname": cfg.PublicHostname,
|
||||
"publicBaseUrl": cfg.PublicBaseURL,
|
||||
@@ -443,6 +499,12 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
|
||||
"externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret,
|
||||
"externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID,
|
||||
"externalImapOutlookClientSecret": cfg.ExternalIMAPOutlookClientSecret,
|
||||
"telegramMailEnabled": strconv.FormatBool(cfg.TelegramMailEnabled),
|
||||
"telegramBotToken": cfg.TelegramBotToken,
|
||||
"telegramPrivateChatId": cfg.TelegramPrivateChatID,
|
||||
"telegramBodyMode": normalizeTelegramBodyMode(cfg.TelegramBodyMode),
|
||||
"telegramMailboxIds": strings.Join(cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")), ","),
|
||||
"telegramIncludeUnregistered": strconv.FormatBool(cfg.TelegramIncludeUnregistered),
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
@@ -456,6 +518,11 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if clearPendingTelegram {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM telegram_mail_outbox WHERE delivered_at IS NULL`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,992 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
nethtml "golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const (
|
||||
telegramMailMaxAttempts = 8
|
||||
telegramMessageBudget = 3800
|
||||
telegramPairingTTL = 10 * time.Minute
|
||||
)
|
||||
|
||||
type telegramPairing struct {
|
||||
TokenFingerprint string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type telegramMailPayload struct {
|
||||
From string `json:"from"`
|
||||
FromName string `json:"fromName,omitempty"`
|
||||
Recipient string `json:"recipient"`
|
||||
Subject string `json:"subject"`
|
||||
ReceivedAt string `json:"receivedAt"`
|
||||
Body string `json:"body"`
|
||||
BodyMode string `json:"bodyMode"`
|
||||
OTP string `json:"otp,omitempty"`
|
||||
AttachmentNames []string `json:"attachmentNames,omitempty"`
|
||||
AttachmentCount int `json:"attachmentCount,omitempty"`
|
||||
}
|
||||
|
||||
type telegramCredentialsRequest struct {
|
||||
BotToken string `json:"botToken"`
|
||||
ChatID string `json:"chatId"`
|
||||
PairingCode string `json:"pairingCode"`
|
||||
}
|
||||
|
||||
type telegramAPIResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
ErrorCode int `json:"error_code"`
|
||||
Description string `json:"description"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Parameters struct {
|
||||
RetryAfter int `json:"retry_after"`
|
||||
} `json:"parameters"`
|
||||
}
|
||||
|
||||
type telegramUpdate struct {
|
||||
UpdateID int64 `json:"update_id"`
|
||||
Message *struct {
|
||||
Text string `json:"text"`
|
||||
Chat struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Username string `json:"username"`
|
||||
} `json:"chat"`
|
||||
} `json:"message"`
|
||||
}
|
||||
|
||||
type telegramAPIError struct {
|
||||
HTTPStatus int
|
||||
ErrorCode int
|
||||
Description string
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
func (e *telegramAPIError) Error() string {
|
||||
description := strings.TrimSpace(e.Description)
|
||||
if description == "" {
|
||||
description = fmt.Sprintf("HTTP %d", e.HTTPStatus)
|
||||
}
|
||||
return "Telegram 发送失败: " + description
|
||||
}
|
||||
|
||||
type telegramSentMessage struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
}
|
||||
|
||||
type telegramFormattedMessage struct {
|
||||
HTML string
|
||||
PlainText string
|
||||
OTP string
|
||||
}
|
||||
|
||||
func normalizeTelegramBodyMode(value string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(value), "full") {
|
||||
return "full"
|
||||
}
|
||||
return "summary"
|
||||
}
|
||||
|
||||
func validTelegramPrivateChatID(value string) bool {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
return err == nil && id > 0
|
||||
}
|
||||
|
||||
func (a *App) handleCreateTelegramPairing(w http.ResponseWriter, r *http.Request) {
|
||||
var req telegramCredentialsRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(req.BotToken)
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(a.config().TelegramBotToken)
|
||||
}
|
||||
if token == "" {
|
||||
badRequest(w, errors.New("请先填写 Telegram Bot Token"))
|
||||
return
|
||||
}
|
||||
var bot struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if err := a.callTelegram(r.Context(), token, "getMe", map[string]any{}, &bot); err != nil {
|
||||
respondError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(bot.Username) == "" {
|
||||
respondError(w, http.StatusBadGateway, "Telegram 机器人没有可用的用户名")
|
||||
return
|
||||
}
|
||||
code, err := newTelegramPairingCode()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "无法生成 Telegram 绑定码")
|
||||
return
|
||||
}
|
||||
expiresAt := a.now().UTC().Add(telegramPairingTTL)
|
||||
a.telegramPairMu.Lock()
|
||||
for value, pairing := range a.telegramPairs {
|
||||
if !pairing.ExpiresAt.After(a.now().UTC()) {
|
||||
delete(a.telegramPairs, value)
|
||||
}
|
||||
}
|
||||
a.telegramPairs[code] = telegramPairing{TokenFingerprint: telegramTokenFingerprint(token), ExpiresAt: expiresAt}
|
||||
a.telegramPairMu.Unlock()
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"code": code,
|
||||
"botUsername": bot.Username,
|
||||
"deepLink": "https://t.me/" + url.PathEscape(bot.Username) + "?start=" + url.QueryEscape(code),
|
||||
"expiresAt": expiresAt.Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) handleDiscoverTelegramChat(w http.ResponseWriter, r *http.Request) {
|
||||
var req telegramCredentialsRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(req.BotToken)
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(a.config().TelegramBotToken)
|
||||
}
|
||||
code := strings.ToUpper(strings.TrimSpace(req.PairingCode))
|
||||
if token == "" || code == "" {
|
||||
badRequest(w, errors.New("请先生成 Telegram 一次性绑定码"))
|
||||
return
|
||||
}
|
||||
a.telegramPairMu.Lock()
|
||||
pairing, ok := a.telegramPairs[code]
|
||||
a.telegramPairMu.Unlock()
|
||||
if !ok || !pairing.ExpiresAt.After(a.now().UTC()) || pairing.TokenFingerprint != telegramTokenFingerprint(token) {
|
||||
badRequest(w, errors.New("Telegram 绑定码无效或已过期,请重新生成"))
|
||||
return
|
||||
}
|
||||
chatID, displayName, err := a.discoverTelegramPrivateChat(r.Context(), token, code)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
a.telegramPairMu.Lock()
|
||||
delete(a.telegramPairs, code)
|
||||
a.telegramPairMu.Unlock()
|
||||
respondJSON(w, http.StatusOK, map[string]string{"chatId": chatID, "displayName": displayName})
|
||||
}
|
||||
|
||||
func (a *App) handleTestTelegram(w http.ResponseWriter, r *http.Request) {
|
||||
var req telegramCredentialsRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
token, chatID := a.telegramCredentials(req)
|
||||
if token == "" {
|
||||
badRequest(w, errors.New("请先填写 Telegram Bot Token"))
|
||||
return
|
||||
}
|
||||
if !validTelegramPrivateChatID(chatID) {
|
||||
badRequest(w, errors.New("请先获取或填写有效的私聊 Chat ID"))
|
||||
return
|
||||
}
|
||||
now := a.now().Local().Format("2006-01-02 15:04:05 MST")
|
||||
text := "<b>NewSzxcn 邮箱通知测试</b>\n\nTelegram 私聊邮件通知连接正常。\n\n<b>测试时间:</b>" + html.EscapeString(now)
|
||||
if err := a.sendTelegramMessage(r.Context(), token, chatID, text); err != nil {
|
||||
respondError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) telegramCredentials(req telegramCredentialsRequest) (string, string) {
|
||||
cfg := a.config()
|
||||
token := strings.TrimSpace(req.BotToken)
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(cfg.TelegramBotToken)
|
||||
}
|
||||
chatID := strings.TrimSpace(req.ChatID)
|
||||
if chatID == "" {
|
||||
chatID = strings.TrimSpace(cfg.TelegramPrivateChatID)
|
||||
}
|
||||
return token, chatID
|
||||
}
|
||||
|
||||
func (a *App) discoverTelegramPrivateChat(ctx context.Context, token, pairingCode string) (string, string, error) {
|
||||
var updates []telegramUpdate
|
||||
if err := a.callTelegram(ctx, token, "getUpdates", map[string]any{
|
||||
"limit": 100,
|
||||
"timeout": 0,
|
||||
"allowed_updates": []string{"message"},
|
||||
}, &updates); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for i := len(updates) - 1; i >= 0; i-- {
|
||||
message := updates[i].Message
|
||||
if message == nil || message.Chat.Type != "private" || message.Chat.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(message.Text)
|
||||
if text != pairingCode && text != "/start "+pairingCode {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(strings.Join([]string{message.Chat.FirstName, message.Chat.LastName}, " "))
|
||||
if name == "" && message.Chat.Username != "" {
|
||||
name = "@" + message.Chat.Username
|
||||
}
|
||||
return strconv.FormatInt(message.Chat.ID, 10), name, nil
|
||||
}
|
||||
return "", "", errors.New("未找到匹配的私聊,请打开机器人发送绑定码后重试")
|
||||
}
|
||||
|
||||
func newTelegramPairingCode() (string, error) {
|
||||
raw := make([]byte, 6)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToUpper(hex.EncodeToString(raw)), nil
|
||||
}
|
||||
|
||||
func telegramTokenFingerprint(token string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func telegramMailboxAllowed(cfg Config, mailboxID string) bool {
|
||||
mailboxID = strings.TrimSpace(mailboxID)
|
||||
if mailboxID == "" {
|
||||
return cfg.TelegramIncludeUnregistered
|
||||
}
|
||||
for _, id := range cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")) {
|
||||
if id == mailboxID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) activeTelegramMailboxIDs(ctx context.Context, values []string) []string {
|
||||
ids := cleanIDList(values)
|
||||
active := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
var exists int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT 1 FROM mailboxes WHERE id=? AND status='active'`, id).Scan(&exists); err == nil && exists == 1 {
|
||||
active = append(active, id)
|
||||
}
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
func (a *App) enqueueTelegramMailNotification(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) {
|
||||
cfg := a.config()
|
||||
if !cfg.TelegramMailEnabled || strings.TrimSpace(cfg.TelegramBotToken) == "" || !validTelegramPrivateChatID(cfg.TelegramPrivateChatID) || !telegramMailboxAllowed(cfg, msg.MailboxID) {
|
||||
return
|
||||
}
|
||||
recipient := normalizeEmail(msg.RecipientAddr)
|
||||
if recipient == "" && len(msg.To) > 0 {
|
||||
recipient = normalizeEmail(msg.To[0])
|
||||
}
|
||||
body := telegramMessageBody(msg)
|
||||
otp := detectTelegramOTP(msg.Subject, body)
|
||||
mode := normalizeTelegramBodyMode(cfg.TelegramBodyMode)
|
||||
limit := 800
|
||||
if mode == "full" {
|
||||
limit = 2600
|
||||
}
|
||||
body, truncated := truncateRunes(body, limit)
|
||||
if truncated {
|
||||
body += "..."
|
||||
}
|
||||
if body == "" {
|
||||
body = normalizeTelegramText(msg.Snippet)
|
||||
}
|
||||
from, _ := truncateRunes(strings.TrimSpace(msg.From), 254)
|
||||
fromName, _ := truncateRunes(strings.TrimSpace(msg.FromName), 160)
|
||||
subject, _ := truncateRunes(strings.TrimSpace(msg.Subject), 240)
|
||||
names := make([]string, 0, min(len(attachments), 5))
|
||||
for _, attachment := range attachments {
|
||||
name := sanitizeTelegramAttachmentName(attachment.Filename)
|
||||
if name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
if len(names) >= 5 {
|
||||
break
|
||||
}
|
||||
}
|
||||
payload := telegramMailPayload{
|
||||
From: from,
|
||||
FromName: fromName,
|
||||
Recipient: recipient,
|
||||
Subject: subject,
|
||||
ReceivedAt: a.now().UTC().Format(time.RFC3339Nano),
|
||||
Body: body,
|
||||
BodyMode: mode,
|
||||
OTP: otp,
|
||||
AttachmentNames: names,
|
||||
AttachmentCount: len(attachments),
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT OR IGNORE INTO telegram_mail_outbox(id,message_id,payload_json,next_attempt_at,created_at,updated_at) VALUES(?,?,?,?,?,?)`, newID("tgm"), messageID, jsonEncode(payload), now, now, now); err != nil {
|
||||
a.log.Warn("failed to enqueue Telegram mail notification", "messageId", messageID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func telegramMessageBody(msg storedMessage) string {
|
||||
text := strings.TrimSpace(msg.BodyText)
|
||||
text, _ = truncateRunes(text, 128*1024)
|
||||
if text != "" && looksLikeHTMLDocument(text) {
|
||||
text = telegramHTMLToText(text)
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
text = telegramHTMLToText(msg.BodyHTML)
|
||||
}
|
||||
return stripTelegramQuotedContent(normalizeTelegramText(text))
|
||||
}
|
||||
|
||||
func looksLikeHTMLDocument(value string) bool {
|
||||
value, _ = truncateRunes(value, 128*1024)
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if strings.HasPrefix(value, "<!doctype html") || strings.HasPrefix(value, "<html") || strings.HasPrefix(value, "<head") || strings.HasPrefix(value, "<body") || strings.HasPrefix(value, "<style") {
|
||||
return true
|
||||
}
|
||||
matches := telegramHTMLTagRe.FindAllStringIndex(value, 4)
|
||||
return len(matches) >= 3
|
||||
}
|
||||
|
||||
func telegramHTMLToText(value string) string {
|
||||
value = strings.ToValidUTF8(value, "�")
|
||||
value, _ = truncateRunes(value, 128*1024)
|
||||
doc, err := nethtml.Parse(strings.NewReader(value))
|
||||
if err != nil {
|
||||
return stripTags(value)
|
||||
}
|
||||
var out strings.Builder
|
||||
var walk func(*nethtml.Node, bool)
|
||||
walk = func(node *nethtml.Node, skipped bool) {
|
||||
if node.Type == nethtml.ElementNode {
|
||||
switch strings.ToLower(node.Data) {
|
||||
case "script", "style", "head", "noscript", "svg":
|
||||
skipped = true
|
||||
case "br":
|
||||
if !skipped {
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
if node.Type == nethtml.TextNode && !skipped {
|
||||
out.WriteString(node.Data)
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child, skipped)
|
||||
}
|
||||
if node.Type == nethtml.ElementNode && !skipped {
|
||||
switch strings.ToLower(node.Data) {
|
||||
case "p", "div", "li", "tr", "table", "section", "article", "header", "footer", "h1", "h2", "h3", "h4", "h5", "h6":
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(doc, false)
|
||||
return normalizeTelegramText(out.String())
|
||||
}
|
||||
|
||||
func normalizeTelegramText(value string) string {
|
||||
value = strings.ReplaceAll(strings.ToValidUTF8(value, "�"), "\r\n", "\n")
|
||||
value = strings.ReplaceAll(value, "\r", "\n")
|
||||
lines := strings.Split(value, "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
empty := false
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(strings.Map(func(r rune) rune {
|
||||
if r == '\t' {
|
||||
return ' '
|
||||
}
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, line))
|
||||
line = strings.Join(strings.Fields(line), " ")
|
||||
if line == "" {
|
||||
if !empty && len(out) > 0 {
|
||||
out = append(out, "")
|
||||
}
|
||||
empty = true
|
||||
continue
|
||||
}
|
||||
empty = false
|
||||
out = append(out, line)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||
}
|
||||
|
||||
var telegramQuoteBoundaryRe = regexp.MustCompile(`(?i)^(?:-{2,}\s*(?:original message|原始邮件)\s*-*|on .+ wrote:|发件人[::]|from[::].+|_{5,})$`)
|
||||
var telegramHTMLTagRe = regexp.MustCompile(`(?i)</?(?:div|p|table|tr|td|br|span|a|img)(?:\s[^>]*)?>`)
|
||||
|
||||
func stripTelegramQuotedContent(value string) string {
|
||||
lines := strings.Split(value, "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if i > 0 && (trimmed == "--" || telegramQuoteBoundaryRe.MatchString(trimmed)) {
|
||||
lines = lines[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func sanitizeTelegramAttachmentName(value string) string {
|
||||
value = strings.TrimSpace(strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, strings.ToValidUTF8(value, "�")))
|
||||
value = strings.Join(strings.Fields(value), " ")
|
||||
value, truncated := truncateRunes(value, 100)
|
||||
if truncated {
|
||||
value += "..."
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
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 {
|
||||
text := normalizeTelegramText(strings.TrimSpace(subject) + "\n" + body)
|
||||
keywords := telegramOTPKeywordRe.FindAllStringIndex(text, -1)
|
||||
if len(keywords) == 0 {
|
||||
return ""
|
||||
}
|
||||
type candidateScore struct {
|
||||
value string
|
||||
score int
|
||||
count int
|
||||
}
|
||||
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
|
||||
}
|
||||
if match[1] < len(text) && isTelegramOTPAlphaNumeric(rune(text[match[1]])) {
|
||||
continue
|
||||
}
|
||||
value := strings.ToUpper(text[match[0]:match[1]])
|
||||
hasDigit := false
|
||||
for _, r := range value {
|
||||
if unicode.IsDigit(r) {
|
||||
hasDigit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDigit || telegramOTPKeywordRe.MatchString(value) {
|
||||
continue
|
||||
}
|
||||
if isTelegramOTPNonCode(value) {
|
||||
continue
|
||||
}
|
||||
best := 0
|
||||
for _, keyword := range keywords {
|
||||
distance := match[0] - keyword[1]
|
||||
if distance < 0 {
|
||||
distance = keyword[0] - match[1]
|
||||
}
|
||||
if distance < 0 {
|
||||
distance = 0
|
||||
}
|
||||
score := 0
|
||||
switch {
|
||||
case distance <= 16:
|
||||
score = 100
|
||||
case distance <= 48:
|
||||
score = 80
|
||||
case distance <= 100:
|
||||
score = 55
|
||||
}
|
||||
if match[0] <= subjectEnd {
|
||||
score += 15
|
||||
}
|
||||
if score > best {
|
||||
best = score
|
||||
}
|
||||
}
|
||||
if best == 0 {
|
||||
continue
|
||||
}
|
||||
current := scores[value]
|
||||
current.value = value
|
||||
current.count++
|
||||
if best > current.score {
|
||||
current.score = best
|
||||
}
|
||||
scores[value] = current
|
||||
}
|
||||
items := make([]candidateScore, 0, len(scores))
|
||||
for _, item := range scores {
|
||||
item.score += min(item.count-1, 2) * 5
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].score > items[j].score })
|
||||
if len(items) == 0 || items[0].score < 55 {
|
||||
return ""
|
||||
}
|
||||
if len(items) > 1 && items[1].score >= items[0].score-25 {
|
||||
return ""
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
func (a *App) shouldNotifyTelegramMessage(ctx context.Context, messageID string) bool {
|
||||
var folder string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT lower(COALESCE(NULLIF(f.role,''),f.name,'')) FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, messageID).Scan(&folder); err != nil {
|
||||
return false
|
||||
}
|
||||
switch strings.TrimSpace(folder) {
|
||||
case "spam", "junk", "trash", "deleted":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) telegramMailWorker(ctx context.Context) {
|
||||
a.log.Info("Telegram mail notification worker started")
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if err := a.processDueTelegramMailNotifications(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
a.log.Warn("Telegram mail notification worker failed", "error", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.log.Info("Telegram mail notification worker stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) processDueTelegramMailNotifications(ctx context.Context) error {
|
||||
a.telegramDeliveryMu.Lock()
|
||||
defer a.telegramDeliveryMu.Unlock()
|
||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM telegram_mail_outbox WHERE updated_at<? AND (delivered_at IS NOT NULL OR attempt_count>=?)`, a.now().UTC().Add(-30*24*time.Hour).Format(time.RFC3339Nano), telegramMailMaxAttempts)
|
||||
cfg := a.config()
|
||||
if !cfg.TelegramMailEnabled || strings.TrimSpace(cfg.TelegramBotToken) == "" || !validTelegramPrivateChatID(cfg.TelegramPrivateChatID) {
|
||||
return nil
|
||||
}
|
||||
nowText := a.now().UTC().Format(time.RFC3339Nano)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,payload_json,attempt_count FROM telegram_mail_outbox WHERE delivered_at IS NULL AND attempt_count<? AND next_attempt_at<=? AND (lease_until='' OR lease_until<=?) ORDER BY next_attempt_at,created_at LIMIT 20`, telegramMailMaxAttempts, nowText, nowText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type queueItem struct {
|
||||
id string
|
||||
payload telegramMailPayload
|
||||
attempt int
|
||||
invalid bool
|
||||
}
|
||||
items := []queueItem{}
|
||||
for rows.Next() {
|
||||
var item queueItem
|
||||
var raw string
|
||||
if err := rows.Scan(&item.id, &raw, &item.attempt); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &item.payload); err != nil {
|
||||
item.invalid = true
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.invalid {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=?,last_error='通知数据损坏',updated_at=?,lease_until='',payload_json='{}' WHERE id=?`, telegramMailMaxAttempts, now, item.id); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
now := a.now().UTC()
|
||||
leaseUntil := now.Add(2 * time.Minute).Format(time.RFC3339Nano)
|
||||
result, err := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET lease_until=?,updated_at=? WHERE id=? AND delivered_at IS NULL AND (lease_until='' OR lease_until<=?)`, leaseUntil, now.Format(time.RFC3339Nano), item.id, now.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
continue
|
||||
}
|
||||
formatted := formatTelegramMailMessage(item.payload)
|
||||
telegramMessageID, err := a.deliverTelegramMailMessage(ctx, cfg.TelegramBotToken, cfg.TelegramPrivateChatID, formatted)
|
||||
now = a.now().UTC()
|
||||
if err != nil {
|
||||
attempts := item.attempt + 1
|
||||
delay := sendRetryDelay(attempts)
|
||||
var apiErr *telegramAPIError
|
||||
if errors.As(err, &apiErr) {
|
||||
if apiErr.RetryAfter > 0 {
|
||||
delay = apiErr.RetryAfter
|
||||
}
|
||||
code := apiErr.ErrorCode
|
||||
if code == 0 {
|
||||
code = apiErr.HTTPStatus
|
||||
}
|
||||
if code == http.StatusUnauthorized || code == http.StatusForbidden || (code >= 400 && code < 500 && code != http.StatusTooManyRequests) {
|
||||
attempts = telegramMailMaxAttempts
|
||||
}
|
||||
}
|
||||
next := now.Add(delay)
|
||||
if _, updateErr := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=?,next_attempt_at=?,last_error=?,updated_at=?,lease_until='',payload_json=CASE WHEN ?>=? THEN '{}' ELSE payload_json END WHERE id=? AND delivered_at IS NULL`, attempts, next.Format(time.RFC3339Nano), truncateWebhookError(err.Error()), now.Format(time.RFC3339Nano), attempts, telegramMailMaxAttempts, item.id); updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
stamp := now.Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=attempt_count+1,last_error='',updated_at=?,delivered_at=?,lease_until='',telegram_message_id=?,payload_json='{}' WHERE id=? AND delivered_at IS NULL`, stamp, stamp, telegramMessageID, item.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatTelegramMailMessage(payload telegramMailPayload) telegramFormattedMessage {
|
||||
subject := strings.TrimSpace(payload.Subject)
|
||||
if subject == "" || subject == "(no subject)" {
|
||||
subject = "(无主题)"
|
||||
}
|
||||
from := strings.TrimSpace(payload.From)
|
||||
if name := strings.TrimSpace(payload.FromName); name != "" {
|
||||
from = name + " <" + from + ">"
|
||||
}
|
||||
receivedAt := parseTime(payload.ReceivedAt)
|
||||
timeText := strings.TrimSpace(payload.ReceivedAt)
|
||||
if !receivedAt.IsZero() {
|
||||
timeText = receivedAt.Local().Format("2006-01-02 15:04:05 MST")
|
||||
}
|
||||
subject, _ = truncateRunes(subject, 180)
|
||||
from, _ = truncateRunes(from, 220)
|
||||
recipient, _ := truncateRunes(strings.TrimSpace(payload.Recipient), 160)
|
||||
lines := []string{
|
||||
"📩 <b>新邮件通知</b>",
|
||||
"",
|
||||
"<b>主题:</b>" + escapeTelegramWithinBudget(subject, 420),
|
||||
"<b>发件人:</b>" + escapeTelegramWithinBudget(from, 500),
|
||||
"<b>收件邮箱:</b><code>" + escapeTelegramWithinBudget(recipient, 320) + "</code>",
|
||||
"<b>收件时间:</b>" + html.EscapeString(timeText),
|
||||
}
|
||||
if payload.OTP != "" {
|
||||
lines = append(lines, "", "🔐 <b>验证码</b>", "<code>"+html.EscapeString(payload.OTP)+"</code>")
|
||||
}
|
||||
if len(payload.AttachmentNames) > 0 {
|
||||
names := make([]string, 0, len(payload.AttachmentNames))
|
||||
for _, name := range payload.AttachmentNames {
|
||||
names = append(names, escapeTelegramWithinBudget(name, 180))
|
||||
}
|
||||
attachmentText := strings.Join(names, "、")
|
||||
if payload.AttachmentCount > len(payload.AttachmentNames) {
|
||||
attachmentText += fmt.Sprintf(",其余 %d 个未显示", payload.AttachmentCount-len(payload.AttachmentNames))
|
||||
}
|
||||
lines = append(lines, "", fmt.Sprintf("📎 <b>附件:%d 个</b>", max(payload.AttachmentCount, len(payload.AttachmentNames))), attachmentText)
|
||||
}
|
||||
body := strings.TrimSpace(payload.Body)
|
||||
if body != "" {
|
||||
label := "正文摘要"
|
||||
if normalizeTelegramBodyMode(payload.BodyMode) == "full" {
|
||||
label = "邮件正文"
|
||||
}
|
||||
prefix := strings.Join(lines, "\n") + "\n\n<b>" + label + "</b>\n<blockquote>"
|
||||
suffix := "</blockquote>"
|
||||
body = formatTelegramBodyHTML(body, telegramMessageBudget-utf8.RuneCountInString(prefix)-utf8.RuneCountInString(suffix))
|
||||
lines = []string{prefix + body + suffix}
|
||||
}
|
||||
htmlText := strings.Join(lines, "\n")
|
||||
plain := formatTelegramMailPlainText(payload)
|
||||
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,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": true,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) deliverTelegramMailMessage(ctx context.Context, token, chatID string, message telegramFormattedMessage) (int64, error) {
|
||||
payload := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"text": message.HTML,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": true,
|
||||
}
|
||||
if markup := telegramCopyMarkup(message.OTP); markup != nil {
|
||||
payload["reply_markup"] = markup
|
||||
}
|
||||
result, err := a.sendTelegramPayload(ctx, token, payload)
|
||||
if err == nil {
|
||||
return result.MessageID, nil
|
||||
}
|
||||
var apiErr *telegramAPIError
|
||||
if !errors.As(err, &apiErr) || apiErr.ErrorCode != http.StatusBadRequest {
|
||||
return 0, err
|
||||
}
|
||||
fallback := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"text": message.PlainText,
|
||||
"disable_web_page_preview": true,
|
||||
}
|
||||
if markup := telegramCopyMarkup(message.OTP); markup != nil {
|
||||
fallback["reply_markup"] = markup
|
||||
}
|
||||
result, err = a.sendTelegramPayload(ctx, token, fallback)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.MessageID, nil
|
||||
}
|
||||
|
||||
func (a *App) sendTelegramPayload(ctx context.Context, token string, payload map[string]any) (telegramSentMessage, error) {
|
||||
var result telegramSentMessage
|
||||
err := a.callTelegram(ctx, token, "sendMessage", payload, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func telegramCopyMarkup(otp string) map[string]any {
|
||||
otp = strings.TrimSpace(otp)
|
||||
if otp == "" || utf8.RuneCountInString(otp) > 256 {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"inline_keyboard": [][]map[string]any{{{
|
||||
"text": "复制验证码",
|
||||
"copy_text": map[string]string{"text": otp},
|
||||
}}}}
|
||||
}
|
||||
|
||||
func escapeTelegramWithinBudget(value string, budget int) string {
|
||||
if budget <= 3 {
|
||||
return ""
|
||||
}
|
||||
var out strings.Builder
|
||||
used := 0
|
||||
truncated := false
|
||||
for _, r := range value {
|
||||
escaped := html.EscapeString(string(r))
|
||||
length := utf8.RuneCountInString(escaped)
|
||||
if used+length > budget-3 {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
out.WriteString(escaped)
|
||||
used += length
|
||||
}
|
||||
if truncated {
|
||||
out.WriteString("...")
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func formatTelegramMailPlainText(payload telegramMailPayload) string {
|
||||
subject := strings.TrimSpace(payload.Subject)
|
||||
if subject == "" || subject == "(no subject)" {
|
||||
subject = "(无主题)"
|
||||
}
|
||||
from := strings.TrimSpace(payload.From)
|
||||
if name := strings.TrimSpace(payload.FromName); name != "" {
|
||||
from = name + " <" + from + ">"
|
||||
}
|
||||
receivedAt := parseTime(payload.ReceivedAt)
|
||||
timeText := strings.TrimSpace(payload.ReceivedAt)
|
||||
if !receivedAt.IsZero() {
|
||||
timeText = receivedAt.Local().Format("2006-01-02 15:04:05 MST")
|
||||
}
|
||||
parts := []string{"新邮件通知", "", "主题:" + subject, "发件人:" + from, "收件邮箱:" + payload.Recipient, "收件时间:" + timeText}
|
||||
if payload.OTP != "" {
|
||||
parts = append(parts, "", "验证码", payload.OTP)
|
||||
}
|
||||
if payload.AttachmentCount > 0 {
|
||||
parts = append(parts, "", fmt.Sprintf("附件:%d 个", payload.AttachmentCount))
|
||||
}
|
||||
if body := strings.TrimSpace(payload.Body); body != "" {
|
||||
parts = append(parts, "", "正文摘要", body)
|
||||
}
|
||||
text := normalizeTelegramText(strings.Join(parts, "\n"))
|
||||
text, truncated := truncateRunes(text, telegramMessageBudget-3)
|
||||
if truncated {
|
||||
text += "..."
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (a *App) callTelegram(ctx context.Context, token, method string, payload any, result any) error {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" || strings.ContainsAny(token, "/\\\r\n") {
|
||||
return errors.New("Telegram Bot Token 无效")
|
||||
}
|
||||
base := strings.TrimRight(strings.TrimSpace(a.telegramURL), "/")
|
||||
endpoint := base + "/bot" + url.PathEscape(token) + "/" + method
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "NewSzxcn-Email-Telegram/1.0")
|
||||
client := &http.Client{Timeout: 12 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return errors.New("Telegram 请求失败,请检查网络连接和机器人配置")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var apiResponse telegramAPIResponse
|
||||
if err := json.Unmarshal(raw, &apiResponse); err != nil {
|
||||
return fmt.Errorf("Telegram 返回了无效响应(HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 || !apiResponse.OK {
|
||||
description := strings.TrimSpace(apiResponse.Description)
|
||||
if description == "" {
|
||||
description = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return &telegramAPIError{HTTPStatus: resp.StatusCode, ErrorCode: apiResponse.ErrorCode, Description: description, RetryAfter: time.Duration(apiResponse.Parameters.RetryAfter) * time.Second}
|
||||
}
|
||||
if result != nil && len(apiResponse.Result) > 0 {
|
||||
if err := json.Unmarshal(apiResponse.Result, result); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
func TestTelegramSettingsDiscoveryTestAndMailQueue(t *testing.T) {
|
||||
type sentMessage struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
ReplyMarkup map[string]any `json:"reply_markup"`
|
||||
}
|
||||
var sent []sentMessage
|
||||
var pairingCode atomic.Value
|
||||
pairingCode.Store("")
|
||||
telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/bottest-token/getMe":
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"newszxcn_test_bot"}}`))
|
||||
case "/bottest-token/getUpdates":
|
||||
code, _ := pairingCode.Load().(string)
|
||||
_, _ = fmt.Fprintf(w, `{"ok":true,"result":[{"update_id":6,"message":{"text":"/start wrong-code","chat":{"id":987654321,"type":"private","first_name":"Other"}}},{"update_id":7,"message":{"text":"/start %s","chat":{"id":123456789,"type":"private","first_name":"Zhenxi","last_name":"Shen"}}}]}`, code)
|
||||
case "/bottest-token/sendMessage":
|
||||
var message sentMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
|
||||
t.Fatalf("decode Telegram message: %v", err)
|
||||
}
|
||||
sent = append(sent, message)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":8}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer telegramServer.Close()
|
||||
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = telegramServer.URL
|
||||
server := httptest.NewServer(a.Router())
|
||||
defer server.Close()
|
||||
admin := &testClient{t: t, server: server}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d body=%v", code, login)
|
||||
}
|
||||
|
||||
var settings SystemSettings
|
||||
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
|
||||
t.Fatalf("get settings code=%d", code)
|
||||
}
|
||||
payload := systemSettingsPayload(settings)
|
||||
payload["telegramMailEnabled"] = true
|
||||
payload["telegramBotToken"] = "test-token"
|
||||
payload["telegramPrivateChatId"] = "123456789"
|
||||
payload["telegramBodyMode"] = "full"
|
||||
var adminMailboxID string
|
||||
if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&adminMailboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload["telegramMailboxIds"] = []string{adminMailboxID}
|
||||
if code := admin.do("POST", "/api/admin/settings", payload, &settings); code != http.StatusOK {
|
||||
t.Fatalf("save Telegram settings code=%d settings=%+v", code, settings)
|
||||
}
|
||||
if !settings.TelegramMailEnabled || !settings.TelegramBotTokenSet || settings.TelegramPrivateChatID != "123456789" || settings.TelegramBodyMode != "full" {
|
||||
t.Fatalf("unexpected Telegram settings: %+v", settings)
|
||||
}
|
||||
if a.config().TelegramBotToken != "test-token" {
|
||||
t.Fatal("Telegram token was not persisted in runtime config")
|
||||
}
|
||||
|
||||
var pairing struct {
|
||||
Code string `json:"code"`
|
||||
DeepLink string `json:"deepLink"`
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/settings/telegram/pair", map[string]string{"botToken": ""}, &pairing); code != http.StatusOK || pairing.Code == "" || !strings.Contains(pairing.DeepLink, pairing.Code) {
|
||||
t.Fatalf("create pairing code=%d response=%+v", code, pairing)
|
||||
}
|
||||
pairingCode.Store(pairing.Code)
|
||||
var discovered map[string]string
|
||||
if code := admin.do("POST", "/api/admin/settings/telegram/discover", map[string]string{"botToken": "", "pairingCode": pairing.Code}, &discovered); code != http.StatusOK {
|
||||
t.Fatalf("discover chat code=%d response=%v", code, discovered)
|
||||
}
|
||||
if discovered["chatId"] != "123456789" || discovered["displayName"] != "Zhenxi Shen" {
|
||||
t.Fatalf("unexpected discovered chat: %v", discovered)
|
||||
}
|
||||
var testResult map[string]any
|
||||
if code := admin.do("POST", "/api/admin/settings/telegram/test", map[string]string{"botToken": "", "chatId": ""}, &testResult); code != http.StatusOK {
|
||||
t.Fatalf("test Telegram code=%d response=%v", code, testResult)
|
||||
}
|
||||
if len(sent) != 1 || sent[0].ChatID != "123456789" || !strings.Contains(sent[0].Text, "通知测试") {
|
||||
t.Fatalf("unexpected Telegram test message: %+v", sent)
|
||||
}
|
||||
|
||||
sent = nil
|
||||
receivedAt := time.Date(2026, 8, 6, 9, 30, 0, 0, time.UTC)
|
||||
a.enqueueTelegramMailNotification(context.Background(), "mail_test_telegram", storedMessage{
|
||||
MailboxID: adminMailboxID,
|
||||
RecipientAddr: "admin@example.com",
|
||||
Subject: "账单 <已生成>",
|
||||
From: "billing@example.net",
|
||||
FromName: "Billing & Support",
|
||||
ReceivedAt: receivedAt,
|
||||
BodyText: "这是邮件正文,验证码是 846981,包含 <VIP> & 续费信息。",
|
||||
}, []AttachmentInput{{Filename: "账单-2026.pdf"}})
|
||||
if err := a.processDueTelegramMailNotifications(context.Background()); err != nil {
|
||||
t.Fatalf("process Telegram mail queue: %v", err)
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one queued Telegram message, got %d", len(sent))
|
||||
}
|
||||
text := sent[0].Text
|
||||
for _, expected := range []string{"新邮件通知", "Billing & Support", "账单 <已生成>", "admin@example.com", "邮件正文", "账单-2026.pdf", "846981", "<VIP> & 续费信息"} {
|
||||
if !strings.Contains(text, expected) {
|
||||
t.Fatalf("Telegram mail message missing %q: %s", expected, text)
|
||||
}
|
||||
}
|
||||
if sent[0].ReplyMarkup == nil {
|
||||
t.Fatal("Telegram OTP copy button was not included")
|
||||
}
|
||||
var delivered, storedPayload string
|
||||
var telegramMessageID int64
|
||||
if err := a.db.QueryRow(`SELECT COALESCE(delivered_at,''),payload_json,telegram_message_id FROM telegram_mail_outbox WHERE message_id=?`, "mail_test_telegram").Scan(&delivered, &storedPayload, &telegramMessageID); err != nil || delivered == "" {
|
||||
t.Fatalf("Telegram queue was not marked delivered: delivered=%q err=%v", delivered, err)
|
||||
}
|
||||
if storedPayload != "{}" || telegramMessageID != 8 {
|
||||
t.Fatalf("delivered payload was not cleared safely: payload=%q telegramMessageId=%d", storedPayload, telegramMessageID)
|
||||
}
|
||||
|
||||
a.enqueueTelegramMailNotification(context.Background(), "mail_pending_before_disable", storedMessage{MailboxID: adminMailboxID, RecipientAddr: "admin@lanqin.local", Subject: "pending", From: "sender@example.com", ReceivedAt: time.Now(), BodyText: "pending"}, nil)
|
||||
disablePayload := systemSettingsPayload(settings)
|
||||
disablePayload["telegramMailEnabled"] = false
|
||||
if code := admin.do("POST", "/api/admin/settings", disablePayload, &settings); code != http.StatusOK {
|
||||
t.Fatalf("disable Telegram settings code=%d", code)
|
||||
}
|
||||
var pending int
|
||||
if err := a.db.QueryRow(`SELECT COUNT(1) FROM telegram_mail_outbox WHERE delivered_at IS NULL`).Scan(&pending); err != nil || pending != 0 {
|
||||
t.Fatalf("pending Telegram queue was not cleared: count=%d err=%v", pending, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramSettingsRejectEnabledWithoutCredentials(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
server := httptest.NewServer(a.Router())
|
||||
defer server.Close()
|
||||
admin := &testClient{t: t, server: server}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d", code)
|
||||
}
|
||||
var settings SystemSettings
|
||||
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
|
||||
t.Fatalf("get settings code=%d", code)
|
||||
}
|
||||
payload := systemSettingsPayload(settings)
|
||||
payload["telegramMailEnabled"] = true
|
||||
var body map[string]any
|
||||
if code := admin.do("POST", "/api/admin/settings", payload, &body); code != http.StatusBadRequest {
|
||||
t.Fatalf("expected missing Telegram credentials to fail, code=%d body=%v", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramNetworkErrorDoesNotExposeToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
serverURL := server.URL
|
||||
server.Close()
|
||||
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = serverURL
|
||||
const token = "123456:secret-token-value"
|
||||
err := a.sendTelegramMessage(context.Background(), token, "123456789", "test")
|
||||
if err == nil {
|
||||
t.Fatal("expected Telegram network request to fail")
|
||||
}
|
||||
if strings.Contains(err.Error(), token) || strings.Contains(err.Error(), "secret-token-value") {
|
||||
t.Fatalf("Telegram error exposed Bot Token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramOTPDetectionAndMessageBudget(t *testing.T) {
|
||||
body := "本次登录验证码为 846981,请在十分钟内完成验证。\n\nOn yesterday wrote:\n旧验证码是 112233"
|
||||
cleaned := stripTelegramQuotedContent(body)
|
||||
if otp := detectTelegramOTP("登录验证", cleaned); otp != "846981" {
|
||||
t.Fatalf("unexpected OTP %q", otp)
|
||||
}
|
||||
if otp := detectTelegramOTP("验证码", "验证码可能是 123456 或 654321,请联系客服确认"); otp != "" {
|
||||
t.Fatalf("ambiguous OTP should not be selected: %q", otp)
|
||||
}
|
||||
message := formatTelegramMailMessage(telegramMailPayload{
|
||||
From: strings.Repeat("R&D <team@example.com> ", 30),
|
||||
Recipient: "admin@example.com",
|
||||
Subject: strings.Repeat("超长主题 & <test> ", 50),
|
||||
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Body: strings.Repeat("正文内容 & <重要> ", 1000),
|
||||
BodyMode: "full",
|
||||
OTP: "846981",
|
||||
AttachmentNames: []string{
|
||||
strings.Repeat("附件&", 80), strings.Repeat("报价<", 80), strings.Repeat("说明", 80),
|
||||
},
|
||||
AttachmentCount: 12,
|
||||
})
|
||||
if got := utf8.RuneCountInString(message.HTML); got > telegramMessageBudget {
|
||||
t.Fatalf("Telegram HTML exceeds budget: %d", got)
|
||||
}
|
||||
if !strings.Contains(message.HTML, "&") || !strings.Contains(message.HTML, "<") || !strings.Contains(message.HTML, "<code>846981</code>") {
|
||||
t.Fatalf("message escaping or OTP formatting missing: %s", message.HTML)
|
||||
}
|
||||
if markup := telegramCopyMarkup(message.OTP); markup == nil {
|
||||
t.Fatal("copy_text markup missing")
|
||||
}
|
||||
}
|
||||
|
||||
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})
|
||||
if strings.Contains(text, "display:none") || strings.Contains(text, "<p>") || !strings.Contains(text, "778899") {
|
||||
t.Fatalf("pseudo HTML was not cleaned: %q", text)
|
||||
}
|
||||
|
||||
encoded, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("您的验证码是 445566"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := append([]byte("From: sender@example.com\r\nTo: admin@example.com\r\nSubject: GBK\r\nContent-Type: text/plain; charset=gbk\r\n\r\n"), encoded...)
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
msg, _, err := a.parseMaildirMessage(raw, "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(msg.BodyText, "445566") || !strings.Contains(msg.BodyText, "验证码") {
|
||||
t.Fatalf("GBK body was not decoded: %q", msg.BodyText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramRetryAfterAndPermanentErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
response string
|
||||
retryAfter time.Duration
|
||||
}{
|
||||
{name: "rate limit", status: http.StatusTooManyRequests, response: `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":17}}`, retryAfter: 17 * time.Second},
|
||||
{name: "unauthorized", status: http.StatusUnauthorized, response: `{"ok":false,"error_code":401,"description":"Unauthorized"}`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(tc.status)
|
||||
_, _ = w.Write([]byte(tc.response))
|
||||
}))
|
||||
defer server.Close()
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = server.URL
|
||||
err := a.sendTelegramMessage(context.Background(), "test-token", "123456", "test")
|
||||
var apiErr *telegramAPIError
|
||||
if !errors.As(err, &apiErr) || apiErr.ErrorCode != tc.status || apiErr.RetryAfter != tc.retryAfter {
|
||||
t.Fatalf("unexpected Telegram error: %#v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramMailboxScopeAndOriginalRecipient(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
var mailboxID string
|
||||
if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&mailboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.updateConfig(func(cfg *Config) {
|
||||
cfg.TelegramMailEnabled = true
|
||||
cfg.TelegramBotToken = "test-token"
|
||||
cfg.TelegramPrivateChatID = "123456"
|
||||
cfg.TelegramMailboxIDs = mailboxID
|
||||
})
|
||||
a.enqueueTelegramMailNotification(context.Background(), "scope-denied", storedMessage{MailboxID: "another-mailbox", RecipientAddr: "other@example.com", Subject: "denied"}, nil)
|
||||
a.enqueueTelegramMailNotification(context.Background(), "scope-allowed", storedMessage{MailboxID: mailboxID, RecipientAddr: "admin@lanqin.local", Subject: "allowed"}, nil)
|
||||
var count int
|
||||
if err := a.db.QueryRow(`SELECT COUNT(1) FROM telegram_mail_outbox`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("unexpected scoped queue count=%d err=%v", count, err)
|
||||
}
|
||||
|
||||
raw := []byte("From: sender@example.com\r\nTo: hidden-list@example.net\r\nDelivered-To: admin@lanqin.local\r\nSubject: recipient\r\n\r\nbody")
|
||||
msg, _, err := a.parseMaildirMessage(raw, "admin@lanqin.local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg.RecipientAddr != "admin@lanqin.local" {
|
||||
t.Fatalf("wrong original recipient: %q", msg.RecipientAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramBadRequestFallsBackToPlainText(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Add(1) == 1 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"Bad Request: can't parse entities"}`))
|
||||
return
|
||||
}
|
||||
if _, exists := payload["parse_mode"]; exists {
|
||||
t.Fatal("plain-text fallback still included parse_mode")
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":99}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = server.URL
|
||||
messageID, err := a.deliverTelegramMailMessage(context.Background(), "test-token", "123456", telegramFormattedMessage{HTML: "<b>broken", PlainText: "safe fallback", OTP: "123456"})
|
||||
if err != nil || messageID != 99 || calls.Load() != 2 {
|
||||
t.Fatalf("fallback failed: messageId=%d calls=%d err=%v", messageID, calls.Load(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramMalformedQueueItemDoesNotBlockLaterMail(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":7}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
a.telegramURL = server.URL
|
||||
a.updateConfig(func(cfg *Config) {
|
||||
cfg.TelegramMailEnabled = true
|
||||
cfg.TelegramBotToken = "test-token"
|
||||
cfg.TelegramPrivateChatID = "123456"
|
||||
})
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.Exec(`INSERT INTO telegram_mail_outbox(id,message_id,payload_json,next_attempt_at,created_at,updated_at) VALUES('bad','bad','{',?,?,?),('good','good',?, ?, ?, ?)`, now, now, now, jsonEncode(telegramMailPayload{Subject: "good", From: "sender@example.com", Recipient: "admin@example.com", ReceivedAt: now, Body: "body"}), now, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.processDueTelegramMailNotifications(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var badAttempts int
|
||||
var delivered string
|
||||
if err := a.db.QueryRow(`SELECT attempt_count FROM telegram_mail_outbox WHERE id='bad'`).Scan(&badAttempts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.db.QueryRow(`SELECT COALESCE(delivered_at,'') FROM telegram_mail_outbox WHERE id='good'`).Scan(&delivered); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if badAttempts != telegramMailMaxAttempts || delivered == "" || calls.Load() != 1 {
|
||||
t.Fatalf("malformed queue handling failed: attempts=%d delivered=%q calls=%d", badAttempts, delivered, calls.Load())
|
||||
}
|
||||
}
|
||||
@@ -233,8 +233,16 @@ export type SystemSettings = {
|
||||
externalImapGmailClientSecretSet: boolean
|
||||
externalImapOutlookClientId: string
|
||||
externalImapOutlookClientSecretSet: boolean
|
||||
telegramMailEnabled: boolean
|
||||
telegramBotTokenSet: boolean
|
||||
telegramPrivateChatId: string
|
||||
telegramBodyMode: "summary" | "full"
|
||||
telegramMailboxIds: string[]
|
||||
telegramIncludeUnregistered: boolean
|
||||
}
|
||||
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string }
|
||||
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet" | "telegramBotTokenSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string; telegramBotToken: string }
|
||||
export type TelegramPrivateChat = { chatId: string; displayName: string }
|
||||
export type TelegramPairing = { code: string; botUsername: string; deepLink: string; expiresAt: string }
|
||||
export type PublicDomain = { id: string; name: string }
|
||||
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] }
|
||||
export type LoginPayload = { loginName?: string; email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult } from "./api-types"
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult, TelegramPrivateChat, TelegramPairing } from "./api-types"
|
||||
export * from "./api-types"
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
@@ -197,6 +197,9 @@ export const api = {
|
||||
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
|
||||
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
|
||||
testSmtp: (to: string) => request<{ ok: boolean }>("/api/admin/settings/test-smtp", { method: "POST", body: JSON.stringify({ to }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
createTelegramPairing: (botToken: string) => request<TelegramPairing>("/api/admin/settings/telegram/pair", { method: "POST", body: JSON.stringify({ botToken }) }),
|
||||
discoverTelegramChat: (botToken: string, pairingCode: string) => request<TelegramPrivateChat>("/api/admin/settings/telegram/discover", { method: "POST", body: JSON.stringify({ botToken, pairingCode }) }),
|
||||
testTelegram: (botToken: string, chatId: string) => request<{ ok: boolean }>("/api/admin/settings/telegram/test", { method: "POST", body: JSON.stringify({ botToken, chatId }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
mailTemplates: () => request<ListResponse<MailTemplate>>("/api/admin/mail-templates"),
|
||||
updateMailTemplate: (key: string, payload: { subject: string; bodyText: string; bodyHtml: string }) => request<MailTemplate>(`/api/admin/mail-templates/${encodeURIComponent(key)}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
resetMailTemplate: (key: string) => request<MailTemplate>(`/api/admin/mail-templates/${encodeURIComponent(key)}/reset`, { method: "POST" }),
|
||||
|
||||
+155
-13
@@ -24,10 +24,10 @@ import { SystemVersionDialog } from "@/components/system-version-dialog"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey } from "@/lib/api-types"
|
||||
import type { PermissionKey, TelegramPairing } from "@/lib/api-types"
|
||||
|
||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings"
|
||||
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "externalImap" | "templates" | "security" | "about"
|
||||
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security" | "about"
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
|
||||
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
|
||||
@@ -87,7 +87,7 @@ export function AdminPage() {
|
||||
const users = useQuery({ queryKey: ["admin", "users"], queryFn: api.users, enabled: !!user && (canUsersView || canMailboxesView) })
|
||||
const permissionGroups = useQuery({ queryKey: ["admin", "permission-groups"], queryFn: api.permissionGroups, enabled: !!user && (canPermissionGroupsView || canUsersView) })
|
||||
const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains, enabled: !!user && (canDomainsView || canDNSView || canMailboxesView || canAliasesView || canSettingsView || canTemplatesView) })
|
||||
const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes, enabled: !!user && (canMailboxesView || canMessagesView) })
|
||||
const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes, enabled: !!user && (canMailboxesView || canMessagesView || canSettingsView) })
|
||||
const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases, enabled: !!user && canAliasesView })
|
||||
const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings, enabled: !!user && canSettingsView })
|
||||
const [params, setParams] = useSearchParams()
|
||||
@@ -141,7 +141,7 @@ export function AdminPage() {
|
||||
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||||
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} systemAdmin={user?.role === "admin"} />}
|
||||
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} initialTab={params.get("settingsTab")} />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} mailboxes={mailboxItems} initialTab={params.get("settingsTab")} />}
|
||||
</main>
|
||||
</ScrollArea>
|
||||
)
|
||||
@@ -1029,7 +1029,7 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SystemSettingsSection({ settings, domains, initialTab }: { settings?: SystemSettings; domains: Domain[]; initialTab?: string | null }) {
|
||||
function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { settings?: SystemSettings; domains: Domain[]; mailboxes: MailboxType[]; initialTab?: string | null }) {
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
const qc = useQueryClient()
|
||||
@@ -1042,7 +1042,7 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
||||
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
||||
const requestedTab = initialTab as SettingsTab | undefined
|
||||
const [settingsTab, setSettingsTab] = React.useState<SettingsTab>(() => requestedTab && ["base", "smtp", "storage", "mail", "externalImap", "templates", "security", "about"].includes(requestedTab) ? requestedTab : "base")
|
||||
const [settingsTab, setSettingsTab] = React.useState<SettingsTab>(() => requestedTab && ["base", "smtp", "storage", "mail", "notifications", "externalImap", "templates", "security", "about"].includes(requestedTab) ? requestedTab : "base")
|
||||
const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
|
||||
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
||||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
||||
@@ -1055,6 +1055,13 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
||||
const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState<string[]>([])
|
||||
const [externalImapEnabled, setExternalImapEnabled] = React.useState(false)
|
||||
const [externalImapAllowPrivateHosts, setExternalImapAllowPrivateHosts] = React.useState(false)
|
||||
const [telegramMailEnabled, setTelegramMailEnabled] = React.useState(false)
|
||||
const [telegramBotToken, setTelegramBotToken] = React.useState("")
|
||||
const [telegramPrivateChatId, setTelegramPrivateChatId] = React.useState("")
|
||||
const [telegramBodyMode, setTelegramBodyMode] = React.useState<"summary" | "full">("summary")
|
||||
const [telegramMailboxIds, setTelegramMailboxIds] = React.useState<string[]>([])
|
||||
const [telegramIncludeUnregistered, setTelegramIncludeUnregistered] = React.useState(false)
|
||||
const [telegramPairing, setTelegramPairing] = React.useState<TelegramPairing | null>(null)
|
||||
React.useEffect(() => {
|
||||
if (!settings) return
|
||||
setSmtpRequireTls(settings.smtpRequireTls)
|
||||
@@ -1068,7 +1075,36 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
||||
setUserMailboxDomainIds(settings.userMailboxDomainIds || [])
|
||||
setExternalImapEnabled(settings.externalImapEnabled)
|
||||
setExternalImapAllowPrivateHosts(settings.externalImapAllowPrivateHosts)
|
||||
setTelegramMailEnabled(settings.telegramMailEnabled)
|
||||
setTelegramBotToken("")
|
||||
setTelegramPrivateChatId(settings.telegramPrivateChatId || "")
|
||||
setTelegramBodyMode(settings.telegramBodyMode === "full" ? "full" : "summary")
|
||||
setTelegramMailboxIds(settings.telegramMailboxIds || [])
|
||||
setTelegramIncludeUnregistered(settings.telegramIncludeUnregistered)
|
||||
setTelegramPairing(null)
|
||||
}, [settings])
|
||||
const createTelegramPairing = useMutation({
|
||||
mutationFn: () => api.createTelegramPairing(telegramBotToken),
|
||||
onSuccess: (pairing) => {
|
||||
setTelegramPairing(pairing)
|
||||
toast({ title: "Telegram 绑定码已生成" })
|
||||
},
|
||||
onError: (error) => toast({ title: "生成失败", description: error.message }),
|
||||
})
|
||||
const discoverTelegram = useMutation({
|
||||
mutationFn: () => api.discoverTelegramChat(telegramBotToken, telegramPairing?.code || ""),
|
||||
onSuccess: (chat) => {
|
||||
setTelegramPrivateChatId(chat.chatId)
|
||||
setTelegramPairing(null)
|
||||
toast({ title: "已获取 Telegram 私聊", description: chat.displayName || chat.chatId })
|
||||
},
|
||||
onError: (error) => toast({ title: "获取失败", description: error.message }),
|
||||
})
|
||||
const testTelegram = useMutation({
|
||||
mutationFn: () => api.testTelegram(telegramBotToken, telegramPrivateChatId),
|
||||
onSuccess: () => toast({ title: "Telegram 测试通知已发送" }),
|
||||
onError: (error) => toast({ title: "发送失败", description: error.message }),
|
||||
})
|
||||
const save = useMutation({
|
||||
mutationFn: (form: FormData) => api.updateSystemSettings({
|
||||
publicHostname: fieldValue(form, "publicHostname", settings?.publicHostname || ""),
|
||||
@@ -1101,6 +1137,12 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
||||
externalImapGmailClientSecret: fieldValue(form, "externalImapGmailClientSecret", ""),
|
||||
externalImapOutlookClientId: fieldValue(form, "externalImapOutlookClientId", settings?.externalImapOutlookClientId || ""),
|
||||
externalImapOutlookClientSecret: fieldValue(form, "externalImapOutlookClientSecret", ""),
|
||||
telegramMailEnabled,
|
||||
telegramBotToken,
|
||||
telegramPrivateChatId,
|
||||
telegramBodyMode,
|
||||
telegramMailboxIds,
|
||||
telegramIncludeUnregistered,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["admin", "settings"] })
|
||||
@@ -1142,6 +1184,12 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
||||
settings.externalImapGmailClientSecretSet,
|
||||
settings.externalImapOutlookClientId,
|
||||
settings.externalImapOutlookClientSecretSet,
|
||||
settings.telegramMailEnabled,
|
||||
settings.telegramBotTokenSet,
|
||||
settings.telegramPrivateChatId,
|
||||
settings.telegramBodyMode,
|
||||
(settings.telegramMailboxIds || []).join(","),
|
||||
settings.telegramIncludeUnregistered,
|
||||
].join("|") : "loading"
|
||||
const tabs: { key: typeof settingsTab; label: string }[] = [
|
||||
...(canSettingsView ? [
|
||||
@@ -1149,6 +1197,7 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
||||
{ key: "smtp" as const, label: "SMTP" },
|
||||
{ key: "storage" as const, label: "存储" },
|
||||
{ key: "mail" as const, label: "邮件" },
|
||||
{ key: "notifications" as const, label: "通知" },
|
||||
{ key: "externalImap" as const, label: "外部 IMAP" },
|
||||
] : []),
|
||||
...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []),
|
||||
@@ -1258,6 +1307,85 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
||||
</CardContent>
|
||||
</Card>}
|
||||
|
||||
{settingsTab === "notifications" && <Card>
|
||||
<CardHeader><CardTitle>Telegram 邮件通知</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<SwitchRow label="私聊新邮件通知" checked={telegramMailEnabled} onCheckedChange={setTelegramMailEnabled} />
|
||||
{telegramMailEnabled && (
|
||||
<div className="space-y-5 border-t pt-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Bot Token</Label>
|
||||
<Input type="password" value={telegramBotToken} onChange={(event) => setTelegramBotToken(event.target.value)} placeholder={settings?.telegramBotTokenSet ? "已保存,留空不变" : "123456789:..."} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>私聊 Chat ID</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input inputMode="numeric" value={telegramPrivateChatId} onChange={(event) => setTelegramPrivateChatId(event.target.value)} placeholder="123456789" />
|
||||
<Button type="button" variant="outline" className="shrink-0" disabled={createTelegramPairing.isPending} onClick={() => createTelegramPairing.mutate()}>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />{createTelegramPairing.isPending ? "生成中" : "安全绑定"}
|
||||
</Button>
|
||||
</div>
|
||||
{telegramPairing && (
|
||||
<div className="space-y-3 border-l-2 border-primary/50 py-1 pl-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 font-mono text-sm font-semibold">{telegramPairing.code}</code>
|
||||
<Button type="button" variant="ghost" size="icon" title="复制绑定码" onClick={() => navigator.clipboard.writeText(telegramPairing.code)}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild type="button" variant="outline" size="sm">
|
||||
<a href={telegramPairing.deepLink} target="_blank" rel="noreferrer"><ExternalLink className="mr-2 h-4 w-4" />打开机器人</a>
|
||||
</Button>
|
||||
<Button type="button" size="sm" disabled={discoverTelegram.isPending} onClick={() => discoverTelegram.mutate()}>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />{discoverTelegram.isPending ? "绑定中" : "完成绑定"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 border-t pt-5">
|
||||
<Label>通知邮箱</Label>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{mailboxes.filter((mailbox) => mailbox.status === "active").map((mailbox) => (
|
||||
<label key={mailbox.id} className="flex min-h-11 items-center gap-3 rounded-md border px-3 py-2">
|
||||
<Checkbox
|
||||
checked={telegramMailboxIds.includes(mailbox.id)}
|
||||
onCheckedChange={(checked) => setTelegramMailboxIds((items) => checked === true ? Array.from(new Set([...items, mailbox.id])) : items.filter((id) => id !== mailbox.id))}
|
||||
/>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{mailbox.address}</span>
|
||||
</label>
|
||||
))}
|
||||
<label className="flex min-h-11 items-center gap-3 rounded-md border px-3 py-2">
|
||||
<Checkbox checked={telegramIncludeUnregistered} onCheckedChange={(checked) => setTelegramIncludeUnregistered(checked === true)} />
|
||||
<span className="text-sm font-medium">未知收件</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>邮件正文</Label>
|
||||
<Select value={telegramBodyMode} onValueChange={(value) => setTelegramBodyMode(value === "full" ? "full" : "summary")}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="summary">正文摘要</SelectItem>
|
||||
<SelectItem value="full">尽量显示完整正文</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="button" variant="outline" disabled={testTelegram.isPending || !telegramPrivateChatId} onClick={() => testTelegram.mutate()}>
|
||||
<Mail className="mr-2 h-4 w-4" />{testTelegram.isPending ? "发送中" : "测试通知"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>}
|
||||
|
||||
{settingsTab === "externalImap" && <Card>
|
||||
<CardHeader>
|
||||
<CardTitle>外部 IMAP 接入</CardTitle>
|
||||
@@ -2043,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} />
|
||||
|
||||
@@ -183,6 +183,22 @@ LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET=
|
||||
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID=
|
||||
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET=
|
||||
|
||||
# =========================
|
||||
# Telegram 私聊邮件通知
|
||||
# =========================
|
||||
# 也可在管理后台“系统设置 > 通知”中配置;后台保存的设置优先于环境变量。
|
||||
# 启用后,新收邮件会先写入本地通知队列,再发送到指定 Telegram 私聊;发送失败不会影响收件。
|
||||
LANQIN_TELEGRAM_MAIL_ENABLED=false
|
||||
|
||||
# 从 @BotFather 获取。不要提交真实 Token,也不要与版本发布频道机器人共用。
|
||||
LANQIN_TELEGRAM_BOT_TOKEN=
|
||||
|
||||
# Telegram 私聊 Chat ID。先向机器人发送 /start,再在后台点击“自动获取”。
|
||||
LANQIN_TELEGRAM_PRIVATE_CHAT_ID=
|
||||
|
||||
# summary:正文摘要;full:尽量显示完整正文。两种模式都会限制长度。
|
||||
LANQIN_TELEGRAM_BODY_MODE=summary
|
||||
|
||||
# =========================
|
||||
# 系统
|
||||
# =========================
|
||||
|
||||
+29
-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 的回滚快照;更新或健康检查失败时会自动恢复。手动完整回滚前还会单独备份当前数据库,回滚镜像会保持锁定到下一次更新。
|
||||
|
||||
@@ -148,13 +148,40 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
|
||||
|
||||
配置完成后点击“检测”。
|
||||
|
||||
## Telegram 通知
|
||||
|
||||
### 私聊新邮件通知
|
||||
|
||||
每台邮局可以在“管理后台 -> 系统设置 -> 通知”中独立配置 Telegram 私聊邮件通知:
|
||||
|
||||
1. 使用 `@BotFather` 创建机器人并填写 Bot Token。
|
||||
2. 在 Telegram 中打开该机器人并发送 `/start`。
|
||||
3. 回到后台点击“自动获取”,系统会填写最近一个私聊 Chat ID。
|
||||
4. 选择“正文摘要”或“尽量显示完整正文”,点击“测试通知”。
|
||||
5. 测试成功后开启“私聊新邮件通知”并保存。
|
||||
|
||||
Bot Token 不会通过设置查询接口返回。新邮件通知会先持久化到 SQLite 队列,Telegram 暂时不可用时按退避策略重试;通知失败不会阻塞收件。通知包含发件人、收件邮箱、主题、收件时间、正文和附件名称,不会把附件文件上传到 Telegram。
|
||||
|
||||
手动部署也可以在 `.env` 中设置 `LANQIN_TELEGRAM_MAIL_ENABLED`、`LANQIN_TELEGRAM_BOT_TOKEN`、`LANQIN_TELEGRAM_PRIVATE_CHAT_ID` 和 `LANQIN_TELEGRAM_BODY_MODE`。后台保存的值会持久化到数据库,并在后续启动时优先使用。
|
||||
|
||||
### GitHub Release 版本频道通知
|
||||
|
||||
版本频道通知由 GitHub Release 工作流统一发送,与各台已部署邮局是否更新无关。仓库需要配置以下 GitHub Actions Secrets:
|
||||
|
||||
```text
|
||||
TELEGRAM_RELEASE_BOT_TOKEN
|
||||
TELEGRAM_RELEASE_CHAT_ID
|
||||
```
|
||||
|
||||
`TELEGRAM_RELEASE_CHAT_ID` 可以填写频道用户名(例如 `@YourChannel`)或频道数字 ID。机器人必须先添加为频道管理员,并具有发布消息权限。工作流只在检查、全部 Docker 镜像和 GitHub Release 成功后发送一次;未配置密钥时自动跳过,Telegram 发送失败也不会把版本发布标记为失败。
|
||||
|
||||
## 邮件服务边界
|
||||
|
||||
- Postfix 读取 `/data/lanqin.db` 中的 `domains`、`mailboxes`、`aliases`。
|
||||
- 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"
|
||||
mv "$tmpfile" "$keyfile"
|
||||
if [ -f "$keyfile" ] && cmp -s "$tmpfile" "$keyfile"; then
|
||||
rm -f "$tmpfile"
|
||||
else
|
||||
mv "$tmpfile" "$keyfile"
|
||||
: > "$changed_marker"
|
||||
fi
|
||||
done
|
||||
|
||||
chown_dkim_dir
|
||||
if [ -f "$changed_marker" ]; then
|
||||
rm -f "$changed_marker"
|
||||
reload_rspamd
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "--once" ]; then
|
||||
|
||||
+20
-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 证书。
|
||||
|
||||
安装完成后,请记录终端中显示的访问地址、管理员邮箱和初始密码。初始密码仅在安装时显示;如果以后在后台修改密码,请以新密码为准。
|
||||
|
||||
@@ -59,6 +59,24 @@ DNS 生效通常需要几分钟到数小时。系统只能检测记录,不能
|
||||
|
||||
无人收件不会自动创建邮箱,也不会把邮件分配给普通用户。只有管理员可以在邮箱前台左侧的“未知收件”中查看这些邮件。
|
||||
|
||||
## Telegram 私聊邮件通知
|
||||
|
||||
管理员可以把每封新收邮件的概要发送到自己的 Telegram 私聊:
|
||||
|
||||
1. 使用 `@BotFather` 创建一个邮件通知机器人并取得 Bot Token。
|
||||
2. 进入“管理后台 -> 系统设置 -> 通知”,填写 Bot Token。
|
||||
3. 点击“安全绑定”生成一次性绑定码,再点击“打开机器人”。
|
||||
4. 在机器人会话中发送页面生成的绑定码,然后点击“完成绑定”。
|
||||
5. 勾选需要通知的邮箱;需要接收未注册地址邮件时,另行勾选“未知收件”。
|
||||
6. 选择正文显示方式并点击“测试通知”。
|
||||
7. 测试成功后开启“私聊新邮件通知”,保存设置。
|
||||
|
||||
一次性绑定码有效期为 10 分钟,只会匹配发送了该绑定码的私聊账号。通知会显示主题、发件人、收件邮箱、服务器收件时间、正文和附件摘要;识别到唯一高可信验证码时,会高亮显示并提供“复制验证码”按钮。外部 IMAP 第一次同步导入的历史邮件不会发送通知,后续新邮件才会通知。
|
||||
|
||||
Telegram 连接失败不会影响邮局收件。系统会保留通知任务并自动重试;关闭通知、更换机器人、更换私聊账号或修改通知邮箱范围时,尚未发送的旧任务会被清除。Telegram Bot API 不提供客户端幂等键,因此网络超时发生在 Telegram 已收到请求但服务器未收到响应时,极少数通知可能重复发送。Bot Token 不会在设置页面重新显示;以后修改其他设置时,Token 输入框留空即可保留原值。
|
||||
|
||||
邮件通知机器人只负责部署实例的私聊提醒。项目版本频道通知由 GitHub Release 工作流统一发送,不需要在每台服务器重复配置。
|
||||
|
||||
## SSL 证书与自动续期
|
||||
|
||||
选择“自动配置 Nginx + SSL”后,官方 `acme.sh` 会安装定时检查任务。证书接近到期时会自动续期,续期成功后自动重载 NewSzxcn Email 和 Nginx。
|
||||
|
||||
@@ -39,6 +39,15 @@
|
||||
| NSX-20260804-002 | 2026-08-04 | 已完成 | 前端/UI/响应式布局 | 邮箱选择器展开后宽度变窄 | S3 | v1.2.14 | 随 v1.2.14 发布 |
|
||||
| NSX-20260805-003 | 2026-08-05 | 已完成 | 前端/UI/布局稳定性 | 邮箱页与设置页侧栏宽度/边框位置不一致 | S3 | v1.2.14 | 随 v1.2.14 发布 |
|
||||
| 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 | 待发布 |
|
||||
| NSX-20260807-009 | 2026-08-07 | 待验收 | 部署运维/安装;文档;质量复核 | 一键安装主菜单未按安装状态区分,命令前置条件和状态显示需复核 | S2 | v1.2.19 | 待发布 |
|
||||
| NSX-20260807-010 | 2026-08-07 | 待验收 | 邮件投递;DKIM;部署运维;质量复核 | Rspamd 在域名密钥变化后继续使用旧私钥,导致外发邮件 DKIM 验证失败 | S2 | v1.2.19 | 待发布 |
|
||||
| NSX-20260807-011 | 2026-08-07 | 待验收 | 后端/通知;验证码识别;质量复核 | 邮件地址中的字母数字片段触发验证码候选冲突,导致 Telegram 不显示验证码与复制按钮 | S3 | v1.2.19 | 待发布 |
|
||||
| NSX-20260807-012 | 2026-08-07 | 待验收 | 前端/UI;域名管理;质量复核 | DNS 记录复制按钮把类型、名称和值拼成整行,无法直接粘贴到域名服务商对应字段 | S3 | v1.2.19 | 待发布 |
|
||||
| NSX-20260807-013 | 2026-08-07 | 待验收 | 邮件核心;前端/标签;数据迁移;质量复核 | 邮箱创建后没有常用默认标签,需要手动逐个建立 | S3 | v1.2.19 | 待发布 |
|
||||
|
||||
## NSX-20260804-001
|
||||
|
||||
@@ -198,3 +207,212 @@
|
||||
| --- | --- |
|
||||
| 2026-08-06 | 用户反馈“全部邮箱”右侧存在空白块并要求修改。 |
|
||||
| 2026-08-06 | 已移除永久占位列,改为具体邮箱状态覆盖显示复制按钮,状态流转为待验收。 |
|
||||
|
||||
## NSX-20260806-005
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 编号 | NSX-20260806-005 |
|
||||
| 日期 | 2026-08-06 |
|
||||
| 状态 | 已完成 |
|
||||
| 模块 | 后端/通知;前端/设置;部署运维/CI;质量复核 |
|
||||
| 需求 | 后台配置 Telegram 机器人,将新邮件排版后发送到管理员私聊;GitHub Release 成功后统一向版本频道发送一次更新通知。 |
|
||||
| 边界 | 邮件通知由各部署实例独立配置;版本通知只由 GitHub Release 工作流发送,不依赖已部署邮局是否更新。 |
|
||||
| 实现 | 新增 Telegram 通知设置、私聊 Chat ID 自动获取、测试发送、正文模式、持久化通知队列、去重与失败重试;Release 工作流在全部镜像和 Release 成功后发送频道消息。 |
|
||||
| 安全 | Bot Token 不通过设置查询接口返回,不写入仓库;频道密钥使用 GitHub Actions Secrets;Telegram 失败不阻塞收件或版本发布。 |
|
||||
| 兼容性 | 数据库只新增表和设置项;默认关闭;现有配置、邮件、证书和在线更新方式不变。 |
|
||||
| 目标版本 | v1.2.16 |
|
||||
| 测试结果 | Go 全量测试和 vet、前端 check/build、安装脚本语法/ShellCheck/回归、工作流 YAML、密钥扫描、桌面和移动端页面检查均通过。 |
|
||||
| 发布状态 | 随 v1.2.16 发布。 |
|
||||
|
||||
### 历史
|
||||
|
||||
| 时间 | 记录 |
|
||||
| --- | --- |
|
||||
| 2026-08-06 | 用户确认后台只保留机器人私聊邮件通知,版本频道通知交由 GitHub Release 工作流统一发送。 |
|
||||
| 2026-08-06 | 已配置仓库频道通知密钥并完成频道实发测试;真实 Bot Token 未写入源码。 |
|
||||
| 2026-08-06 | 完成密钥隐藏、网络错误脱敏、通知失败隔离和 Release 重跑去重复核,状态流转为已完成。 |
|
||||
| 2026-08-06 | v1.2.16 检查、六个 Docker 镜像、GitHub Release 和 Telegram 频道通知全部成功。 |
|
||||
|
||||
## NSX-20260806-006
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 编号 | NSX-20260806-006 |
|
||||
| 日期 | 2026-08-06 |
|
||||
| 状态 | 已完成 |
|
||||
| 模块 | 后端/通知;邮件核心;前端/设置;质量复核 |
|
||||
| 现象 | 原自动获取 Chat ID 可能匹配错误私聊;通知范围默认覆盖全部本地邮箱;关闭后重开可能补发旧任务;收件邮箱、正文编码、长度预算、错误重试和验证码复制不完整。 |
|
||||
| 根因 | 通知功能首版只覆盖基础发送,没有建立安全配对、显式邮箱范围、发送租约、Telegram 错误分类和统一的 MIME/正文规范化流程。 |
|
||||
| 实现 | 使用 10 分钟一次性绑定码;通知范围改为显式邮箱和未知收件选择;目的地或范围变化时事务清理旧任务;增加发送租约、Telegram 消息编号、送达后正文清除、400 纯文本降级、429 `retry_after`、401/403 停止重试;新增验证码评分与 `copy_text` 按钮、全消息长度预算、附件清理、GBK 等正文字符集解码和伪 HTML 清理。 |
|
||||
| 收件链路 | 修正实际收件地址解析;本地互发、未知收件和后续外部 IMAP 新邮件统一通知;首次外部 IMAP 历史导入不通知;收信规则先执行,垃圾邮件和已删除邮件不通知。 |
|
||||
| 兼容性 | 数据库仅增加可空闲迁移列和设置项;现有 Bot Token 保留且不回传;升级后管理员邮箱自动成为默认通知范围,已开启通知的实例继续包含未知收件。 |
|
||||
| 目标版本 | v1.2.17 |
|
||||
| 测试结果 | Telegram 专项测试、Go 全量测试、`go vet`、竞态检测、前端 shadcn 检查和生产构建通过;桌面端与移动端页面视觉验收通过。 |
|
||||
| 发布状态 | 随 v1.2.17 发布。 |
|
||||
|
||||
### 历史
|
||||
|
||||
| 时间 | 记录 |
|
||||
| --- | --- |
|
||||
| 2026-08-06 | 完成通知全链路复核,确认安全绑定、旧队列、收件地址、来源覆盖、长度预算、错误分类和 MIME 处理问题。 |
|
||||
| 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 检查均通过;本地双邮箱页面实测默认选择登录邮箱,下拉项完整,切换邮箱后收件人、主题、正文均保留,数据库确认草稿保存到新选择的邮箱;桌面截图确认写信窗口无溢出和遮挡。 |
|
||||
| 发布状态 | 待发布。 |
|
||||
|
||||
### 历史
|
||||
|
||||
| 时间 | 记录 |
|
||||
| --- | --- |
|
||||
| 2026-08-07 | 用户反馈全部邮箱写信的默认发件箱、邮箱选择和窗口宽度问题,进入处理中。 |
|
||||
| 2026-08-07 | 完成写信邮箱选择、默认项、回复/转发邮箱、草稿归属、窗口宽度和安装管理员邮箱交互修改;自动化与页面验收通过,状态流转为待验收。 |
|
||||
|
||||
## NSX-20260807-009
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 编号 | NSX-20260807-009 |
|
||||
| 日期 | 2026-08-07 |
|
||||
| 状态 | 待验收 |
|
||||
| 模块 | 部署运维/安装;文档;质量复核 |
|
||||
| 现象 | 空白服务器仍显示更新、回滚、重启等不可用操作;已安装菜单没有运行状态和实际版本;部分直接命令缺少统一安装前置检查;安装残缺时没有明确修复入口。 |
|
||||
| 根因 | 主菜单只根据 `.env` 切换默认选项,所有状态共用一套菜单;运行状态、镜像版本和安装完整性没有独立判断;部分前置检查散落在菜单分发层。 |
|
||||
| 实现 | 未安装服务器只显示一键安装和退出;已安装服务器按安装维护、服务管理、证书恢复、账号帮助、危险操作分组,动态显示运行状态、镜像版本和访问地址;安装残缺时默认进入修复;新增 `repair` 命令;服务命令统一校验 `.env` 与 Compose 文件;日志、状态、回滚和卸载的 Docker 检查收回各自函数;修复成功文案和缺失版本标签显示已纠正;README 加入两套主菜单示例。 |
|
||||
| 兼容性 | 保持既有菜单编号 `1–12` 和 `ns` 快捷命令;现有配置、数据库、邮件、证书和更新流程不变。 |
|
||||
| 测试结果 | Bash 语法检查、ShellCheck、安装脚本全量测试、菜单渲染/范围/分发/状态/版本/残缺安装/前置条件测试、前端 check/build、Go 全量测试和 go vet 均通过。 |
|
||||
| 目标版本 | v1.2.19 |
|
||||
| 发布状态 | 待发布。 |
|
||||
|
||||
### 历史
|
||||
|
||||
| 时间 | 记录 |
|
||||
| --- | --- |
|
||||
| 2026-08-07 | 用户确认按安装状态拆分主菜单,并要求写入仓库介绍、复核全部命令逻辑。 |
|
||||
| 2026-08-07 | 完成动态菜单、命令前置条件、残缺安装修复入口、README 和自动化回归,状态流转为待验收。 |
|
||||
|
||||
## NSX-20260807-010
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 编号 | NSX-20260807-010 |
|
||||
| 日期 | 2026-08-07 |
|
||||
| 状态 | 待验收 |
|
||||
| 模块 | 邮件投递;DKIM;部署运维;质量复核 |
|
||||
| 现象 | Gmail 显示 SPF 和 DMARC 通过,但 NewSzxcn 发出的邮件 DKIM 验证失败;同内容的 NodeSeek 对照邮件三项认证均通过。 |
|
||||
| 诊断 | NewSzxcn 邮件的 relaxed 正文哈希与 Gmail 收到的 `bh` 精确一致,排除正文传输修改;邮件签名无法由当前 DNS 公钥验证,说明发信时使用了不同私钥。`key not secure` 仅表示 DNSSEC 未验证,TXT 分段也属于正常 DNS 表示。 |
|
||||
| 根因 | DKIM 同步任务会覆盖容器内密钥文件,但 Rspamd 已载入的签名密钥不会随文件替换自动更新,域名重建或密钥变化后可能继续使用内存中的旧私钥。 |
|
||||
| 实现 | DKIM 同步改为先比较密钥内容;相同密钥不再重复替换,密钥新增或变化后立即重新载入 Rspamd;后台 DNS 检查从“仅判断 DKIM 记录存在”升级为核对实际 `p=` 公钥,明确区分缺失与公钥不一致;新增密钥同步和 DNS 公钥匹配回归,并纳入 CI 与发布检查。 |
|
||||
| 兼容性 | 不轮换现有 DKIM 密钥、不修改 DNS;已有数据库、邮件和域名配置保持不变。 |
|
||||
| 测试结果 | DKIM 同步专项测试、DNS 公钥匹配测试、Go 全量测试与 vet、安装脚本回归均通过;线上仍需升级后重新发信,确认 Gmail 原始邮件显示 `dkim=pass`。 |
|
||||
| 目标版本 | v1.2.19 |
|
||||
| 发布状态 | 待发布。 |
|
||||
|
||||
### 历史
|
||||
|
||||
| 时间 | 记录 |
|
||||
| --- | --- |
|
||||
| 2026-08-07 | 用户提供 Gmail 原始邮件和 NodeSeek 对照邮件,完成正文哈希、签名公钥和认证结果比对。 |
|
||||
| 2026-08-07 | 完成 DKIM 密钥热更新修复和专项回归,等待服务器实发验收。 |
|
||||
|
||||
## NSX-20260807-011
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 编号 | NSX-20260807-011 |
|
||||
| 日期 | 2026-08-07 |
|
||||
| 状态 | 待验收 |
|
||||
| 模块 | 后端/通知;验证码识别;质量复核 |
|
||||
| 现象 | 爱奇艺邮件的主题和正文均包含验证码 `825534`,Telegram 通知却没有独立验证码区域和“复制验证码”按钮。 |
|
||||
| 根因 | 正文开头的收件地址 `iqiyi02@newszxcn.com` 被拆成 `iqiyi02`、`newszxcn` 两个候选;它们与主题中的“验证码”距离较近,触发多候选歧义保护后返回空结果。 |
|
||||
| 实现 | 验证码评分前排除邮箱地址和 HTTP/HTTPS 链接范围内的字母数字片段,保留真实正文与主题候选;新增爱奇艺原始场景回归,同时检查独立验证码区域和复制按钮。 |
|
||||
| 测试结果 | 爱奇艺原始场景、Gate 验证码与链接、Telegram 消息预算专项测试及 Go 全量测试均通过。 |
|
||||
| 目标版本 | v1.2.19 |
|
||||
| 发布状态 | 待发布。 |
|
||||
|
||||
### 历史
|
||||
|
||||
| 时间 | 记录 |
|
||||
| --- | --- |
|
||||
| 2026-08-07 | 用户提供 Telegram 实际通知截图,完成邮箱地址候选冲突复现。 |
|
||||
| 2026-08-07 | 修复邮箱和链接候选排除逻辑,加入爱奇艺验证码专项回归。 |
|
||||
|
||||
## NSX-20260807-012
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 编号 | NSX-20260807-012 |
|
||||
| 日期 | 2026-08-07 |
|
||||
| 状态 | 待验收 |
|
||||
| 模块 | 前端/UI;域名管理;质量复核 |
|
||||
| 现象 | DNS 记录顶部“复制”会得到 `TXT newszxcn.com v=spf1 mx -all`,不能直接粘贴到域名服务商的主机记录和记录值输入框。 |
|
||||
| 实现 | 删除整行复制;每条记录明确展示记录类型、主机记录、记录值和 TTL;主机记录与记录值分别提供图标复制和对应成功提示,TTL 仅展示。 |
|
||||
| 测试结果 | 前端 check/build 通过;本地页面确认 SPF 主机记录和记录值分别复制为 `lanqin.local` 与 `v=spf1 mx -all`,DKIM 长记录正常换行且弹窗没有横向溢出。 |
|
||||
| 目标版本 | v1.2.19 |
|
||||
| 发布状态 | 待发布。 |
|
||||
|
||||
### 历史
|
||||
|
||||
| 时间 | 记录 |
|
||||
| --- | --- |
|
||||
| 2026-08-07 | 用户提供 DNS 弹窗截图并指出整行复制无法直接用于 DNS 面板。 |
|
||||
| 2026-08-07 | 完成主机记录和记录值分离复制。 |
|
||||
|
||||
## NSX-20260807-013
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 编号 | NSX-20260807-013 |
|
||||
| 日期 | 2026-08-07 |
|
||||
| 状态 | 待验收 |
|
||||
| 模块 | 邮件核心;前端/标签;数据迁移;质量复核 |
|
||||
| 需求 | 邮箱默认增加个人、家人、朋友、工作、重要五个常用标签,并按名称使用容易辨认的颜色。 |
|
||||
| 实现 | 新邮箱创建时由后端事务生成五个标签;已有邮箱升级时一次性补齐;固定顺序为个人、家人、朋友、工作、重要,颜色依次为绿色、玫红、青色、蓝色、橙色;用户后续删除标签不会在重启时恢复;“全部邮箱”按名称合并同名标签并汇总数量,点击或导出时覆盖用户名下所有邮箱。 |
|
||||
| 兼容性 | 使用 `INSERT OR IGNORE` 保留已有同名标签及其颜色和邮件关联;不修改用户自建标签。 |
|
||||
| 测试结果 | 新邮箱创建、旧邮箱补齐、删除后不重建、多邮箱同名汇总与跨邮箱筛选专项测试,以及 Go 全量测试和 vet 均通过;页面确认五个标签各显示一次、顺序正确,颜色与侧栏宽度正确且无越界。 |
|
||||
| 目标版本 | v1.2.19 |
|
||||
| 发布状态 | 待发布。 |
|
||||
|
||||
### 历史
|
||||
|
||||
| 时间 | 记录 |
|
||||
| --- | --- |
|
||||
| 2026-08-07 | 用户提供标签侧栏参考并指定五个默认标签。 |
|
||||
| 2026-08-07 | 完成新邮箱默认生成、已有邮箱一次性补齐、固定排序和颜色回归;页面复核时发现全部邮箱重复显示,继续完成同名汇总、跨邮箱筛选与导出回归。 |
|
||||
|
||||
+180
-76
@@ -29,12 +29,13 @@ NewSzxcn Email 管理命令
|
||||
menu 显示安装与运维菜单
|
||||
install 首次安装;已有安装会先完整备份再重新安装
|
||||
update 备份数据库并更新到最新版
|
||||
repair 检查并修复现有安装
|
||||
status 查看容器与健康状态
|
||||
logs 持续查看运行日志
|
||||
restart 重启服务并重载 Nginx
|
||||
certificate 申请或续期自动模式的 SSL 证书
|
||||
rollback 回滚到上次更新前版本
|
||||
guide 显示并更新 NewSzxcn 邮箱指南
|
||||
guide 显示并更新邮箱后台配置指南
|
||||
credentials 查看管理员登录信息和记录密码
|
||||
reset-password 重置管理员统一登录密码(含名下邮箱)
|
||||
reset-2fa 应急关闭唯一管理员双因素认证
|
||||
@@ -202,6 +203,18 @@ env_value() {
|
||||
sed -n "s/^${key}=//p" "${INSTALL_DIR}/.env" | tail -n 1
|
||||
}
|
||||
|
||||
installation_configured() {
|
||||
[[ -f "${INSTALL_DIR}/.env" ]]
|
||||
}
|
||||
|
||||
installation_complete() {
|
||||
installation_configured && [[ -f "${INSTALL_DIR}/docker-compose.yml" ]]
|
||||
}
|
||||
|
||||
require_installation() {
|
||||
installation_complete || fail "尚未完成安装,请先运行 newszxcn-email install;如果配置残缺,请运行 newszxcn-email repair。"
|
||||
}
|
||||
|
||||
prompt_value() {
|
||||
local variable="$1" prompt="$2" default_value="$3" secret="${4:-false}"
|
||||
local value="${!variable:-}"
|
||||
@@ -318,8 +331,8 @@ prompt_mail_domain() {
|
||||
suggestion="$(suggest_mail_domain "${hostname}")"
|
||||
value="${LANQIN_MAIL_DOMAIN:-}"
|
||||
if [[ -z "${value}" ]] && has_tty; then
|
||||
prompt_text "[提示] 邮件服务器域名是 ${hostname};邮箱地址域名可以使用 ${suggestion},请确认。\n"
|
||||
read -r -p "邮箱地址域名 [${suggestion}]: " value </dev/tty
|
||||
prompt_text "[检测] 邮件服务器域名:${hostname}\n[检测] 邮箱地址域名:@${suggestion}\n"
|
||||
read -r -p "邮箱地址域名 [${suggestion}](直接回车确认): " value </dev/tty
|
||||
fi
|
||||
value="${value:-${suggestion}}"
|
||||
if [[ -z "${LANQIN_MAIL_DOMAIN:-}" && -z "${admin_email}" ]] && ! has_tty; then
|
||||
@@ -342,10 +355,10 @@ prompt_admin_email() {
|
||||
return
|
||||
fi
|
||||
if has_tty; then
|
||||
prompt_text "\n创建管理员邮箱 [1]:\n1. 默认 admin,自动创建 admin@${mail_domain}\n2. 自定义前缀\n"
|
||||
prompt_text "\n检测到邮箱地址域名:@${mail_domain}\n创建管理员邮箱 [1]:\n1. 使用默认前缀 admin\n2. 自定义管理员邮箱前缀\n"
|
||||
choice="$(prompt_choice LANQIN_ADMIN_EMAIL_MODE "请选择 [1]: " "1" "2")"
|
||||
if [[ "${choice}" == "2" ]]; then
|
||||
prefix="$(prompt_value LANQIN_ADMIN_PREFIX "管理员邮箱前缀" "admin")"
|
||||
prefix="$(prompt_value LANQIN_ADMIN_PREFIX "管理员邮箱账号前缀" "admin")"
|
||||
else
|
||||
prefix="admin"
|
||||
fi
|
||||
@@ -353,7 +366,9 @@ prompt_admin_email() {
|
||||
prefix="${LANQIN_ADMIN_PREFIX:-admin}"
|
||||
fi
|
||||
valid_mail_local_part "${prefix}" || fail "管理员邮箱前缀格式不正确。"
|
||||
printf '%s@%s' "$(lowercase "${prefix}")" "${mail_domain}"
|
||||
email="$(lowercase "${prefix}")@${mail_domain}"
|
||||
prompt_text "[提示] 将创建管理员邮箱:${email}\n"
|
||||
printf '%s' "${email}"
|
||||
}
|
||||
|
||||
ensure_admin_email_config() {
|
||||
@@ -974,42 +989,67 @@ restore_update_snapshot() {
|
||||
}
|
||||
|
||||
do_repair_install() {
|
||||
installation_configured || fail "尚未安装,无法执行修复。"
|
||||
local snapshot_created="false"
|
||||
ensure_docker
|
||||
create_update_snapshot || fail "修复前备份失败,未修改现有安装。"
|
||||
if [[ -f "${INSTALL_DIR}/docker-compose.yml" ]]; then
|
||||
create_update_snapshot || fail "修复前备份失败,未修改现有安装。"
|
||||
snapshot_created="true"
|
||||
else
|
||||
warn "安装缺少 docker-compose.yml,将保留现有配置和数据并重新生成运行文件。"
|
||||
fi
|
||||
stage_assets
|
||||
clear_runtime_image_pin
|
||||
if ! apply_staged_assets || ! ensure_update_token || ! ensure_admin_email_config || ! configure_runtime_bindings; then
|
||||
restore_update_snapshot "" false || true
|
||||
fail "修复准备失败,已恢复原安装。"
|
||||
if [[ "${snapshot_created}" == "true" ]]; then
|
||||
restore_update_snapshot "" false || true
|
||||
fail "修复准备失败,已恢复原安装。"
|
||||
fi
|
||||
fail "修复准备失败,原配置和数据未删除。"
|
||||
fi
|
||||
if ! (configure_firewall && prepare_directories); then
|
||||
restore_update_snapshot "" false || true
|
||||
fail "修复环境准备失败,已恢复原安装。"
|
||||
if [[ "${snapshot_created}" == "true" ]]; then
|
||||
restore_update_snapshot "" false || true
|
||||
fail "修复环境准备失败,已恢复原安装。"
|
||||
fi
|
||||
fail "修复环境准备失败,原配置和数据未删除。"
|
||||
fi
|
||||
log "正在拉取并修复 NewSzxcn Email 服务..."
|
||||
if ! compose pull; then
|
||||
restore_update_snapshot "" false || true
|
||||
fail "修复镜像拉取失败,已恢复原安装。"
|
||||
if [[ "${snapshot_created}" == "true" ]]; then
|
||||
restore_update_snapshot "" false || true
|
||||
fail "修复镜像拉取失败,已恢复原安装。"
|
||||
fi
|
||||
fail "修复镜像拉取失败,原配置和数据未删除。"
|
||||
fi
|
||||
log "正在启动服务..."
|
||||
if ! compose up -d --remove-orphans; then
|
||||
warn "修复后容器启动失败,正在自动回滚。"
|
||||
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
|
||||
fail "修复失败,已恢复到修复前版本。"
|
||||
if [[ "${snapshot_created}" == "true" ]]; then
|
||||
warn "修复后容器启动失败,正在自动回滚。"
|
||||
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
|
||||
fail "修复失败,已恢复到修复前版本。"
|
||||
fi
|
||||
fail "修复后容器启动失败,请查看实时日志;原配置和数据未删除。"
|
||||
fi
|
||||
if ! wait_for_health 90; then
|
||||
warn "修复后健康检查失败,正在自动回滚。"
|
||||
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
|
||||
fail "修复失败,已恢复到修复前版本。"
|
||||
if [[ "${snapshot_created}" == "true" ]]; then
|
||||
warn "修复后健康检查失败,正在自动回滚。"
|
||||
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
|
||||
fail "修复失败,已恢复到修复前版本。"
|
||||
fi
|
||||
fail "修复后健康检查失败,请查看实时日志;原配置和数据未删除。"
|
||||
fi
|
||||
if ! (configure_web_mode); then
|
||||
restore_update_snapshot || fail "Web 配置失败,且自动恢复未完成,请使用回滚快照手动恢复。"
|
||||
fail "Web 配置失败,已恢复到修复前版本。"
|
||||
if [[ "${snapshot_created}" == "true" ]]; then
|
||||
restore_update_snapshot || fail "Web 配置失败,且自动恢复未完成,请使用回滚快照手动恢复。"
|
||||
fail "Web 配置失败,已恢复到修复前版本。"
|
||||
fi
|
||||
fail "Web 配置修复失败,原配置和数据未删除。"
|
||||
fi
|
||||
generate_guide >/dev/null || warn "安装成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
|
||||
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
|
||||
generate_guide >/dev/null || warn "修复成功,但邮箱后台配置指南生成失败,可稍后执行 newszxcn-email guide 重试。"
|
||||
success "修复完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
|
||||
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
|
||||
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱指南。"
|
||||
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱后台配置指南。"
|
||||
}
|
||||
|
||||
do_install() {
|
||||
@@ -1031,14 +1071,14 @@ do_install() {
|
||||
compose up -d --remove-orphans
|
||||
wait_for_health 90 || fail "服务未能通过健康检查,请执行 newszxcn-email logs 查看日志。"
|
||||
configure_web_mode
|
||||
generate_guide >/dev/null || warn "安装成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
|
||||
generate_guide >/dev/null || warn "安装成功,但邮箱后台配置指南生成失败,可稍后执行 newszxcn-email guide 重试。"
|
||||
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
|
||||
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
|
||||
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱指南。"
|
||||
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱后台配置指南。"
|
||||
}
|
||||
|
||||
do_update() {
|
||||
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装,请先执行 install。"
|
||||
require_installation
|
||||
ensure_docker
|
||||
create_update_snapshot || fail "更新前备份失败,未修改现有安装。"
|
||||
stage_assets
|
||||
@@ -1063,11 +1103,12 @@ do_update() {
|
||||
fail "更新失败,已恢复到更新前版本。"
|
||||
fi
|
||||
ensure_cli_alias
|
||||
generate_guide >/dev/null || warn "更新成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
|
||||
generate_guide >/dev/null || warn "更新成功,但邮箱后台配置指南生成失败,可稍后执行 newszxcn-email guide 重试。"
|
||||
success "系统已更新,配置、邮件、证书和数据库均已保留。"
|
||||
}
|
||||
|
||||
do_rollback() {
|
||||
require_installation
|
||||
[[ -f "${ROLLBACK_POINTER}" ]] || fail "没有可用的完整回滚快照。"
|
||||
local confirm="${LANQIN_ROLLBACK_CONFIRM:-}" image timestamp emergency_backup
|
||||
if [[ -z "${confirm}" ]] && has_tty; then
|
||||
@@ -1085,7 +1126,7 @@ do_rollback() {
|
||||
}
|
||||
|
||||
reload_services() {
|
||||
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || return 0
|
||||
require_installation
|
||||
ensure_docker
|
||||
compose restart lanqin-email >/dev/null
|
||||
if [[ -f "${NGINX_CONFIG}" ]]; then
|
||||
@@ -1100,14 +1141,14 @@ do_restart() {
|
||||
}
|
||||
|
||||
do_certificate() {
|
||||
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
|
||||
require_installation
|
||||
[[ "$(env_value LANQIN_INSTALL_WEB_MODE || true)" == "1" ]] || fail "只有自动 Nginx + SSL 模式可使用此命令。"
|
||||
ensure_nginx
|
||||
write_nginx_http_config
|
||||
install_certificate
|
||||
write_nginx_https_config
|
||||
reload_services
|
||||
generate_guide >/dev/null || warn "证书已应用,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
|
||||
generate_guide >/dev/null || warn "证书已应用,但邮箱后台配置指南生成失败,可稍后执行 newszxcn-email guide 重试。"
|
||||
success "SSL 证书已安装并应用。"
|
||||
}
|
||||
|
||||
@@ -1140,7 +1181,7 @@ generate_guide() {
|
||||
tmp="$(mktemp)"
|
||||
cat > "${tmp}" <<EOF
|
||||
==================================================
|
||||
NewSzxcn 邮箱指南
|
||||
NewSzxcn 邮箱后台配置指南
|
||||
==================================================
|
||||
|
||||
【安装信息】
|
||||
@@ -1199,7 +1240,7 @@ EOF
|
||||
}
|
||||
|
||||
do_guide() {
|
||||
generate_guide || fail "尚未安装,无法生成邮箱指南。"
|
||||
generate_guide || fail "尚未安装,无法生成邮箱后台配置指南。"
|
||||
cat "${GUIDE_FILE}"
|
||||
success "指南已更新并保存到 ${GUIDE_FILE}。"
|
||||
}
|
||||
@@ -1233,7 +1274,7 @@ generate_admin_password_hash() {
|
||||
}
|
||||
|
||||
do_reset_admin_password() {
|
||||
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
|
||||
require_installation
|
||||
local admin_email password user_id hash image timestamp backup env_backup result user_changes mailbox_changes
|
||||
ensure_docker
|
||||
ensure_admin_email_config
|
||||
@@ -1277,7 +1318,7 @@ do_reset_admin_password() {
|
||||
}
|
||||
|
||||
do_reset_admin_two_factor() {
|
||||
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
|
||||
require_installation
|
||||
local admin_email user_id image timestamp backup result user_changes recovery_changes challenge_changes
|
||||
ensure_docker
|
||||
ensure_admin_email_config
|
||||
@@ -1306,7 +1347,8 @@ do_reset_admin_two_factor() {
|
||||
}
|
||||
|
||||
do_status() {
|
||||
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
|
||||
require_installation
|
||||
ensure_docker
|
||||
compose ps
|
||||
if wait_for_health 1; then
|
||||
success "Web 与 API 健康检查正常。"
|
||||
@@ -1315,8 +1357,15 @@ do_status() {
|
||||
fi
|
||||
}
|
||||
|
||||
do_logs() {
|
||||
require_installation
|
||||
ensure_docker
|
||||
compose logs -f --tail=200 lanqin-email updater
|
||||
}
|
||||
|
||||
do_uninstall() {
|
||||
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
|
||||
require_installation
|
||||
ensure_docker
|
||||
local confirm="${LANQIN_UNINSTALL_CONFIRM:-}" remove_renewal="${LANQIN_REMOVE_CERT_RENEWAL:-}" hostname
|
||||
if [[ -z "${confirm}" ]] && has_tty; then
|
||||
read -r -p "确认停止并卸载服务吗?邮件和配置将保留。[y/N]: " confirm </dev/tty
|
||||
@@ -1415,58 +1464,112 @@ do_backup_reinstall() {
|
||||
fail "重新安装失败,旧安装已自动恢复。失败的新安装保存在 ${failed_dir}。"
|
||||
}
|
||||
|
||||
do_menu() {
|
||||
local installed="false" default_choice="1" public_url="" choice
|
||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
||||
installed="true"
|
||||
default_choice="2"
|
||||
public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)"
|
||||
menu_service_status() {
|
||||
local container_id
|
||||
if ! installation_complete; then
|
||||
printf '安装不完整'
|
||||
return
|
||||
fi
|
||||
|
||||
prompt_text '\n==================================================\n'
|
||||
prompt_text ' NewSzxcn Email 一键安装与管理\n'
|
||||
prompt_text '==================================================\n'
|
||||
if [[ "${installed}" == "true" ]]; then
|
||||
prompt_text " 状态:已安装\n 路径:${INSTALL_DIR}\n"
|
||||
[[ -n "${public_url}" ]] && prompt_text " 地址:${public_url}\n"
|
||||
if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then
|
||||
printf '状态未知'
|
||||
return
|
||||
fi
|
||||
container_id="$(compose ps -q lanqin-email 2>/dev/null | head -n 1 || true)"
|
||||
if [[ -n "${container_id}" ]] && [[ "$(docker inspect --format '{{.State.Running}}' "${container_id}" 2>/dev/null || true)" == "true" ]]; then
|
||||
printf '运行中'
|
||||
else
|
||||
prompt_text ' 状态:未安装\n'
|
||||
printf '已停止'
|
||||
fi
|
||||
prompt_text '--------------------------------------------------\n'
|
||||
prompt_text ' 1. 安装 / 重新安装(完整备份,失败自动恢复)\n'
|
||||
prompt_text ' 2. 更新系统(数据库备份,失败自动回滚)\n'
|
||||
prompt_text ' 3. 检查并修复现有安装\n'
|
||||
prompt_text ' 4. 查看运行状态\n'
|
||||
prompt_text ' 5. 重启服务\n'
|
||||
prompt_text ' 6. 查看实时日志\n'
|
||||
prompt_text ' 7. 申请、检查或续期 SSL 证书\n'
|
||||
prompt_text ' 8. 回滚到上次更新前版本\n'
|
||||
prompt_text ' 9. NewSzxcn 邮箱指南\n'
|
||||
prompt_text ' 10. 查看管理员登录信息\n'
|
||||
prompt_text ' 11. 重置管理员统一登录密码\n'
|
||||
prompt_text ' 12. 卸载服务(保留数据)\n'
|
||||
prompt_text ' 0. 退出\n'
|
||||
}
|
||||
|
||||
menu_installed_version() {
|
||||
local image version
|
||||
if ! installation_complete || ! command -v docker >/dev/null 2>&1; then
|
||||
printf '未知'
|
||||
return
|
||||
fi
|
||||
image="$(current_image_id 2>/dev/null || true)"
|
||||
if [[ -n "${image}" ]]; then
|
||||
version="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "${image}" 2>/dev/null || true)"
|
||||
fi
|
||||
[[ "${version:-}" == "<no value>" ]] && version=""
|
||||
printf '%s' "${version:-未知}"
|
||||
}
|
||||
|
||||
render_uninstalled_menu() {
|
||||
prompt_text '\n==================================================\n'
|
||||
prompt_text ' NewSzxcn Email 管理面板\n'
|
||||
prompt_text '==================================================\n'
|
||||
prompt_text '状态:尚未安装\n'
|
||||
prompt_text '--------------------------------------------------\n'
|
||||
prompt_text '1. 一键安装 NewSzxcn Email\n'
|
||||
prompt_text '0. 退出\n'
|
||||
prompt_text '==================================================\n'
|
||||
}
|
||||
|
||||
choice="$(prompt_menu_choice "${default_choice}" "12")"
|
||||
if [[ "${choice}" != "0" && "${choice}" != "1" && "${installed}" != "true" ]]; then
|
||||
fail "尚未安装,请先选择 1。"
|
||||
render_installed_menu() {
|
||||
local status="$1" version="$2" public_url="$3"
|
||||
prompt_text '\n==================================================\n'
|
||||
prompt_text ' NewSzxcn Email 管理面板\n'
|
||||
prompt_text '==================================================\n'
|
||||
prompt_text "状态:${status}\n"
|
||||
prompt_text "版本:${version}\n"
|
||||
prompt_text "地址:${public_url:-未配置}\n"
|
||||
prompt_text '--------------------------------------------------\n'
|
||||
prompt_text '安装与维护\n'
|
||||
prompt_text '1. 重新安装(完整备份,失败自动恢复)\n'
|
||||
prompt_text '2. 更新系统(自动备份,失败自动回滚)\n'
|
||||
prompt_text '3. 检查并修复现有安装\n\n'
|
||||
prompt_text '服务管理\n'
|
||||
prompt_text '4. 查看运行状态\n'
|
||||
prompt_text '5. 重启服务\n'
|
||||
prompt_text '6. 查看实时日志\n\n'
|
||||
prompt_text '证书与恢复\n'
|
||||
prompt_text '7. 管理 SSL 证书\n'
|
||||
prompt_text '8. 回滚到上次更新前版本\n\n'
|
||||
prompt_text '账号与帮助\n'
|
||||
prompt_text '9. 邮箱后台配置指南\n'
|
||||
prompt_text '10. 查看管理员登录信息\n'
|
||||
prompt_text '11. 重置管理员登录密码\n\n'
|
||||
prompt_text '危险操作\n'
|
||||
prompt_text '12. 卸载服务(保留数据)\n\n'
|
||||
prompt_text '0. 退出\n'
|
||||
prompt_text '==================================================\n'
|
||||
}
|
||||
|
||||
do_menu() {
|
||||
local default_choice="2" public_url="" choice status version
|
||||
if ! installation_configured; then
|
||||
render_uninstalled_menu
|
||||
choice="$(prompt_menu_choice "1" "1")" || return 1
|
||||
case "${choice}" in
|
||||
0) success "已退出,未作任何修改。" ;;
|
||||
1) do_install ;;
|
||||
esac
|
||||
return
|
||||
fi
|
||||
|
||||
public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)"
|
||||
status="$(menu_service_status)"
|
||||
version="$(menu_installed_version)"
|
||||
[[ "${status}" == "安装不完整" ]] && default_choice="3"
|
||||
render_installed_menu "${status}" "${version}" "${public_url}"
|
||||
|
||||
choice="$(prompt_menu_choice "${default_choice}" "12")" || return 1
|
||||
case "${choice}" in
|
||||
0) success "已退出,未作任何修改。" ;;
|
||||
1) do_install ;;
|
||||
2) do_update ;;
|
||||
3) do_repair_install ;;
|
||||
4) ensure_docker; do_status ;;
|
||||
4) do_status ;;
|
||||
5) do_restart ;;
|
||||
6) ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
|
||||
6) do_logs ;;
|
||||
7) do_certificate ;;
|
||||
8) ensure_docker; do_rollback ;;
|
||||
8) do_rollback ;;
|
||||
9) do_guide ;;
|
||||
10) do_show_admin_credentials ;;
|
||||
11) do_reset_admin_password ;;
|
||||
12) ensure_docker; do_uninstall ;;
|
||||
12) do_uninstall ;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -1486,16 +1589,17 @@ case "${COMMAND}" in
|
||||
menu) require_root; require_curl; do_menu ;;
|
||||
install) require_root; require_curl; do_install ;;
|
||||
update) require_root; require_curl; do_update ;;
|
||||
status) require_root; require_curl; ensure_docker; do_status ;;
|
||||
logs) require_root; require_curl; ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
|
||||
repair) require_root; require_curl; do_repair_install ;;
|
||||
status) require_root; require_curl; do_status ;;
|
||||
logs) require_root; require_curl; do_logs ;;
|
||||
restart) require_root; require_curl; do_restart ;;
|
||||
reload) require_root; require_curl; reload_services ;;
|
||||
certificate) require_root; require_curl; do_certificate ;;
|
||||
rollback) require_root; require_curl; ensure_docker; do_rollback ;;
|
||||
rollback) require_root; require_curl; do_rollback ;;
|
||||
guide) require_root; require_curl; do_guide ;;
|
||||
credentials) require_root; require_curl; do_show_admin_credentials ;;
|
||||
reset-password) require_root; require_curl; do_reset_admin_password ;;
|
||||
reset-2fa) require_root; require_curl; do_reset_admin_two_factor ;;
|
||||
uninstall) require_root; require_curl; ensure_docker; do_uninstall ;;
|
||||
uninstall) require_root; require_curl; do_uninstall ;;
|
||||
*) usage; fail "未知命令:${COMMAND}" ;;
|
||||
esac
|
||||
|
||||
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