From 635ab02b292809c05d3fe3f620be45e0f70ec060 Mon Sep 17 00:00:00 2001 From: zxyszx <299979470+zxyszx@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:13:08 +0800 Subject: [PATCH] feat: harden Telegram mail notifications --- .github/release-notes/v1.2.17.md | 6 + apps/api/internal/app/app.go | 101 +++- apps/api/internal/app/app_test.go | 2 + apps/api/internal/app/config.go | 4 + apps/api/internal/app/external_imap.go | 10 +- apps/api/internal/app/mail_handlers.go | 6 + apps/api/internal/app/maildir_sync.go | 43 +- apps/api/internal/app/router_auth.go | 1 + apps/api/internal/app/settings_handlers.go | 31 +- apps/api/internal/app/telegram.go | 619 +++++++++++++++++++-- apps/api/internal/app/telegram_test.go | 240 +++++++- apps/web/src/lib/api-types.ts | 3 + apps/web/src/lib/api.ts | 5 +- apps/web/src/pages/admin.tsx | 69 ++- docs/GUIDE.md | 15 +- docs/ISSUE_LEDGER.md | 27 + 16 files changed, 1099 insertions(+), 83 deletions(-) create mode 100644 .github/release-notes/v1.2.17.md diff --git a/.github/release-notes/v1.2.17.md b/.github/release-notes/v1.2.17.md new file mode 100644 index 0000000..6ba8084 --- /dev/null +++ b/.github/release-notes/v1.2.17.md @@ -0,0 +1,6 @@ +- Telegram 私聊改用 10 分钟一次性绑定码,避免自动获取 Chat ID 时绑定到错误账号。 +- 新增通知邮箱范围,可分别选择已启用邮箱和“未知收件”;升级后默认保留管理员邮箱范围。 +- 优化邮件通知排版,显示实际收件邮箱、正文摘要和附件数量;高可信验证码支持高亮与一键复制。 +- 完善邮件解析,支持 GBK 等字符集、伪 HTML 正文清理和历史引用过滤,减少乱码及旧验证码误识别。 +- 完善通知队列和错误处理:配置变化清理旧任务、发送租约、限流等待、格式降级、永久错误停止重试,并在任务结束后清除敏感正文。 +- 补齐本地互发、未知收件和外部 IMAP 新邮件通知;首次导入的历史邮件以及垃圾邮件、已删除邮件不会发送通知。 diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index df74231..3547417 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -23,18 +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 - telegramURL 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 { @@ -72,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(), telegramURL: "https://api.telegram.org"} + 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() @@ -94,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 @@ -444,7 +455,9 @@ func (a *App) migrate(ctx context.Context) error { last_error TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - delivered_at TEXT + 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 @@ -689,12 +702,72 @@ 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.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) 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 diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 950b91c..658a6ca 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -463,6 +463,8 @@ func systemSettingsPayload(settings SystemSettings) map[string]any { "telegramBotToken": "", "telegramPrivateChatId": settings.TelegramPrivateChatID, "telegramBodyMode": settings.TelegramBodyMode, + "telegramMailboxIds": settings.TelegramMailboxIDs, + "telegramIncludeUnregistered": settings.TelegramIncludeUnregistered, } } diff --git a/apps/api/internal/app/config.go b/apps/api/internal/app/config.go index 92d9561..2d235c9 100644 --- a/apps/api/internal/app/config.go +++ b/apps/api/internal/app/config.go @@ -56,6 +56,8 @@ type Config struct { TelegramBotToken string TelegramPrivateChatID string TelegramBodyMode string + TelegramMailboxIDs string + TelegramIncludeUnregistered bool MailTranslateEnabled bool MailTranslateMaxChars int DeliveryWebhookSecret string @@ -118,6 +120,8 @@ func LoadConfig() Config { 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", ""), diff --git a/apps/api/internal/app/external_imap.go b/apps/api/internal/app/external_imap.go index 83bd7ad..78dc821 100644 --- a/apps/api/internal/app/external_imap.go +++ b/apps/api/internal/app/external_imap.go @@ -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 } diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index 9a9387a..88e0994 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -1131,6 +1131,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 +1145,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 +1157,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) + } } } diff --git a/apps/api/internal/app/maildir_sync.go b/apps/api/internal/app/maildir_sync.go index 34d876e..16e6c7e 100644 --- a/apps/api/internal/app/maildir_sync.go +++ b/apps/api/internal/app/maildir_sync.go @@ -336,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 == "" { @@ -368,9 +371,11 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai } id, err := a.insertMessage(ctx, msg, attachments) 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.processInboundForwarding(ctx, id, mb.ID, raw) + if a.shouldNotifyTelegramMessage(ctx, id) { + a.enqueueTelegramMailNotification(ctx, id, msg, attachments) + } } return err == nil, err } @@ -589,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) { @@ -613,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) == "" { @@ -633,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, @@ -648,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) @@ -686,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 == "" { diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index 21df6d3..d791406 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -171,6 +171,7 @@ 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) diff --git a/apps/api/internal/app/settings_handlers.go b/apps/api/internal/app/settings_handlers.go index 6b14b39..6d75ea1 100644 --- a/apps/api/internal/app/settings_handlers.go +++ b/apps/api/internal/app/settings_handlers.go @@ -44,6 +44,8 @@ type SystemSettings struct { TelegramBotTokenSet bool `json:"telegramBotTokenSet"` TelegramPrivateChatID string `json:"telegramPrivateChatId"` TelegramBodyMode string `json:"telegramBodyMode"` + TelegramMailboxIDs []string `json:"telegramMailboxIds"` + TelegramIncludeUnregistered bool `json:"telegramIncludeUnregistered"` } type systemSettingsUpdate struct { @@ -81,6 +83,8 @@ type systemSettingsUpdate struct { TelegramBotToken string `json:"telegramBotToken"` TelegramPrivateChatID string `json:"telegramPrivateChatId"` TelegramBodyMode string `json:"telegramBodyMode"` + TelegramMailboxIDs []string `json:"telegramMailboxIds"` + TelegramIncludeUnregistered bool `json:"telegramIncludeUnregistered"` } type PublicSettings struct { @@ -135,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) @@ -218,6 +224,8 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) } 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 未设置")) @@ -227,9 +235,15 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) 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 } @@ -346,6 +360,8 @@ func (a *App) systemSettingsSnapshot() SystemSettings { TelegramBotTokenSet: strings.TrimSpace(cfg.TelegramBotToken) != "", TelegramPrivateChatID: cfg.TelegramPrivateChatID, TelegramBodyMode: normalizeTelegramBodyMode(cfg.TelegramBodyMode), + TelegramMailboxIDs: cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")), + TelegramIncludeUnregistered: cfg.TelegramIncludeUnregistered, } } @@ -438,6 +454,10 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error { 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 { @@ -447,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, @@ -483,6 +503,8 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error { "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) @@ -496,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() } diff --git a/apps/api/internal/app/telegram.go b/apps/api/internal/app/telegram.go index 26449dc..c5f0f64 100644 --- a/apps/api/internal/app/telegram.go +++ b/apps/api/internal/app/telegram.go @@ -3,6 +3,9 @@ package app import ( "bytes" "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -10,12 +13,27 @@ import ( "io" "net/http" "net/url" + "regexp" + "sort" "strconv" "strings" "time" + "unicode" + "unicode/utf8" + + nethtml "golang.org/x/net/html" ) -const telegramMailMaxAttempts = 8 +const ( + telegramMailMaxAttempts = 8 + telegramMessageBudget = 3800 + telegramPairingTTL = 10 * time.Minute +) + +type telegramPairing struct { + TokenFingerprint string + ExpiresAt time.Time +} type telegramMailPayload struct { From string `json:"from"` @@ -25,23 +43,31 @@ type telegramMailPayload struct { 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"` + 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"` @@ -52,6 +78,31 @@ type telegramUpdate struct { } `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" @@ -64,7 +115,7 @@ func validTelegramPrivateChatID(value string) bool { return err == nil && id > 0 } -func (a *App) handleDiscoverTelegramChat(w http.ResponseWriter, r *http.Request) { +func (a *App) handleCreateTelegramPairing(w http.ResponseWriter, r *http.Request) { var req telegramCredentialsRequest if err := decodeJSON(r, &req); err != nil { badRequest(w, err) @@ -78,11 +129,69 @@ func (a *App) handleDiscoverTelegramChat(w http.ResponseWriter, r *http.Request) badRequest(w, errors.New("请先填写 Telegram Bot Token")) return } - chatID, displayName, err := a.discoverTelegramPrivateChat(r.Context(), token) + 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}) } @@ -123,7 +232,7 @@ func (a *App) telegramCredentials(req telegramCredentialsRequest) (string, strin return token, chatID } -func (a *App) discoverTelegramPrivateChat(ctx context.Context, token string) (string, string, error) { +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, @@ -137,59 +246,104 @@ func (a *App) discoverTelegramPrivateChat(ctx context.Context, token string) (st 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("未找到私聊会话,请先在 Telegram 中打开机器人并发送 /start,然后重试") + 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) { + 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 := strings.TrimSpace(msg.BodyText) - if body == "" { - body = strings.TrimSpace(stripTags(msg.BodyHTML)) - } + body := telegramMessageBody(msg) + otp := detectTelegramOTP(msg.Subject, body) mode := normalizeTelegramBodyMode(cfg.TelegramBodyMode) limit := 800 if mode == "full" { limit = 2600 } - body, truncated := truncateRunes(strings.Join(strings.Fields(body), " "), limit) + body, truncated := truncateRunes(body, limit) if truncated { body += "..." } if body == "" { - body = strings.TrimSpace(msg.Snippet) + body = normalizeTelegramText(msg.Snippet) } - names := make([]string, 0, len(attachments)) + 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 := strings.TrimSpace(attachment.Filename) + name := sanitizeTelegramAttachmentName(attachment.Filename) if name != "" { names = append(names, name) } - if len(names) >= 10 { + if len(names) >= 5 { break } } payload := telegramMailPayload{ - From: msg.From, - FromName: msg.FromName, + From: from, + FromName: fromName, Recipient: recipient, - Subject: msg.Subject, - ReceivedAt: msg.ReceivedAt.Format(time.RFC3339Nano), + 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 { @@ -197,6 +351,229 @@ func (a *App) enqueueTelegramMailNotification(ctx context.Context, messageID str } } +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, "= 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)]*)?>`) + +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}`) +) + +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)) + for _, match := range telegramOTPCandidateRe.FindAllStringIndex(text, -1) { + 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 + } + 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 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) @@ -215,12 +592,15 @@ func (a *App) telegramMailWorker(ctx context.Context) { } 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=?)`, 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 } - _, _ = a.db.ExecContext(ctx, `DELETE FROM telegram_mail_outbox WHERE updated_at=?)`, 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 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) - _, _ = 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) + 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) string { +func formatTelegramMailMessage(payload telegramMailPayload) telegramFormattedMessage { subject := strings.TrimSpace(payload.Subject) if subject == "" || subject == "(no subject)" { subject = "(无主题)" @@ -274,20 +690,30 @@ func formatTelegramMailMessage(payload telegramMailPayload) string { 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{ - "收到新邮件", + "📩 新邮件通知", "", - "发件人:" + html.EscapeString(from), - "收件邮箱:" + html.EscapeString(payload.Recipient), - "主题:" + html.EscapeString(subject), + "主题:" + escapeTelegramWithinBudget(subject, 420), + "发件人:" + escapeTelegramWithinBudget(from, 500), + "收件邮箱:" + escapeTelegramWithinBudget(recipient, 320) + "", "收件时间:" + html.EscapeString(timeText), } + if payload.OTP != "" { + lines = append(lines, "", "🔐 验证码", ""+html.EscapeString(payload.OTP)+"") + } if len(payload.AttachmentNames) > 0 { names := make([]string, 0, len(payload.AttachmentNames)) for _, name := range payload.AttachmentNames { - names = append(names, html.EscapeString(name)) + names = append(names, escapeTelegramWithinBudget(name, 180)) } - lines = append(lines, "附件:"+strings.Join(names, "、")) + attachmentText := strings.Join(names, "、") + if payload.AttachmentCount > len(payload.AttachmentNames) { + attachmentText += fmt.Sprintf(",其余 %d 个未显示", payload.AttachmentCount-len(payload.AttachmentNames)) + } + lines = append(lines, "", fmt.Sprintf("📎 附件:%d 个", max(payload.AttachmentCount, len(payload.AttachmentNames))), attachmentText) } body := strings.TrimSpace(payload.Body) if body != "" { @@ -295,18 +721,129 @@ func formatTelegramMailMessage(payload telegramMailPayload) string { if normalizeTelegramBodyMode(payload.BodyMode) == "full" { label = "邮件正文" } - lines = append(lines, "", ""+label+":", "
"+html.EscapeString(body)+"
") + prefix := strings.Join(lines, "\n") + "\n\n" + label + "\n
" + suffix := "
" + body = escapeTelegramWithinBudget(body, telegramMessageBudget-utf8.RuneCountInString(prefix)-utf8.RuneCountInString(suffix)) + lines = []string{prefix + body + suffix} } - return strings.Join(lines, "\n") + htmlText := strings.Join(lines, "\n") + plain := formatTelegramMailPlainText(payload) + return telegramFormattedMessage{HTML: htmlText, PlainText: plain, OTP: payload.OTP} } func (a *App) sendTelegramMessage(ctx context.Context, token, chatID, text string) error { - return a.callTelegram(ctx, token, "sendMessage", map[string]any{ + _, err := a.sendTelegramPayload(ctx, token, map[string]any{ "chat_id": chatID, "text": text, "parse_mode": "HTML", "disable_web_page_preview": true, - }, nil) + }) + 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 { @@ -345,7 +882,7 @@ func (a *App) callTelegram(ctx context.Context, token, method string, payload an if description == "" { description = fmt.Sprintf("HTTP %d", resp.StatusCode) } - return fmt.Errorf("Telegram 发送失败: %s", description) + 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 { diff --git a/apps/api/internal/app/telegram_test.go b/apps/api/internal/app/telegram_test.go index 1a03b39..d98dc25 100644 --- a/apps/api/internal/app/telegram_test.go +++ b/apps/api/internal/app/telegram_test.go @@ -3,24 +3,36 @@ 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"` + 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": - _, _ = w.Write([]byte(`{"ok":true,"result":[{"update_id":7,"message":{"chat":{"id":123456789,"type":"private","first_name":"Zhenxi","last_name":"Shen"}}}]}`)) + 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 { @@ -54,6 +66,11 @@ func TestTelegramSettingsDiscoveryTestAndMailQueue(t *testing.T) { 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) } @@ -64,8 +81,16 @@ func TestTelegramSettingsDiscoveryTestAndMailQueue(t *testing.T) { 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": ""}, &discovered); code != http.StatusOK { + 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" { @@ -82,12 +107,13 @@ func TestTelegramSettingsDiscoveryTestAndMailQueue(t *testing.T) { 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: "这是邮件正文,包含 & 续费信息。", + BodyText: "这是邮件正文,验证码是 846981,包含 & 续费信息。", }, []AttachmentInput{{Filename: "账单-2026.pdf"}}) if err := a.processDueTelegramMailNotifications(context.Background()); err != nil { t.Fatalf("process Telegram mail queue: %v", err) @@ -96,15 +122,33 @@ func TestTelegramSettingsDiscoveryTestAndMailQueue(t *testing.T) { 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", "<VIP> & 续费信息"} { + 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) } } - 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 == "" { + 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) { @@ -145,3 +189,183 @@ func TestTelegramNetworkErrorDoesNotExposeToken(t *testing.T) { 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 ", 30), + Recipient: "admin@example.com", + Subject: strings.Repeat("超长主题 & ", 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, "846981") { + 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 TestTelegramPseudoHTMLAndBodyCharset(t *testing.T) { + pseudo := `

验证码:778899

欢迎登录
` + text := telegramMessageBody(storedMessage{BodyText: pseudo}) + if strings.Contains(text, "display:none") || strings.Contains(text, "

") || !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: "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()) + } +} diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 4d1cc07..9f2a5aa 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -237,9 +237,12 @@ export type SystemSettings = { telegramBotTokenSet: boolean telegramPrivateChatId: string telegramBodyMode: "summary" | "full" + telegramMailboxIds: string[] + telegramIncludeUnregistered: boolean } export type SystemSettingsPayload = Omit & { 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 } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index ae09273..d99aa51 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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, TelegramPrivateChat } 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,7 +197,8 @@ export const api = { maildirSyncHealth: () => request("/api/admin/maildir-sync/health"), updateSystemSettings: (payload: SystemSettingsPayload) => request("/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 }), - discoverTelegramChat: (botToken: string) => request("/api/admin/settings/telegram/discover", { method: "POST", body: JSON.stringify({ botToken }) }), + createTelegramPairing: (botToken: string) => request("/api/admin/settings/telegram/pair", { method: "POST", body: JSON.stringify({ botToken }) }), + discoverTelegramChat: (botToken: string, pairingCode: string) => request("/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>("/api/admin/mail-templates"), updateMailTemplate: (key: string, payload: { subject: string; bodyText: string; bodyHtml: string }) => request(`/api/admin/mail-templates/${encodeURIComponent(key)}`, { method: "POST", body: JSON.stringify(payload) }), diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 23789e2..1203854 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -24,7 +24,7 @@ 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" | "notifications" | "externalImap" | "templates" | "security" | "about" @@ -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" && } {section === "messages" && } {section === "sendAudit" && } - {section === "settings" && } + {section === "settings" && } ) @@ -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() @@ -1059,6 +1059,9 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S const [telegramBotToken, setTelegramBotToken] = React.useState("") const [telegramPrivateChatId, setTelegramPrivateChatId] = React.useState("") const [telegramBodyMode, setTelegramBodyMode] = React.useState<"summary" | "full">("summary") + const [telegramMailboxIds, setTelegramMailboxIds] = React.useState([]) + const [telegramIncludeUnregistered, setTelegramIncludeUnregistered] = React.useState(false) + const [telegramPairing, setTelegramPairing] = React.useState(null) React.useEffect(() => { if (!settings) return setSmtpRequireTls(settings.smtpRequireTls) @@ -1076,11 +1079,23 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S 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), + 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 }), @@ -1126,6 +1141,8 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S telegramBotToken, telegramPrivateChatId, telegramBodyMode, + telegramMailboxIds, + telegramIncludeUnregistered, }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["admin", "settings"] }) @@ -1171,6 +1188,8 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S settings.telegramBotTokenSet, settings.telegramPrivateChatId, settings.telegramBodyMode, + (settings.telegramMailboxIds || []).join(","), + settings.telegramIncludeUnregistered, ].join("|") : "loading" const tabs: { key: typeof settingsTab; label: string }[] = [ ...(canSettingsView ? [ @@ -1303,10 +1322,46 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S

setTelegramPrivateChatId(event.target.value)} placeholder="123456789" /> -
+ {telegramPairing && ( +
+
+ {telegramPairing.code} + +
+
+ + +
+
+ )} + + +
+ +
+ {mailboxes.filter((mailbox) => mailbox.status === "active").map((mailbox) => ( + + ))} +
diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 48c8799..bccdd6f 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -64,13 +64,16 @@ DNS 生效通常需要几分钟到数小时。系统只能检测记录,不能 管理员可以把每封新收邮件的概要发送到自己的 Telegram 私聊: 1. 使用 `@BotFather` 创建一个邮件通知机器人并取得 Bot Token。 -2. 在 Telegram 中打开机器人,发送 `/start`。 -3. 进入“管理后台 -> 系统设置 -> 通知”。 -4. 填写 Bot Token,点击“自动获取”取得私聊 Chat ID。 -5. 选择正文显示方式并点击“测试通知”。 -6. 测试成功后开启“私聊新邮件通知”,保存设置。 +2. 进入“管理后台 -> 系统设置 -> 通知”,填写 Bot Token。 +3. 点击“安全绑定”生成一次性绑定码,再点击“打开机器人”。 +4. 在机器人会话中发送页面生成的绑定码,然后点击“完成绑定”。 +5. 勾选需要通知的邮箱;需要接收未注册地址邮件时,另行勾选“未知收件”。 +6. 选择正文显示方式并点击“测试通知”。 +7. 测试成功后开启“私聊新邮件通知”,保存设置。 -通知会显示发件人、收件邮箱、主题、收件时间、正文和附件名称。Telegram 连接失败不会影响邮局收件,系统会保留通知任务并自动重试。Bot Token 不会在设置页面重新显示;以后修改其他设置时,Token 输入框留空即可保留原值。 +一次性绑定码有效期为 10 分钟,只会匹配发送了该绑定码的私聊账号。通知会显示主题、发件人、收件邮箱、服务器收件时间、正文和附件摘要;识别到唯一高可信验证码时,会高亮显示并提供“复制验证码”按钮。外部 IMAP 第一次同步导入的历史邮件不会发送通知,后续新邮件才会通知。 + +Telegram 连接失败不会影响邮局收件。系统会保留通知任务并自动重试;关闭通知、更换机器人、更换私聊账号或修改通知邮箱范围时,尚未发送的旧任务会被清除。Telegram Bot API 不提供客户端幂等键,因此网络超时发生在 Telegram 已收到请求但服务器未收到响应时,极少数通知可能重复发送。Bot Token 不会在设置页面重新显示;以后修改其他设置时,Token 输入框留空即可保留原值。 邮件通知机器人只负责部署实例的私聊提醒。项目版本频道通知由 GitHub Release 工作流统一发送,不需要在每台服务器重复配置。 diff --git a/docs/ISSUE_LEDGER.md b/docs/ISSUE_LEDGER.md index c327d11..9553626 100644 --- a/docs/ISSUE_LEDGER.md +++ b/docs/ISSUE_LEDGER.md @@ -40,6 +40,7 @@ | 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-20260804-001 @@ -225,3 +226,29 @@ | 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 | 完成桌面端与移动端页面验收及最终回归,状态流转为已完成。 |