feat: add Telegram mail and release notifications
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions

This commit is contained in:
zxyszx
2026-08-06 02:17:11 +08:00
parent c77f63b5af
commit 79f920bf0b
17 changed files with 805 additions and 6 deletions
+3
View File
@@ -0,0 +1,3 @@
- 新增后台 Telegram 私聊新邮件通知,支持自动获取 Chat ID、测试通知、正文显示模式和失败自动重试。
- 新增 GitHub Release 版本频道通知;仅首次创建 Release 时发送一次,工作流重跑不会重复推送。
- Bot Token 不通过设置接口返回,Telegram 异常不会阻塞邮件接收或版本发布。
+57
View File
@@ -240,6 +240,7 @@ jobs:
cp generated-release-notes.md release-notes.md cp generated-release-notes.md release-notes.md
- name: Create or update GitHub release - name: Create or update GitHub release
id: release_result
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
shell: bash shell: bash
@@ -248,6 +249,62 @@ jobs:
title="NewSzxcn Email ${tag}" title="NewSzxcn Email ${tag}"
if gh release view "${tag}" >/dev/null 2>&1; then if gh release view "${tag}" >/dev/null 2>&1; then
gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest
echo "created=false" >> "$GITHUB_OUTPUT"
else else
gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest
echo "created=true" >> "$GITHUB_OUTPUT"
fi 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 re
import os
notes = open("release-notes.md", "r", encoding="utf-8").read().strip()
lines = []
for raw in notes.splitlines():
line = re.sub(r"^#{1,6}\s+", "", raw).strip()
line = re.sub(r"\*\*([^*]+)\*\*", r"\1", line)
line = re.sub(r"\[([^]]+)\]\(([^)]+)\)", r"\1\2", line)
lines.append(line)
body = "\n".join(lines).strip()
tag = os.environ["RELEASE_TAG"]
release_url = os.environ["RELEASE_URL"]
prefix = f"NewSzxcn Email {tag}\n\n"
suffix = f"\n\n更新地址:{release_url}"
available = max(0, 3600 - len(prefix) - len(suffix))
if len(body) > available:
body = body[:available].rstrip() + "..."
open("telegram-release-message.txt", "w", encoding="utf-8").write(prefix + body + suffix)
PY
jq -n \
--arg chat_id "${TELEGRAM_CHAT_ID}" \
--rawfile text telegram-release-message.txt \
'{chat_id:$chat_id,text:$text,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."
+15 -1
View File
@@ -34,6 +34,7 @@ type App struct {
maildirHealth *maildirSyncHealthTracker maildirHealth *maildirSyncHealthTracker
externalIMAP externalIMAPClientFactory externalIMAP externalIMAPClientFactory
turnstileURL string turnstileURL string
telegramURL string
} }
func (a *App) config() Config { func (a *App) config() Config {
@@ -71,7 +72,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
} }
db.SetMaxOpenConns(1) 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"}
a.externalIMAP = a a.externalIMAP = a
if err := a.configureSQLite(context.Background()); err != nil { if err := a.configureSQLite(context.Background()); err != nil {
db.Close() db.Close()
@@ -107,6 +108,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
a.startWorker(func() { a.externalIMAPWorker(workerCtx) }) a.startWorker(func() { a.externalIMAPWorker(workerCtx) })
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) }) a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
a.startWorker(func() { a.statusWebhookWorker(workerCtx) }) a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
a.startWorker(func() { a.telegramMailWorker(workerCtx) })
return a, nil return a, nil
} }
@@ -433,6 +435,18 @@ 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_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 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
)`,
`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 `CREATE TRIGGER IF NOT EXISTS trg_mailbox_delete_status_webhook_outbox
AFTER DELETE ON mailboxes BEGIN AFTER DELETE ON mailboxes BEGIN
DELETE FROM status_webhook_outbox WHERE mailbox_id=OLD.id; DELETE FROM status_webhook_outbox WHERE mailbox_id=OLD.id;
+4
View File
@@ -459,6 +459,10 @@ func systemSettingsPayload(settings SystemSettings) map[string]any {
"externalImapGmailClientSecret": "", "externalImapGmailClientSecret": "",
"externalImapOutlookClientId": settings.ExternalIMAPOutlookClientID, "externalImapOutlookClientId": settings.ExternalIMAPOutlookClientID,
"externalImapOutlookClientSecret": "", "externalImapOutlookClientSecret": "",
"telegramMailEnabled": settings.TelegramMailEnabled,
"telegramBotToken": "",
"telegramPrivateChatId": settings.TelegramPrivateChatID,
"telegramBodyMode": settings.TelegramBodyMode,
} }
} }
+8
View File
@@ -52,6 +52,10 @@ type Config struct {
ExternalIMAPGmailClientSecret string ExternalIMAPGmailClientSecret string
ExternalIMAPOutlookClientID string ExternalIMAPOutlookClientID string
ExternalIMAPOutlookClientSecret string ExternalIMAPOutlookClientSecret string
TelegramMailEnabled bool
TelegramBotToken string
TelegramPrivateChatID string
TelegramBodyMode string
MailTranslateEnabled bool MailTranslateEnabled bool
MailTranslateMaxChars int MailTranslateMaxChars int
DeliveryWebhookSecret string DeliveryWebhookSecret string
@@ -110,6 +114,10 @@ func LoadConfig() Config {
ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""), ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""),
ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""), ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""),
ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""), 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")),
MailTranslateEnabled: getenvBool("LANQIN_MAIL_TRANSLATE_ENABLED", true), MailTranslateEnabled: getenvBool("LANQIN_MAIL_TRANSLATE_ENABLED", true),
MailTranslateMaxChars: getenvInt("LANQIN_MAIL_TRANSLATE_MAX_CHARS", 8000), MailTranslateMaxChars: getenvInt("LANQIN_MAIL_TRANSLATE_MAX_CHARS", 8000),
DeliveryWebhookSecret: getenv("LANQIN_DELIVERY_WEBHOOK_SECRET", ""), DeliveryWebhookSecret: getenv("LANQIN_DELIVERY_WEBHOOK_SECRET", ""),
+5 -1
View File
@@ -292,7 +292,10 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr) a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
return false, nil 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 return err == nil, err
} }
@@ -365,6 +368,7 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
} }
id, err := a.insertMessage(ctx, msg, attachments) id, err := a.insertMessage(ctx, msg, attachments)
if err == nil && strings.EqualFold(folder.Name, "Inbox") { if err == nil && strings.EqualFold(folder.Name, "Inbox") {
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
a.applyInboundControls(ctx, id, mb.ID, msg.From, msg.Subject) a.applyInboundControls(ctx, id, mb.ID, msg.From, msg.Subject)
a.processInboundForwarding(ctx, id, mb.ID, raw) a.processInboundForwarding(ctx, id, mb.ID, raw)
} }
+2
View File
@@ -171,6 +171,8 @@ func (a *App) Router() http.Handler {
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/maildir-sync/health", a.handleMaildirSyncHealth) 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(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP) r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
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(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
r.With(a.requirePermission(PermissionTemplatesUpdate)).Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate) 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) r.With(a.requirePermission(PermissionTemplatesReset)).Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
@@ -40,6 +40,10 @@ type SystemSettings struct {
ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"` ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"`
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"` ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
ExternalIMAPOutlookClientSecretSet bool `json:"externalImapOutlookClientSecretSet"` ExternalIMAPOutlookClientSecretSet bool `json:"externalImapOutlookClientSecretSet"`
TelegramMailEnabled bool `json:"telegramMailEnabled"`
TelegramBotTokenSet bool `json:"telegramBotTokenSet"`
TelegramPrivateChatID string `json:"telegramPrivateChatId"`
TelegramBodyMode string `json:"telegramBodyMode"`
} }
type systemSettingsUpdate struct { type systemSettingsUpdate struct {
@@ -73,6 +77,10 @@ type systemSettingsUpdate struct {
ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"` ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"`
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"` ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
ExternalIMAPOutlookClientSecret string `json:"externalImapOutlookClientSecret"` ExternalIMAPOutlookClientSecret string `json:"externalImapOutlookClientSecret"`
TelegramMailEnabled bool `json:"telegramMailEnabled"`
TelegramBotToken string `json:"telegramBotToken"`
TelegramPrivateChatID string `json:"telegramPrivateChatId"`
TelegramBodyMode string `json:"telegramBodyMode"`
} }
type PublicSettings struct { type PublicSettings struct {
@@ -204,6 +212,22 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
badRequest(w, errors.New("外部 IMAP 加密密钥未设置")) badRequest(w, errors.New("外部 IMAP 加密密钥未设置"))
return 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)
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 err := a.saveSystemSettings(r.Context(), next); err != nil { if err := a.saveSystemSettings(r.Context(), next); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save settings") respondError(w, http.StatusInternalServerError, "failed to save settings")
@@ -318,6 +342,10 @@ func (a *App) systemSettingsSnapshot() SystemSettings {
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "", ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "",
ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID, ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID,
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPOutlookClientSecret) != "", ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPOutlookClientSecret) != "",
TelegramMailEnabled: cfg.TelegramMailEnabled,
TelegramBotTokenSet: strings.TrimSpace(cfg.TelegramBotToken) != "",
TelegramPrivateChatID: cfg.TelegramPrivateChatID,
TelegramBodyMode: normalizeTelegramBodyMode(cfg.TelegramBodyMode),
} }
} }
@@ -402,6 +430,14 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
cfg.ExternalIMAPOutlookClientID = value cfg.ExternalIMAPOutlookClientID = value
case "externalImapOutlookClientSecret": case "externalImapOutlookClientSecret":
cfg.ExternalIMAPOutlookClientSecret = value 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)
} }
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -443,6 +479,10 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
"externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret, "externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret,
"externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID, "externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID,
"externalImapOutlookClientSecret": cfg.ExternalIMAPOutlookClientSecret, "externalImapOutlookClientSecret": cfg.ExternalIMAPOutlookClientSecret,
"telegramMailEnabled": strconv.FormatBool(cfg.TelegramMailEnabled),
"telegramBotToken": cfg.TelegramBotToken,
"telegramPrivateChatId": cfg.TelegramPrivateChatID,
"telegramBodyMode": normalizeTelegramBodyMode(cfg.TelegramBodyMode),
} }
now := a.now().UTC().Format(time.RFC3339Nano) now := a.now().UTC().Format(time.RFC3339Nano)
tx, err := a.db.BeginTx(ctx, nil) tx, err := a.db.BeginTx(ctx, nil)
+356
View File
@@ -0,0 +1,356 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const telegramMailMaxAttempts = 8
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"`
AttachmentNames []string `json:"attachmentNames,omitempty"`
}
type telegramCredentialsRequest struct {
BotToken string `json:"botToken"`
ChatID string `json:"chatId"`
}
type telegramAPIResponse struct {
OK bool `json:"ok"`
Description string `json:"description"`
Result json.RawMessage `json:"result"`
}
type telegramUpdate struct {
UpdateID int64 `json:"update_id"`
Message *struct {
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"`
}
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) 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)
}
if token == "" {
badRequest(w, errors.New("请先填写 Telegram Bot Token"))
return
}
chatID, displayName, err := a.discoverTelegramPrivateChat(r.Context(), token)
if err != nil {
respondError(w, http.StatusBadGateway, err.Error())
return
}
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 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
}
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("未找到私聊会话,请先在 Telegram 中打开机器人并发送 /start,然后重试")
}
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) {
return
}
recipient := normalizeEmail(msg.RecipientAddr)
if recipient == "" && len(msg.To) > 0 {
recipient = normalizeEmail(msg.To[0])
}
body := strings.TrimSpace(msg.BodyText)
if body == "" {
body = strings.TrimSpace(stripTags(msg.BodyHTML))
}
mode := normalizeTelegramBodyMode(cfg.TelegramBodyMode)
limit := 800
if mode == "full" {
limit = 2600
}
body, truncated := truncateRunes(strings.Join(strings.Fields(body), " "), limit)
if truncated {
body += "..."
}
if body == "" {
body = strings.TrimSpace(msg.Snippet)
}
names := make([]string, 0, len(attachments))
for _, attachment := range attachments {
name := strings.TrimSpace(attachment.Filename)
if name != "" {
names = append(names, name)
}
if len(names) >= 10 {
break
}
}
payload := telegramMailPayload{
From: msg.From,
FromName: msg.FromName,
Recipient: recipient,
Subject: msg.Subject,
ReceivedAt: msg.ReceivedAt.Format(time.RFC3339Nano),
Body: body,
BodyMode: mode,
AttachmentNames: names,
}
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 (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 {
cfg := a.config()
if !cfg.TelegramMailEnabled || strings.TrimSpace(cfg.TelegramBotToken) == "" || !validTelegramPrivateChatID(cfg.TelegramPrivateChatID) {
return nil
}
_, _ = 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)
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<=? ORDER BY next_attempt_at,created_at LIMIT 20`, telegramMailMaxAttempts, a.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return err
}
type queueItem struct {
id string
payload telegramMailPayload
attempt int
}
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 {
rows.Close()
return err
}
items = append(items, item)
}
if err := rows.Close(); err != nil {
return err
}
for _, item := range items {
err := a.sendTelegramMessage(ctx, cfg.TelegramBotToken, cfg.TelegramPrivateChatID, formatTelegramMailMessage(item.payload))
now := a.now().UTC()
if err != nil {
next := now.Add(sendRetryDelay(item.attempt + 1))
_, _ = a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=attempt_count+1,next_attempt_at=?,last_error=?,updated_at=? WHERE id=? AND delivered_at IS NULL`, next.Format(time.RFC3339Nano), truncateWebhookError(err.Error()), now.Format(time.RFC3339Nano), item.id)
continue
}
stamp := now.Format(time.RFC3339Nano)
_, _ = a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=attempt_count+1,last_error='',updated_at=?,delivered_at=? WHERE id=? AND delivered_at IS NULL`, stamp, stamp, item.id)
}
return nil
}
func formatTelegramMailMessage(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")
}
lines := []string{
"<b>收到新邮件</b>",
"",
"<b>发件人:</b>" + html.EscapeString(from),
"<b>收件邮箱:</b>" + html.EscapeString(payload.Recipient),
"<b>主题:</b>" + html.EscapeString(subject),
"<b>收件时间:</b>" + html.EscapeString(timeText),
}
if len(payload.AttachmentNames) > 0 {
names := make([]string, 0, len(payload.AttachmentNames))
for _, name := range payload.AttachmentNames {
names = append(names, html.EscapeString(name))
}
lines = append(lines, "<b>附件:</b>"+strings.Join(names, "、"))
}
body := strings.TrimSpace(payload.Body)
if body != "" {
label := "正文摘要"
if normalizeTelegramBodyMode(payload.BodyMode) == "full" {
label = "邮件正文"
}
lines = append(lines, "", "<b>"+label+"</b>", "<blockquote>"+html.EscapeString(body)+"</blockquote>")
}
return strings.Join(lines, "\n")
}
func (a *App) sendTelegramMessage(ctx context.Context, token, chatID, text string) error {
return a.callTelegram(ctx, token, "sendMessage", map[string]any{
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": true,
}, nil)
}
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 fmt.Errorf("Telegram 发送失败: %s", description)
}
if result != nil && len(apiResponse.Result) > 0 {
if err := json.Unmarshal(apiResponse.Result, result); err != nil {
return err
}
}
return nil
}
+147
View File
@@ -0,0 +1,147 @@
package app
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestTelegramSettingsDiscoveryTestAndMailQueue(t *testing.T) {
type sentMessage struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
}
var sent []sentMessage
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/getUpdates":
_, _ = w.Write([]byte(`{"ok":true,"result":[{"update_id":7,"message":{"chat":{"id":123456789,"type":"private","first_name":"Zhenxi","last_name":"Shen"}}}]}`))
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"
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 discovered map[string]string
if code := admin.do("POST", "/api/admin/settings/telegram/discover", map[string]string{"botToken": ""}, &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{
RecipientAddr: "admin@example.com",
Subject: "账单 <已生成>",
From: "billing@example.net",
FromName: "Billing & Support",
ReceivedAt: receivedAt,
BodyText: "这是邮件正文,包含 <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 &amp; Support", "账单 &lt;已生成&gt;", "admin@example.com", "邮件正文", "账单-2026.pdf", "&lt;VIP&gt; &amp; 续费信息"} {
if !strings.Contains(text, expected) {
t.Fatalf("Telegram mail message missing %q: %s", expected, text)
}
}
var delivered string
if err := a.db.QueryRow(`SELECT COALESCE(delivered_at,'') FROM telegram_mail_outbox WHERE message_id=?`, "mail_test_telegram").Scan(&delivered); err != nil || delivered == "" {
t.Fatalf("Telegram queue was not marked delivered: delivered=%q err=%v", delivered, 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)
}
}
+6 -1
View File
@@ -233,8 +233,13 @@ export type SystemSettings = {
externalImapGmailClientSecretSet: boolean externalImapGmailClientSecretSet: boolean
externalImapOutlookClientId: string externalImapOutlookClientId: string
externalImapOutlookClientSecretSet: boolean externalImapOutlookClientSecretSet: boolean
telegramMailEnabled: boolean
telegramBotTokenSet: boolean
telegramPrivateChatId: string
telegramBodyMode: "summary" | "full"
} }
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 PublicDomain = { id: string; name: 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 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 } export type LoginPayload = { loginName?: string; email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
+3 -1
View File
@@ -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 } from "./api-types"
export * from "./api-types" export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000 const REQUEST_TIMEOUT_MS = 15_000
@@ -197,6 +197,8 @@ export const api = {
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"), maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }), 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 }), testSmtp: (to: string) => request<{ ok: boolean }>("/api/admin/settings/test-smtp", { method: "POST", body: JSON.stringify({ to }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
discoverTelegramChat: (botToken: string) => request<TelegramPrivateChat>("/api/admin/settings/telegram/discover", { method: "POST", body: JSON.stringify({ botToken }) }),
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"), 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) }), 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" }), resetMailTemplate: (key: string) => request<MailTemplate>(`/api/admin/mail-templates/${encodeURIComponent(key)}/reset`, { method: "POST" }),
+75 -2
View File
@@ -27,7 +27,7 @@ import { hasAnyPermission, hasPermission } from "@/lib/permissions"
import type { PermissionKey } from "@/lib/api-types" import type { PermissionKey } from "@/lib/api-types"
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings" 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 } type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = { const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
@@ -1042,7 +1042,7 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
const canResetTemplates = hasPermission(user, "admin.templates.reset") const canResetTemplates = hasPermission(user, "admin.templates.reset")
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates }) const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
const requestedTab = initialTab as SettingsTab | undefined 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 maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false) const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true) const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
@@ -1055,6 +1055,10 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState<string[]>([]) const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState<string[]>([])
const [externalImapEnabled, setExternalImapEnabled] = React.useState(false) const [externalImapEnabled, setExternalImapEnabled] = React.useState(false)
const [externalImapAllowPrivateHosts, setExternalImapAllowPrivateHosts] = 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")
React.useEffect(() => { React.useEffect(() => {
if (!settings) return if (!settings) return
setSmtpRequireTls(settings.smtpRequireTls) setSmtpRequireTls(settings.smtpRequireTls)
@@ -1068,7 +1072,24 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
setUserMailboxDomainIds(settings.userMailboxDomainIds || []) setUserMailboxDomainIds(settings.userMailboxDomainIds || [])
setExternalImapEnabled(settings.externalImapEnabled) setExternalImapEnabled(settings.externalImapEnabled)
setExternalImapAllowPrivateHosts(settings.externalImapAllowPrivateHosts) setExternalImapAllowPrivateHosts(settings.externalImapAllowPrivateHosts)
setTelegramMailEnabled(settings.telegramMailEnabled)
setTelegramBotToken("")
setTelegramPrivateChatId(settings.telegramPrivateChatId || "")
setTelegramBodyMode(settings.telegramBodyMode === "full" ? "full" : "summary")
}, [settings]) }, [settings])
const discoverTelegram = useMutation({
mutationFn: () => api.discoverTelegramChat(telegramBotToken),
onSuccess: (chat) => {
setTelegramPrivateChatId(chat.chatId)
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({ const save = useMutation({
mutationFn: (form: FormData) => api.updateSystemSettings({ mutationFn: (form: FormData) => api.updateSystemSettings({
publicHostname: fieldValue(form, "publicHostname", settings?.publicHostname || ""), publicHostname: fieldValue(form, "publicHostname", settings?.publicHostname || ""),
@@ -1101,6 +1122,10 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
externalImapGmailClientSecret: fieldValue(form, "externalImapGmailClientSecret", ""), externalImapGmailClientSecret: fieldValue(form, "externalImapGmailClientSecret", ""),
externalImapOutlookClientId: fieldValue(form, "externalImapOutlookClientId", settings?.externalImapOutlookClientId || ""), externalImapOutlookClientId: fieldValue(form, "externalImapOutlookClientId", settings?.externalImapOutlookClientId || ""),
externalImapOutlookClientSecret: fieldValue(form, "externalImapOutlookClientSecret", ""), externalImapOutlookClientSecret: fieldValue(form, "externalImapOutlookClientSecret", ""),
telegramMailEnabled,
telegramBotToken,
telegramPrivateChatId,
telegramBodyMode,
}), }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", "settings"] }) qc.invalidateQueries({ queryKey: ["admin", "settings"] })
@@ -1142,6 +1167,10 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
settings.externalImapGmailClientSecretSet, settings.externalImapGmailClientSecretSet,
settings.externalImapOutlookClientId, settings.externalImapOutlookClientId,
settings.externalImapOutlookClientSecretSet, settings.externalImapOutlookClientSecretSet,
settings.telegramMailEnabled,
settings.telegramBotTokenSet,
settings.telegramPrivateChatId,
settings.telegramBodyMode,
].join("|") : "loading" ].join("|") : "loading"
const tabs: { key: typeof settingsTab; label: string }[] = [ const tabs: { key: typeof settingsTab; label: string }[] = [
...(canSettingsView ? [ ...(canSettingsView ? [
@@ -1149,6 +1178,7 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
{ key: "smtp" as const, label: "SMTP" }, { key: "smtp" as const, label: "SMTP" },
{ key: "storage" as const, label: "存储" }, { key: "storage" as const, label: "存储" },
{ key: "mail" as const, label: "邮件" }, { key: "mail" as const, label: "邮件" },
{ key: "notifications" as const, label: "通知" },
{ key: "externalImap" as const, label: "外部 IMAP" }, { key: "externalImap" as const, label: "外部 IMAP" },
] : []), ] : []),
...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []), ...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []),
@@ -1258,6 +1288,49 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
</CardContent> </CardContent>
</Card>} </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={discoverTelegram.isPending} onClick={() => discoverTelegram.mutate()}>
<Search className="mr-2 h-4 w-4" />{discoverTelegram.isPending ? "获取中" : "自动获取"}
</Button>
</div>
</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> {settingsTab === "externalImap" && <Card>
<CardHeader> <CardHeader>
<CardTitle> IMAP </CardTitle> <CardTitle> IMAP </CardTitle>
+16
View File
@@ -183,6 +183,22 @@ LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET=
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID= LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID=
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET= 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
# ========================= # =========================
# 系统 # 系统
# ========================= # =========================
+27
View File
@@ -148,6 +148,33 @@ 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` - Postfix 读取 `/data/lanqin.db` 中的 `domains``mailboxes``aliases`
+15
View File
@@ -59,6 +59,21 @@ DNS 生效通常需要几分钟到数小时。系统只能检测记录,不能
无人收件不会自动创建邮箱,也不会把邮件分配给普通用户。只有管理员可以在邮箱前台左侧的“未知收件”中查看这些邮件。 无人收件不会自动创建邮箱,也不会把邮件分配给普通用户。只有管理员可以在邮箱前台左侧的“未知收件”中查看这些邮件。
## Telegram 私聊邮件通知
管理员可以把每封新收邮件的概要发送到自己的 Telegram 私聊:
1. 使用 `@BotFather` 创建一个邮件通知机器人并取得 Bot Token。
2. 在 Telegram 中打开机器人,发送 `/start`
3. 进入“管理后台 -> 系统设置 -> 通知”。
4. 填写 Bot Token,点击“自动获取”取得私聊 Chat ID。
5. 选择正文显示方式并点击“测试通知”。
6. 测试成功后开启“私聊新邮件通知”,保存设置。
通知会显示发件人、收件邮箱、主题、收件时间、正文和附件名称。Telegram 连接失败不会影响邮局收件,系统会保留通知任务并自动重试。Bot Token 不会在设置页面重新显示;以后修改其他设置时,Token 输入框留空即可保留原值。
邮件通知机器人只负责部署实例的私聊提醒。项目版本频道通知由 GitHub Release 工作流统一发送,不需要在每台服务器重复配置。
## SSL 证书与自动续期 ## SSL 证书与自动续期
选择“自动配置 Nginx + SSL”后,官方 `acme.sh` 会安装定时检查任务。证书接近到期时会自动续期,续期成功后自动重载 NewSzxcn Email 和 Nginx。 选择“自动配置 Nginx + SSL”后,官方 `acme.sh` 会安装定时检查任务。证书接近到期时会自动续期,续期成功后自动重载 NewSzxcn Email 和 Nginx。
+26
View File
@@ -39,6 +39,7 @@
| NSX-20260804-002 | 2026-08-04 | 已完成 | 前端/UI/响应式布局 | 邮箱选择器展开后宽度变窄 | S3 | v1.2.14 | 随 v1.2.14 发布 | | 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-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-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 | 待发布 |
## NSX-20260804-001 ## NSX-20260804-001
@@ -198,3 +199,28 @@
| --- | --- | | --- | --- |
| 2026-08-06 | 用户反馈“全部邮箱”右侧存在空白块并要求修改。 | | 2026-08-06 | 用户反馈“全部邮箱”右侧存在空白块并要求修改。 |
| 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 SecretsTelegram 失败不阻塞收件或版本发布。 |
| 兼容性 | 数据库只新增表和设置项;默认关闭;现有配置、邮件、证书和在线更新方式不变。 |
| 目标版本 | v1.2.16 |
| 测试结果 | Go 全量测试和 vet、前端 check/build、安装脚本语法/ShellCheck/回归、工作流 YAML、密钥扫描、桌面和移动端页面检查均通过。 |
| 发布状态 | 待发布。 |
### 历史
| 时间 | 记录 |
| --- | --- |
| 2026-08-06 | 用户确认后台只保留机器人私聊邮件通知,版本频道通知交由 GitHub Release 工作流统一发送。 |
| 2026-08-06 | 已配置仓库频道通知密钥并完成频道实发测试;真实 Bot Token 未写入源码。 |
| 2026-08-06 | 完成密钥隐藏、网络错误脱敏、通知失败隔离和 Release 重跑去重复核,状态流转为已完成。 |