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)?(?:div|p|table|tr|td|br|span|a|img)(?:\s[^>]*)?>`)
+
+func stripTelegramQuotedContent(value string) string {
+ lines := strings.Split(value, "\n")
+ for i, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if i > 0 && (trimmed == "--" || telegramQuoteBoundaryRe.MatchString(trimmed)) {
+ lines = lines[:i]
+ break
+ }
+ }
+ return strings.TrimSpace(strings.Join(lines, "\n"))
+}
+
+func sanitizeTelegramAttachmentName(value string) string {
+ value = strings.TrimSpace(strings.Map(func(r rune) rune {
+ if unicode.IsControl(r) {
+ return -1
+ }
+ return r
+ }, strings.ToValidUTF8(value, "�")))
+ value = strings.Join(strings.Fields(value), " ")
+ value, truncated := truncateRunes(value, 100)
+ if truncated {
+ value += "..."
+ }
+ return value
+}
+
+var (
+ telegramOTPKeywordRe = regexp.MustCompile(`(?i)(验证码|校验码|动态码|登录码|安全码|一次性密码|otp|verification[ -]?code|security[ -]?code|login[ -]?code|passcode|one[ -]?time[ -]?(?:password|code))`)
+ telegramOTPCandidateRe = regexp.MustCompile(`(?i)[a-z0-9]{4,10}`)
+)
+
+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 AND (delivered_at IS NOT NULL OR attempt_count>=?)`, a.now().UTC().Add(-30*24*time.Hour).Format(time.RFC3339Nano), telegramMailMaxAttempts)
cfg := a.config()
if !cfg.TelegramMailEnabled || strings.TrimSpace(cfg.TelegramBotToken) == "" || !validTelegramPrivateChatID(cfg.TelegramPrivateChatID) {
return nil
}
- _, _ = 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))
+ nowText := a.now().UTC().Format(time.RFC3339Nano)
+ rows, err := a.db.QueryContext(ctx, `SELECT id,payload_json,attempt_count FROM telegram_mail_outbox WHERE delivered_at IS NULL AND attempt_count AND next_attempt_at<=? AND (lease_until='' OR lease_until<=?) ORDER BY next_attempt_at,created_at LIMIT 20`, telegramMailMaxAttempts, nowText, nowText)
if err != nil {
return err
}
@@ -228,6 +608,7 @@ func (a *App) processDueTelegramMailNotifications(ctx context.Context) error {
id string
payload telegramMailPayload
attempt int
+ invalid bool
}
items := []queueItem{}
for rows.Next() {
@@ -238,8 +619,7 @@ func (a *App) processDueTelegramMailNotifications(ctx context.Context) error {
return err
}
if err := json.Unmarshal([]byte(raw), &item.payload); err != nil {
- rows.Close()
- return err
+ item.invalid = true
}
items = append(items, item)
}
@@ -247,20 +627,56 @@ func (a *App) processDueTelegramMailNotifications(ctx context.Context) error {
return err
}
for _, item := range items {
- err := a.sendTelegramMessage(ctx, cfg.TelegramBotToken, cfg.TelegramPrivateChatID, formatTelegramMailMessage(item.payload))
+ if item.invalid {
+ now := a.now().UTC().Format(time.RFC3339Nano)
+ if _, err := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=?,last_error='通知数据损坏',updated_at=?,lease_until='',payload_json='{}' WHERE id=?`, telegramMailMaxAttempts, now, item.id); err != nil {
+ return err
+ }
+ continue
+ }
now := a.now().UTC()
+ leaseUntil := now.Add(2 * time.Minute).Format(time.RFC3339Nano)
+ result, err := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET lease_until=?,updated_at=? WHERE id=? AND delivered_at IS NULL AND (lease_until='' OR lease_until<=?)`, leaseUntil, now.Format(time.RFC3339Nano), item.id, now.Format(time.RFC3339Nano))
if err != nil {
- 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)
+ return err
+ }
+ if affected, _ := result.RowsAffected(); affected != 1 {
+ continue
+ }
+ formatted := formatTelegramMailMessage(item.payload)
+ telegramMessageID, err := a.deliverTelegramMailMessage(ctx, cfg.TelegramBotToken, cfg.TelegramPrivateChatID, formatted)
+ now = a.now().UTC()
+ if err != nil {
+ attempts := item.attempt + 1
+ delay := sendRetryDelay(attempts)
+ var apiErr *telegramAPIError
+ if errors.As(err, &apiErr) {
+ if apiErr.RetryAfter > 0 {
+ delay = apiErr.RetryAfter
+ }
+ code := apiErr.ErrorCode
+ if code == 0 {
+ code = apiErr.HTTPStatus
+ }
+ if code == http.StatusUnauthorized || code == http.StatusForbidden || (code >= 400 && code < 500 && code != http.StatusTooManyRequests) {
+ attempts = telegramMailMaxAttempts
+ }
+ }
+ next := now.Add(delay)
+ if _, updateErr := a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=?,next_attempt_at=?,last_error=?,updated_at=?,lease_until='',payload_json=CASE WHEN ?>=? THEN '{}' ELSE payload_json END WHERE id=? AND delivered_at IS NULL`, attempts, next.Format(time.RFC3339Nano), truncateWebhookError(err.Error()), now.Format(time.RFC3339Nano), attempts, telegramMailMaxAttempts, item.id); updateErr != nil {
+ return updateErr
+ }
continue
}
stamp := now.Format(time.RFC3339Nano)
- _, _ = 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: "这是邮件正文,包含
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
") || !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{telegramPairing.code}
+