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

This commit is contained in:
zxyszx
2026-08-06 17:13:08 +08:00
parent 4c90c1de44
commit 635ab02b29
16 changed files with 1099 additions and 83 deletions
+6
View File
@@ -0,0 +1,6 @@
- Telegram 私聊改用 10 分钟一次性绑定码,避免自动获取 Chat ID 时绑定到错误账号。
- 新增通知邮箱范围,可分别选择已启用邮箱和“未知收件”;升级后默认保留管理员邮箱范围。
- 优化邮件通知排版,显示实际收件邮箱、正文摘要和附件数量;高可信验证码支持高亮与一键复制。
- 完善邮件解析,支持 GBK 等字符集、伪 HTML 正文清理和历史引用过滤,减少乱码及旧验证码误识别。
- 完善通知队列和错误处理:配置变化清理旧任务、发送租约、限流等待、格式降级、永久错误停止重试,并在任务结束后清除敏感正文。
- 补齐本地互发、未知收件和外部 IMAP 新邮件通知;首次导入的历史邮件以及垃圾邮件、已删除邮件不会发送通知。
+87 -14
View File
@@ -23,18 +23,21 @@ import (
) )
type App struct { type App struct {
cfg Config cfg Config
cfgMu sync.RWMutex cfgMu sync.RWMutex
db *sql.DB db *sql.DB
log *slog.Logger log *slog.Logger
now func() time.Time now func() time.Time
policy *HTMLPolicy policy *HTMLPolicy
workerCancel context.CancelFunc workerCancel context.CancelFunc
workerWG sync.WaitGroup workerWG sync.WaitGroup
maildirHealth *maildirSyncHealthTracker maildirHealth *maildirSyncHealthTracker
externalIMAP externalIMAPClientFactory externalIMAP externalIMAPClientFactory
turnstileURL string turnstileURL string
telegramURL string telegramURL string
telegramPairMu sync.Mutex
telegramPairs map[string]telegramPairing
telegramDeliveryMu sync.Mutex
} }
func (a *App) config() Config { func (a *App) config() Config {
@@ -72,7 +75,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
} }
db.SetMaxOpenConns(1) db.SetMaxOpenConns(1)
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker(), 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 a.externalIMAP = a
if err := a.configureSQLite(context.Background()); err != nil { if err := a.configureSQLite(context.Background()); err != nil {
db.Close() db.Close()
@@ -94,6 +97,14 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
db.Close() db.Close()
return nil, err 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 { if err := a.enforceSingleAdministratorIndex(context.Background()); err != nil {
db.Close() db.Close()
return nil, err return nil, err
@@ -444,7 +455,9 @@ func (a *App) migrate(ctx context.Context) error {
last_error TEXT NOT NULL DEFAULT '', last_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_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 INDEX IF NOT EXISTS idx_telegram_mail_outbox_due ON telegram_mail_outbox(delivered_at,next_attempt_at,created_at)`,
`CREATE TRIGGER IF NOT EXISTS trg_mailbox_delete_status_webhook_outbox `CREATE TRIGGER IF NOT EXISTS trg_mailbox_delete_status_webhook_outbox
@@ -689,12 +702,72 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateAPITokenScopes(ctx); err != nil { if err := a.migrateAPITokenScopes(ctx); err != nil {
return err return err
} }
if err := a.migrateTelegramNotifications(ctx); err != nil {
return err
}
if err := a.ensureDefaultPermissionGroups(ctx); err != nil { if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
return err return err
} }
return nil 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 { func (a *App) migrateForwardingVerification(ctx context.Context) error {
columns := []struct { columns := []struct {
name string name string
+2
View File
@@ -463,6 +463,8 @@ func systemSettingsPayload(settings SystemSettings) map[string]any {
"telegramBotToken": "", "telegramBotToken": "",
"telegramPrivateChatId": settings.TelegramPrivateChatID, "telegramPrivateChatId": settings.TelegramPrivateChatID,
"telegramBodyMode": settings.TelegramBodyMode, "telegramBodyMode": settings.TelegramBodyMode,
"telegramMailboxIds": settings.TelegramMailboxIDs,
"telegramIncludeUnregistered": settings.TelegramIncludeUnregistered,
} }
} }
+4
View File
@@ -56,6 +56,8 @@ type Config struct {
TelegramBotToken string TelegramBotToken string
TelegramPrivateChatID string TelegramPrivateChatID string
TelegramBodyMode string TelegramBodyMode string
TelegramMailboxIDs string
TelegramIncludeUnregistered bool
MailTranslateEnabled bool MailTranslateEnabled bool
MailTranslateMaxChars int MailTranslateMaxChars int
DeliveryWebhookSecret string DeliveryWebhookSecret string
@@ -118,6 +120,8 @@ func LoadConfig() Config {
TelegramBotToken: getenv("LANQIN_TELEGRAM_BOT_TOKEN", ""), TelegramBotToken: getenv("LANQIN_TELEGRAM_BOT_TOKEN", ""),
TelegramPrivateChatID: getenv("LANQIN_TELEGRAM_PRIVATE_CHAT_ID", ""), TelegramPrivateChatID: getenv("LANQIN_TELEGRAM_PRIVATE_CHAT_ID", ""),
TelegramBodyMode: normalizeTelegramBodyMode(getenv("LANQIN_TELEGRAM_BODY_MODE", "summary")), 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), MailTranslateEnabled: getenvBool("LANQIN_MAIL_TRANSLATE_ENABLED", true),
MailTranslateMaxChars: getenvInt("LANQIN_MAIL_TRANSLATE_MAX_CHARS", 8000), MailTranslateMaxChars: getenvInt("LANQIN_MAIL_TRANSLATE_MAX_CHARS", 8000),
DeliveryWebhookSecret: getenv("LANQIN_DELIVERY_WEBHOOK_SECRET", ""), DeliveryWebhookSecret: getenv("LANQIN_DELIVERY_WEBHOOK_SECRET", ""),
+8 -2
View File
@@ -1181,6 +1181,9 @@ func (a *App) syncExternalIMAPFolder(ctx context.Context, account externalIMAPAc
if err := a.writeStoredMessageToMaildir(ctx, msgID, stored, attachments); err != nil { 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) 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++ imported++
} else { } else {
skipped++ skipped++
@@ -1194,12 +1197,15 @@ func (a *App) syncExternalIMAPFolder(ctx context.Context, account externalIMAPAc
} }
type externalIMAPFolderState struct { type externalIMAPFolderState struct {
LastUID uint32 LastUID uint32
Initialized bool
} }
func (a *App) loadExternalIMAPFolderState(ctx context.Context, accountID, folder string) externalIMAPFolderState { func (a *App) loadExternalIMAPFolderState(ctx context.Context, accountID, folder string) externalIMAPFolderState {
var state 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 return state
} }
+6
View File
@@ -1131,6 +1131,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
copyMsg.IsRead = false copyMsg.IsRead = false
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil { if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments) _ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
} }
continue continue
} }
@@ -1144,6 +1145,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
copyMsg.IsRead = false copyMsg.IsRead = false
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil { if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments) _ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
} }
} }
continue continue
@@ -1155,12 +1157,16 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
copyMsg := base copyMsg := base
copyMsg.MailboxID = rcptMailbox.ID copyMsg.MailboxID = rcptMailbox.ID
copyMsg.FolderID = inboxID copyMsg.FolderID = inboxID
copyMsg.RecipientAddr = normalizeEmail(rcpt)
copyMsg.MessageUID = newID("uid") copyMsg.MessageUID = newID("uid")
copyMsg.IsRead = false copyMsg.IsRead = false
if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil { if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
_ = a.writeStoredMessageToMaildir(ctx, inboxMsgID, copyMsg, req.Attachments) _ = a.writeStoredMessageToMaildir(ctx, inboxMsgID, copyMsg, req.Attachments)
a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject) a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject)
a.processInboundForwarding(ctx, inboxMsgID, rcptMailbox.ID, mimeBytes) a.processInboundForwarding(ctx, inboxMsgID, rcptMailbox.ID, mimeBytes)
if a.shouldNotifyTelegramMessage(ctx, inboxMsgID) {
a.enqueueTelegramMailNotification(ctx, inboxMsgID, copyMsg, req.Attachments)
}
} }
} }
+42 -1
View File
@@ -336,6 +336,9 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
} }
msg.MailboxID = mb.ID msg.MailboxID = mb.ID
msg.FolderID = folder.ID msg.FolderID = folder.ID
if strings.TrimSpace(msg.RecipientAddr) == "" {
msg.RecipientAddr = mb.Address
}
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name) msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
msg.RawPath = path msg.RawPath = path
if msg.MessageUID == "" { 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) id, err := a.insertMessage(ctx, msg, attachments)
if err == nil && strings.EqualFold(folder.Name, "Inbox") { if err == nil && strings.EqualFold(folder.Name, "Inbox") {
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
a.applyInboundControls(ctx, id, mb.ID, msg.From, msg.Subject) a.applyInboundControls(ctx, id, mb.ID, msg.From, msg.Subject)
a.processInboundForwarding(ctx, id, mb.ID, raw) a.processInboundForwarding(ctx, id, mb.ID, raw)
if a.shouldNotifyTelegramMessage(ctx, id) {
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
}
} }
return err == nil, err return err == nil, err
} }
@@ -589,6 +594,9 @@ func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, ra
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string { func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
domain = normalizeDomain(domain) domain = normalizeDomain(domain)
if address := normalizeEmail(msg.RecipientAddr); strings.HasSuffix(address, "@"+domain) {
return address
}
for _, address := range append(append([]string{}, msg.To...), msg.CC...) { for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
address = normalizeEmail(address) address = normalizeEmail(address)
if strings.HasSuffix(address, "@"+domain) { if strings.HasSuffix(address, "@"+domain) {
@@ -613,11 +621,18 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
if len(to) == 0 { if len(to) == 0 {
to = []string{fallbackTo} to = []string{fallbackTo}
} }
recipientAddr := originalMailRecipient(m.Header)
sentAt := parseMailDate(m.Header.Get("Date")) sentAt := parseMailDate(m.Header.Get("Date"))
parsed := &parsedMail{} parsed := &parsedMail{}
if err := parseMailPart(textproto.MIMEHeader(m.Header), m.Body, parsed); err != nil { if err := parseMailPart(textproto.MIMEHeader(m.Header), m.Body, parsed); err != nil {
return storedMessage{}, nil, err 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) bodyHTML := a.policy.Sanitize(parsed.HTML)
bodyText := parsed.Text bodyText := parsed.Text
if strings.TrimSpace(bodyText) == "" { if strings.TrimSpace(bodyText) == "" {
@@ -633,6 +648,7 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
return storedMessage{ return storedMessage{
MessageUID: newID("uid"), MessageUID: newID("uid"),
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")), MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
RecipientAddr: recipientAddr,
Subject: subject, Subject: subject,
From: from, From: from,
FromName: fromName, FromName: fromName,
@@ -648,6 +664,21 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
}, parsed.Attachments, nil }, 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 { func parseMailPart(header textproto.MIMEHeader, body io.Reader, parsed *parsedMail) error {
contentType := header.Get("Content-Type") contentType := header.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType) 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)}) parsed.Attachments = append(parsed.Attachments, AttachmentInput{Filename: filename, ContentType: mediaType, ContentBase64: base64.StdEncoding.EncodeToString(decoded)})
return nil 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) { switch strings.ToLower(mediaType) {
case "text/html": case "text/html":
if parsed.HTML == "" { if parsed.HTML == "" {
+1
View File
@@ -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(PermissionSettingsView)).Get("/admin/maildir-sync/health", a.handleMaildirSyncHealth)
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings) r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP) r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/pair", a.handleCreateTelegramPairing)
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/discover", a.handleDiscoverTelegramChat) 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(PermissionSettingsUpdate)).Post("/admin/settings/telegram/test", a.handleTestTelegram)
r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates) r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
+29 -2
View File
@@ -44,6 +44,8 @@ type SystemSettings struct {
TelegramBotTokenSet bool `json:"telegramBotTokenSet"` TelegramBotTokenSet bool `json:"telegramBotTokenSet"`
TelegramPrivateChatID string `json:"telegramPrivateChatId"` TelegramPrivateChatID string `json:"telegramPrivateChatId"`
TelegramBodyMode string `json:"telegramBodyMode"` TelegramBodyMode string `json:"telegramBodyMode"`
TelegramMailboxIDs []string `json:"telegramMailboxIds"`
TelegramIncludeUnregistered bool `json:"telegramIncludeUnregistered"`
} }
type systemSettingsUpdate struct { type systemSettingsUpdate struct {
@@ -81,6 +83,8 @@ type systemSettingsUpdate struct {
TelegramBotToken string `json:"telegramBotToken"` TelegramBotToken string `json:"telegramBotToken"`
TelegramPrivateChatID string `json:"telegramPrivateChatId"` TelegramPrivateChatID string `json:"telegramPrivateChatId"`
TelegramBodyMode string `json:"telegramBodyMode"` TelegramBodyMode string `json:"telegramBodyMode"`
TelegramMailboxIDs []string `json:"telegramMailboxIds"`
TelegramIncludeUnregistered bool `json:"telegramIncludeUnregistered"`
} }
type PublicSettings struct { 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) { func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
a.telegramDeliveryMu.Lock()
defer a.telegramDeliveryMu.Unlock()
var req systemSettingsUpdate var req systemSettingsUpdate
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
badRequest(w, err) badRequest(w, err)
@@ -218,6 +224,8 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
} }
next.TelegramPrivateChatID = strings.TrimSpace(req.TelegramPrivateChatID) next.TelegramPrivateChatID = strings.TrimSpace(req.TelegramPrivateChatID)
next.TelegramBodyMode = normalizeTelegramBodyMode(req.TelegramBodyMode) next.TelegramBodyMode = normalizeTelegramBodyMode(req.TelegramBodyMode)
next.TelegramMailboxIDs = strings.Join(a.activeTelegramMailboxIDs(r.Context(), req.TelegramMailboxIDs), ",")
next.TelegramIncludeUnregistered = req.TelegramIncludeUnregistered
if next.TelegramMailEnabled { if next.TelegramMailEnabled {
if next.TelegramBotToken == "" { if next.TelegramBotToken == "" {
badRequest(w, errors.New("Telegram Bot Token 未设置")) 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 无效")) badRequest(w, errors.New("Telegram 私聊 Chat ID 无效"))
return 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") respondError(w, http.StatusInternalServerError, "failed to save settings")
return return
} }
@@ -346,6 +360,8 @@ func (a *App) systemSettingsSnapshot() SystemSettings {
TelegramBotTokenSet: strings.TrimSpace(cfg.TelegramBotToken) != "", TelegramBotTokenSet: strings.TrimSpace(cfg.TelegramBotToken) != "",
TelegramPrivateChatID: cfg.TelegramPrivateChatID, TelegramPrivateChatID: cfg.TelegramPrivateChatID,
TelegramBodyMode: normalizeTelegramBodyMode(cfg.TelegramBodyMode), 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 cfg.TelegramPrivateChatID = value
case "telegramBodyMode": case "telegramBodyMode":
cfg.TelegramBodyMode = normalizeTelegramBodyMode(value) 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 { if err := rows.Err(); err != nil {
@@ -447,7 +467,7 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
return nil 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{ values := map[string]string{
"publicHostname": cfg.PublicHostname, "publicHostname": cfg.PublicHostname,
"publicBaseUrl": cfg.PublicBaseURL, "publicBaseUrl": cfg.PublicBaseURL,
@@ -483,6 +503,8 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
"telegramBotToken": cfg.TelegramBotToken, "telegramBotToken": cfg.TelegramBotToken,
"telegramPrivateChatId": cfg.TelegramPrivateChatID, "telegramPrivateChatId": cfg.TelegramPrivateChatID,
"telegramBodyMode": normalizeTelegramBodyMode(cfg.TelegramBodyMode), "telegramBodyMode": normalizeTelegramBodyMode(cfg.TelegramBodyMode),
"telegramMailboxIds": strings.Join(cleanIDList(strings.Split(cfg.TelegramMailboxIDs, ",")), ","),
"telegramIncludeUnregistered": strconv.FormatBool(cfg.TelegramIncludeUnregistered),
} }
now := a.now().UTC().Format(time.RFC3339Nano) now := a.now().UTC().Format(time.RFC3339Nano)
tx, err := a.db.BeginTx(ctx, nil) tx, err := a.db.BeginTx(ctx, nil)
@@ -496,6 +518,11 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
return err 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() return tx.Commit()
} }
+578 -41
View File
@@ -3,6 +3,9 @@ package app
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -10,12 +13,27 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "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 { type telegramMailPayload struct {
From string `json:"from"` From string `json:"from"`
@@ -25,23 +43,31 @@ type telegramMailPayload struct {
ReceivedAt string `json:"receivedAt"` ReceivedAt string `json:"receivedAt"`
Body string `json:"body"` Body string `json:"body"`
BodyMode string `json:"bodyMode"` BodyMode string `json:"bodyMode"`
OTP string `json:"otp,omitempty"`
AttachmentNames []string `json:"attachmentNames,omitempty"` AttachmentNames []string `json:"attachmentNames,omitempty"`
AttachmentCount int `json:"attachmentCount,omitempty"`
} }
type telegramCredentialsRequest struct { type telegramCredentialsRequest struct {
BotToken string `json:"botToken"` BotToken string `json:"botToken"`
ChatID string `json:"chatId"` ChatID string `json:"chatId"`
PairingCode string `json:"pairingCode"`
} }
type telegramAPIResponse struct { type telegramAPIResponse struct {
OK bool `json:"ok"` OK bool `json:"ok"`
ErrorCode int `json:"error_code"`
Description string `json:"description"` Description string `json:"description"`
Result json.RawMessage `json:"result"` Result json.RawMessage `json:"result"`
Parameters struct {
RetryAfter int `json:"retry_after"`
} `json:"parameters"`
} }
type telegramUpdate struct { type telegramUpdate struct {
UpdateID int64 `json:"update_id"` UpdateID int64 `json:"update_id"`
Message *struct { Message *struct {
Text string `json:"text"`
Chat struct { Chat struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Type string `json:"type"` Type string `json:"type"`
@@ -52,6 +78,31 @@ type telegramUpdate struct {
} `json:"message"` } `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 { func normalizeTelegramBodyMode(value string) string {
if strings.EqualFold(strings.TrimSpace(value), "full") { if strings.EqualFold(strings.TrimSpace(value), "full") {
return "full" return "full"
@@ -64,7 +115,7 @@ func validTelegramPrivateChatID(value string) bool {
return err == nil && id > 0 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 var req telegramCredentialsRequest
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
badRequest(w, err) badRequest(w, err)
@@ -78,11 +129,69 @@ func (a *App) handleDiscoverTelegramChat(w http.ResponseWriter, r *http.Request)
badRequest(w, errors.New("请先填写 Telegram Bot Token")) badRequest(w, errors.New("请先填写 Telegram Bot Token"))
return 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 { if err != nil {
respondError(w, http.StatusBadGateway, err.Error()) respondError(w, http.StatusBadGateway, err.Error())
return return
} }
a.telegramPairMu.Lock()
delete(a.telegramPairs, code)
a.telegramPairMu.Unlock()
respondJSON(w, http.StatusOK, map[string]string{"chatId": chatID, "displayName": displayName}) 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 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 var updates []telegramUpdate
if err := a.callTelegram(ctx, token, "getUpdates", map[string]any{ if err := a.callTelegram(ctx, token, "getUpdates", map[string]any{
"limit": 100, "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 { if message == nil || message.Chat.Type != "private" || message.Chat.ID <= 0 {
continue 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}, " ")) name := strings.TrimSpace(strings.Join([]string{message.Chat.FirstName, message.Chat.LastName}, " "))
if name == "" && message.Chat.Username != "" { if name == "" && message.Chat.Username != "" {
name = "@" + message.Chat.Username name = "@" + message.Chat.Username
} }
return strconv.FormatInt(message.Chat.ID, 10), name, nil 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) { func (a *App) enqueueTelegramMailNotification(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) {
cfg := a.config() 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 return
} }
recipient := normalizeEmail(msg.RecipientAddr) recipient := normalizeEmail(msg.RecipientAddr)
if recipient == "" && len(msg.To) > 0 { if recipient == "" && len(msg.To) > 0 {
recipient = normalizeEmail(msg.To[0]) recipient = normalizeEmail(msg.To[0])
} }
body := strings.TrimSpace(msg.BodyText) body := telegramMessageBody(msg)
if body == "" { otp := detectTelegramOTP(msg.Subject, body)
body = strings.TrimSpace(stripTags(msg.BodyHTML))
}
mode := normalizeTelegramBodyMode(cfg.TelegramBodyMode) mode := normalizeTelegramBodyMode(cfg.TelegramBodyMode)
limit := 800 limit := 800
if mode == "full" { if mode == "full" {
limit = 2600 limit = 2600
} }
body, truncated := truncateRunes(strings.Join(strings.Fields(body), " "), limit) body, truncated := truncateRunes(body, limit)
if truncated { if truncated {
body += "..." body += "..."
} }
if 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 { for _, attachment := range attachments {
name := strings.TrimSpace(attachment.Filename) name := sanitizeTelegramAttachmentName(attachment.Filename)
if name != "" { if name != "" {
names = append(names, name) names = append(names, name)
} }
if len(names) >= 10 { if len(names) >= 5 {
break break
} }
} }
payload := telegramMailPayload{ payload := telegramMailPayload{
From: msg.From, From: from,
FromName: msg.FromName, FromName: fromName,
Recipient: recipient, Recipient: recipient,
Subject: msg.Subject, Subject: subject,
ReceivedAt: msg.ReceivedAt.Format(time.RFC3339Nano), ReceivedAt: a.now().UTC().Format(time.RFC3339Nano),
Body: body, Body: body,
BodyMode: mode, BodyMode: mode,
OTP: otp,
AttachmentNames: names, AttachmentNames: names,
AttachmentCount: len(attachments),
} }
now := a.now().UTC().Format(time.RFC3339Nano) 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 { 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, "<!doctype html") || strings.HasPrefix(value, "<html") || strings.HasPrefix(value, "<head") || strings.HasPrefix(value, "<body") || strings.HasPrefix(value, "<style") {
return true
}
matches := telegramHTMLTagRe.FindAllStringIndex(value, 4)
return len(matches) >= 3
}
func telegramHTMLToText(value string) string {
value = strings.ToValidUTF8(value, "")
value, _ = truncateRunes(value, 128*1024)
doc, err := nethtml.Parse(strings.NewReader(value))
if err != nil {
return stripTags(value)
}
var out strings.Builder
var walk func(*nethtml.Node, bool)
walk = func(node *nethtml.Node, skipped bool) {
if node.Type == nethtml.ElementNode {
switch strings.ToLower(node.Data) {
case "script", "style", "head", "noscript", "svg":
skipped = true
case "br":
if !skipped {
out.WriteByte('\n')
}
}
}
if node.Type == nethtml.TextNode && !skipped {
out.WriteString(node.Data)
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
walk(child, skipped)
}
if node.Type == nethtml.ElementNode && !skipped {
switch strings.ToLower(node.Data) {
case "p", "div", "li", "tr", "table", "section", "article", "header", "footer", "h1", "h2", "h3", "h4", "h5", "h6":
out.WriteByte('\n')
}
}
}
walk(doc, false)
return normalizeTelegramText(out.String())
}
func normalizeTelegramText(value string) string {
value = strings.ReplaceAll(strings.ToValidUTF8(value, ""), "\r\n", "\n")
value = strings.ReplaceAll(value, "\r", "\n")
lines := strings.Split(value, "\n")
out := make([]string, 0, len(lines))
empty := false
for _, line := range lines {
line = strings.TrimSpace(strings.Map(func(r rune) rune {
if r == '\t' {
return ' '
}
if unicode.IsControl(r) {
return -1
}
return r
}, line))
line = strings.Join(strings.Fields(line), " ")
if line == "" {
if !empty && len(out) > 0 {
out = append(out, "")
}
empty = true
continue
}
empty = false
out = append(out, line)
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
var telegramQuoteBoundaryRe = regexp.MustCompile(`(?i)^(?:-{2,}\s*(?:original message|原始邮件)\s*-*|on .+ wrote:|发件人[:]|from[:].+|_{5,})$`)
var telegramHTMLTagRe = regexp.MustCompile(`(?i)</?(?:div|p|table|tr|td|br|span|a|img)(?:\s[^>]*)?>`)
func stripTelegramQuotedContent(value string) string {
lines := strings.Split(value, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if i > 0 && (trimmed == "--" || telegramQuoteBoundaryRe.MatchString(trimmed)) {
lines = lines[:i]
break
}
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func sanitizeTelegramAttachmentName(value string) string {
value = strings.TrimSpace(strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, strings.ToValidUTF8(value, "")))
value = strings.Join(strings.Fields(value), " ")
value, truncated := truncateRunes(value, 100)
if truncated {
value += "..."
}
return value
}
var (
telegramOTPKeywordRe = regexp.MustCompile(`(?i)(验证码|校验码|动态码|登录码|安全码|一次性密码|otp|verification[ -]?code|security[ -]?code|login[ -]?code|passcode|one[ -]?time[ -]?(?:password|code))`)
telegramOTPCandidateRe = regexp.MustCompile(`(?i)[a-z0-9]{4,10}`)
)
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) { func (a *App) telegramMailWorker(ctx context.Context) {
a.log.Info("Telegram mail notification worker started") a.log.Info("Telegram mail notification worker started")
ticker := time.NewTicker(5 * time.Second) 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 { 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() cfg := a.config()
if !cfg.TelegramMailEnabled || strings.TrimSpace(cfg.TelegramBotToken) == "" || !validTelegramPrivateChatID(cfg.TelegramPrivateChatID) { if !cfg.TelegramMailEnabled || strings.TrimSpace(cfg.TelegramBotToken) == "" || !validTelegramPrivateChatID(cfg.TelegramPrivateChatID) {
return nil 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) 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<=? ORDER BY next_attempt_at,created_at LIMIT 20`, telegramMailMaxAttempts, 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 { if err != nil {
return err return err
} }
@@ -228,6 +608,7 @@ func (a *App) processDueTelegramMailNotifications(ctx context.Context) error {
id string id string
payload telegramMailPayload payload telegramMailPayload
attempt int attempt int
invalid bool
} }
items := []queueItem{} items := []queueItem{}
for rows.Next() { for rows.Next() {
@@ -238,8 +619,7 @@ func (a *App) processDueTelegramMailNotifications(ctx context.Context) error {
return err return err
} }
if err := json.Unmarshal([]byte(raw), &item.payload); err != nil { if err := json.Unmarshal([]byte(raw), &item.payload); err != nil {
rows.Close() item.invalid = true
return err
} }
items = append(items, item) items = append(items, item)
} }
@@ -247,20 +627,56 @@ func (a *App) processDueTelegramMailNotifications(ctx context.Context) error {
return err return err
} }
for _, item := range items { 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() 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 { if err != nil {
next := now.Add(sendRetryDelay(item.attempt + 1)) return err
_, _ = 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) }
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 continue
} }
stamp := now.Format(time.RFC3339Nano) 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 return nil
} }
func formatTelegramMailMessage(payload telegramMailPayload) string { func formatTelegramMailMessage(payload telegramMailPayload) telegramFormattedMessage {
subject := strings.TrimSpace(payload.Subject) subject := strings.TrimSpace(payload.Subject)
if subject == "" || subject == "(no subject)" { if subject == "" || subject == "(no subject)" {
subject = "(无主题)" subject = "(无主题)"
@@ -274,20 +690,30 @@ func formatTelegramMailMessage(payload telegramMailPayload) string {
if !receivedAt.IsZero() { if !receivedAt.IsZero() {
timeText = receivedAt.Local().Format("2006-01-02 15:04:05 MST") 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{ lines := []string{
"<b>收到新邮件</b>", "📩 <b>新邮件通知</b>",
"", "",
"<b>发件人</b>" + html.EscapeString(from), "<b>主题</b>" + escapeTelegramWithinBudget(subject, 420),
"<b>收件邮箱</b>" + html.EscapeString(payload.Recipient), "<b>发件人</b>" + escapeTelegramWithinBudget(from, 500),
"<b>主题:</b>" + html.EscapeString(subject), "<b>收件邮箱:</b><code>" + escapeTelegramWithinBudget(recipient, 320) + "</code>",
"<b>收件时间:</b>" + html.EscapeString(timeText), "<b>收件时间:</b>" + html.EscapeString(timeText),
} }
if payload.OTP != "" {
lines = append(lines, "", "🔐 <b>验证码</b>", "<code>"+html.EscapeString(payload.OTP)+"</code>")
}
if len(payload.AttachmentNames) > 0 { if len(payload.AttachmentNames) > 0 {
names := make([]string, 0, len(payload.AttachmentNames)) names := make([]string, 0, len(payload.AttachmentNames))
for _, name := range payload.AttachmentNames { for _, name := range payload.AttachmentNames {
names = append(names, html.EscapeString(name)) names = append(names, escapeTelegramWithinBudget(name, 180))
} }
lines = append(lines, "<b>附件:</b>"+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("📎 <b>附件:%d 个</b>", max(payload.AttachmentCount, len(payload.AttachmentNames))), attachmentText)
} }
body := strings.TrimSpace(payload.Body) body := strings.TrimSpace(payload.Body)
if body != "" { if body != "" {
@@ -295,18 +721,129 @@ func formatTelegramMailMessage(payload telegramMailPayload) string {
if normalizeTelegramBodyMode(payload.BodyMode) == "full" { if normalizeTelegramBodyMode(payload.BodyMode) == "full" {
label = "邮件正文" label = "邮件正文"
} }
lines = append(lines, "", "<b>"+label+"</b>", "<blockquote>"+html.EscapeString(body)+"</blockquote>") prefix := strings.Join(lines, "\n") + "\n\n<b>" + label + "</b>\n<blockquote>"
suffix := "</blockquote>"
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 { 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, "chat_id": chatID,
"text": text, "text": text,
"parse_mode": "HTML", "parse_mode": "HTML",
"disable_web_page_preview": true, "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 { 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 == "" { if description == "" {
description = fmt.Sprintf("HTTP %d", resp.StatusCode) 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 result != nil && len(apiResponse.Result) > 0 {
if err := json.Unmarshal(apiResponse.Result, result); err != nil { if err := json.Unmarshal(apiResponse.Result, result); err != nil {
+232 -8
View File
@@ -3,24 +3,36 @@ package app
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time" "time"
"unicode/utf8"
"golang.org/x/text/encoding/simplifiedchinese"
) )
func TestTelegramSettingsDiscoveryTestAndMailQueue(t *testing.T) { func TestTelegramSettingsDiscoveryTestAndMailQueue(t *testing.T) {
type sentMessage struct { type sentMessage struct {
ChatID string `json:"chat_id"` ChatID string `json:"chat_id"`
Text string `json:"text"` Text string `json:"text"`
ReplyMarkup map[string]any `json:"reply_markup"`
} }
var sent []sentMessage var sent []sentMessage
var pairingCode atomic.Value
pairingCode.Store("")
telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
switch r.URL.Path { 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": 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": case "/bottest-token/sendMessage":
var message sentMessage var message sentMessage
if err := json.NewDecoder(r.Body).Decode(&message); err != nil { if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
@@ -54,6 +66,11 @@ func TestTelegramSettingsDiscoveryTestAndMailQueue(t *testing.T) {
payload["telegramBotToken"] = "test-token" payload["telegramBotToken"] = "test-token"
payload["telegramPrivateChatId"] = "123456789" payload["telegramPrivateChatId"] = "123456789"
payload["telegramBodyMode"] = "full" 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 { if code := admin.do("POST", "/api/admin/settings", payload, &settings); code != http.StatusOK {
t.Fatalf("save Telegram settings code=%d settings=%+v", code, settings) 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") 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 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) t.Fatalf("discover chat code=%d response=%v", code, discovered)
} }
if discovered["chatId"] != "123456789" || discovered["displayName"] != "Zhenxi Shen" { if discovered["chatId"] != "123456789" || discovered["displayName"] != "Zhenxi Shen" {
@@ -82,12 +107,13 @@ func TestTelegramSettingsDiscoveryTestAndMailQueue(t *testing.T) {
sent = nil sent = nil
receivedAt := time.Date(2026, 8, 6, 9, 30, 0, 0, time.UTC) receivedAt := time.Date(2026, 8, 6, 9, 30, 0, 0, time.UTC)
a.enqueueTelegramMailNotification(context.Background(), "mail_test_telegram", storedMessage{ a.enqueueTelegramMailNotification(context.Background(), "mail_test_telegram", storedMessage{
MailboxID: adminMailboxID,
RecipientAddr: "admin@example.com", RecipientAddr: "admin@example.com",
Subject: "账单 <已生成>", Subject: "账单 <已生成>",
From: "billing@example.net", From: "billing@example.net",
FromName: "Billing & Support", FromName: "Billing & Support",
ReceivedAt: receivedAt, ReceivedAt: receivedAt,
BodyText: "这是邮件正文,包含 <VIP> & 续费信息。", BodyText: "这是邮件正文,验证码是 846981包含 <VIP> & 续费信息。",
}, []AttachmentInput{{Filename: "账单-2026.pdf"}}) }, []AttachmentInput{{Filename: "账单-2026.pdf"}})
if err := a.processDueTelegramMailNotifications(context.Background()); err != nil { if err := a.processDueTelegramMailNotifications(context.Background()); err != nil {
t.Fatalf("process Telegram mail queue: %v", err) 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)) t.Fatalf("expected one queued Telegram message, got %d", len(sent))
} }
text := sent[0].Text text := sent[0].Text
for _, expected := range []string{"收到新邮件", "Billing &amp; Support", "账单 &lt;已生成&gt;", "admin@example.com", "邮件正文", "账单-2026.pdf", "&lt;VIP&gt; &amp; 续费信息"} { for _, expected := range []string{"新邮件通知", "Billing &amp; Support", "账单 &lt;已生成&gt;", "admin@example.com", "邮件正文", "账单-2026.pdf", "846981", "&lt;VIP&gt; &amp; 续费信息"} {
if !strings.Contains(text, expected) { if !strings.Contains(text, expected) {
t.Fatalf("Telegram mail message missing %q: %s", expected, text) t.Fatalf("Telegram mail message missing %q: %s", expected, text)
} }
} }
var delivered string if sent[0].ReplyMarkup == nil {
if err := a.db.QueryRow(`SELECT COALESCE(delivered_at,'') FROM telegram_mail_outbox WHERE message_id=?`, "mail_test_telegram").Scan(&delivered); err != nil || delivered == "" { t.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) 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) { func TestTelegramSettingsRejectEnabledWithoutCredentials(t *testing.T) {
@@ -145,3 +189,183 @@ func TestTelegramNetworkErrorDoesNotExposeToken(t *testing.T) {
t.Fatalf("Telegram error exposed Bot Token: %v", err) t.Fatalf("Telegram error exposed Bot Token: %v", err)
} }
} }
func TestTelegramOTPDetectionAndMessageBudget(t *testing.T) {
body := "本次登录验证码为 846981,请在十分钟内完成验证。\n\nOn yesterday wrote:\n旧验证码是 112233"
cleaned := stripTelegramQuotedContent(body)
if otp := detectTelegramOTP("登录验证", cleaned); otp != "846981" {
t.Fatalf("unexpected OTP %q", otp)
}
if otp := detectTelegramOTP("验证码", "验证码可能是 123456 或 654321,请联系客服确认"); otp != "" {
t.Fatalf("ambiguous OTP should not be selected: %q", otp)
}
message := formatTelegramMailMessage(telegramMailPayload{
From: strings.Repeat("R&D <team@example.com> ", 30),
Recipient: "admin@example.com",
Subject: strings.Repeat("超长主题 & <test> ", 50),
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano),
Body: strings.Repeat("正文内容 & <重要> ", 1000),
BodyMode: "full",
OTP: "846981",
AttachmentNames: []string{
strings.Repeat("附件&", 80), strings.Repeat("报价<", 80), strings.Repeat("说明", 80),
},
AttachmentCount: 12,
})
if got := utf8.RuneCountInString(message.HTML); got > telegramMessageBudget {
t.Fatalf("Telegram HTML exceeds budget: %d", got)
}
if !strings.Contains(message.HTML, "&amp;") || !strings.Contains(message.HTML, "&lt;") || !strings.Contains(message.HTML, "<code>846981</code>") {
t.Fatalf("message escaping or OTP formatting missing: %s", message.HTML)
}
if markup := telegramCopyMarkup(message.OTP); markup == nil {
t.Fatal("copy_text markup missing")
}
}
func TestTelegramPseudoHTMLAndBodyCharset(t *testing.T) {
pseudo := `<html><head><style>.hidden{display:none}</style></head><body><p>验证码:778899</p><div>欢迎登录</div></body></html>`
text := telegramMessageBody(storedMessage{BodyText: pseudo})
if strings.Contains(text, "display:none") || strings.Contains(text, "<p>") || !strings.Contains(text, "778899") {
t.Fatalf("pseudo HTML was not cleaned: %q", text)
}
encoded, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("您的验证码是 445566"))
if err != nil {
t.Fatal(err)
}
raw := append([]byte("From: sender@example.com\r\nTo: admin@example.com\r\nSubject: GBK\r\nContent-Type: text/plain; charset=gbk\r\n\r\n"), encoded...)
a := newTestApp(t)
stopTestWorkers(a)
msg, _, err := a.parseMaildirMessage(raw, "admin@example.com")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(msg.BodyText, "445566") || !strings.Contains(msg.BodyText, "验证码") {
t.Fatalf("GBK body was not decoded: %q", msg.BodyText)
}
}
func TestTelegramRetryAfterAndPermanentErrors(t *testing.T) {
tests := []struct {
name string
status int
response string
retryAfter time.Duration
}{
{name: "rate limit", status: http.StatusTooManyRequests, response: `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":17}}`, retryAfter: 17 * time.Second},
{name: "unauthorized", status: http.StatusUnauthorized, response: `{"ok":false,"error_code":401,"description":"Unauthorized"}`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.response))
}))
defer server.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = server.URL
err := a.sendTelegramMessage(context.Background(), "test-token", "123456", "test")
var apiErr *telegramAPIError
if !errors.As(err, &apiErr) || apiErr.ErrorCode != tc.status || apiErr.RetryAfter != tc.retryAfter {
t.Fatalf("unexpected Telegram error: %#v", err)
}
})
}
}
func TestTelegramMailboxScopeAndOriginalRecipient(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
var mailboxID string
if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&mailboxID); err != nil {
t.Fatal(err)
}
a.updateConfig(func(cfg *Config) {
cfg.TelegramMailEnabled = true
cfg.TelegramBotToken = "test-token"
cfg.TelegramPrivateChatID = "123456"
cfg.TelegramMailboxIDs = mailboxID
})
a.enqueueTelegramMailNotification(context.Background(), "scope-denied", storedMessage{MailboxID: "another-mailbox", RecipientAddr: "other@example.com", Subject: "denied"}, nil)
a.enqueueTelegramMailNotification(context.Background(), "scope-allowed", storedMessage{MailboxID: mailboxID, RecipientAddr: "admin@lanqin.local", Subject: "allowed"}, nil)
var count int
if err := a.db.QueryRow(`SELECT COUNT(1) FROM telegram_mail_outbox`).Scan(&count); err != nil || count != 1 {
t.Fatalf("unexpected scoped queue count=%d err=%v", count, err)
}
raw := []byte("From: sender@example.com\r\nTo: hidden-list@example.net\r\nDelivered-To: admin@lanqin.local\r\nSubject: recipient\r\n\r\nbody")
msg, _, err := a.parseMaildirMessage(raw, "admin@lanqin.local")
if err != nil {
t.Fatal(err)
}
if msg.RecipientAddr != "admin@lanqin.local" {
t.Fatalf("wrong original recipient: %q", msg.RecipientAddr)
}
}
func TestTelegramBadRequestFallsBackToPlainText(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if calls.Add(1) == 1 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"Bad Request: can't parse entities"}`))
return
}
if _, exists := payload["parse_mode"]; exists {
t.Fatal("plain-text fallback still included parse_mode")
}
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":99}}`))
}))
defer server.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = server.URL
messageID, err := a.deliverTelegramMailMessage(context.Background(), "test-token", "123456", telegramFormattedMessage{HTML: "<b>broken", PlainText: "safe fallback", OTP: "123456"})
if err != nil || messageID != 99 || calls.Load() != 2 {
t.Fatalf("fallback failed: messageId=%d calls=%d err=%v", messageID, calls.Load(), err)
}
}
func TestTelegramMalformedQueueItemDoesNotBlockLaterMail(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":7}}`))
}))
defer server.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = server.URL
a.updateConfig(func(cfg *Config) {
cfg.TelegramMailEnabled = true
cfg.TelegramBotToken = "test-token"
cfg.TelegramPrivateChatID = "123456"
})
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.Exec(`INSERT INTO telegram_mail_outbox(id,message_id,payload_json,next_attempt_at,created_at,updated_at) VALUES('bad','bad','{',?,?,?),('good','good',?, ?, ?, ?)`, now, now, now, jsonEncode(telegramMailPayload{Subject: "good", From: "sender@example.com", Recipient: "admin@example.com", ReceivedAt: now, Body: "body"}), now, now, now); err != nil {
t.Fatal(err)
}
if err := a.processDueTelegramMailNotifications(context.Background()); err != nil {
t.Fatal(err)
}
var badAttempts int
var delivered string
if err := a.db.QueryRow(`SELECT attempt_count FROM telegram_mail_outbox WHERE id='bad'`).Scan(&badAttempts); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRow(`SELECT COALESCE(delivered_at,'') FROM telegram_mail_outbox WHERE id='good'`).Scan(&delivered); err != nil {
t.Fatal(err)
}
if badAttempts != telegramMailMaxAttempts || delivered == "" || calls.Load() != 1 {
t.Fatalf("malformed queue handling failed: attempts=%d delivered=%q calls=%d", badAttempts, delivered, calls.Load())
}
}
+3
View File
@@ -237,9 +237,12 @@ export type SystemSettings = {
telegramBotTokenSet: boolean telegramBotTokenSet: boolean
telegramPrivateChatId: string telegramPrivateChatId: string
telegramBodyMode: "summary" | "full" telegramBodyMode: "summary" | "full"
telegramMailboxIds: string[]
telegramIncludeUnregistered: boolean
} }
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet" | "telegramBotTokenSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string; telegramBotToken: string } export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet" | "telegramBotTokenSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string; telegramBotToken: string }
export type TelegramPrivateChat = { chatId: string; displayName: string } export type 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 PublicDomain = { id: string; name: string }
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] } export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] }
export type LoginPayload = { loginName?: string; email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string } export type LoginPayload = { loginName?: string; email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
+3 -2
View File
@@ -1,4 +1,4 @@
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult, 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" export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000 const REQUEST_TIMEOUT_MS = 15_000
@@ -197,7 +197,8 @@ export const api = {
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"), maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }), updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
testSmtp: (to: string) => request<{ ok: boolean }>("/api/admin/settings/test-smtp", { method: "POST", body: JSON.stringify({ to }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), testSmtp: (to: string) => request<{ ok: boolean }>("/api/admin/settings/test-smtp", { method: "POST", body: JSON.stringify({ to }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
discoverTelegramChat: (botToken: string) => request<TelegramPrivateChat>("/api/admin/settings/telegram/discover", { method: "POST", body: JSON.stringify({ botToken }) }), createTelegramPairing: (botToken: string) => request<TelegramPairing>("/api/admin/settings/telegram/pair", { method: "POST", body: JSON.stringify({ botToken }) }),
discoverTelegramChat: (botToken: string, pairingCode: string) => request<TelegramPrivateChat>("/api/admin/settings/telegram/discover", { method: "POST", body: JSON.stringify({ botToken, pairingCode }) }),
testTelegram: (botToken: string, chatId: string) => request<{ ok: boolean }>("/api/admin/settings/telegram/test", { method: "POST", body: JSON.stringify({ botToken, chatId }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), testTelegram: (botToken: string, chatId: string) => request<{ ok: boolean }>("/api/admin/settings/telegram/test", { method: "POST", body: JSON.stringify({ botToken, chatId }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
mailTemplates: () => request<ListResponse<MailTemplate>>("/api/admin/mail-templates"), mailTemplates: () => request<ListResponse<MailTemplate>>("/api/admin/mail-templates"),
updateMailTemplate: (key: string, payload: { subject: string; bodyText: string; bodyHtml: string }) => request<MailTemplate>(`/api/admin/mail-templates/${encodeURIComponent(key)}`, { method: "POST", body: JSON.stringify(payload) }), updateMailTemplate: (key: string, payload: { subject: string; bodyText: string; bodyHtml: string }) => request<MailTemplate>(`/api/admin/mail-templates/${encodeURIComponent(key)}`, { method: "POST", body: JSON.stringify(payload) }),
+62 -7
View File
@@ -24,7 +24,7 @@ import { SystemVersionDialog } from "@/components/system-version-dialog"
import { useMe } from "@/hooks/use-me" import { useMe } from "@/hooks/use-me"
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast"
import { hasAnyPermission, hasPermission } from "@/lib/permissions" 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 Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings"
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security" | "about" 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 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 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 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 aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases, enabled: !!user && canAliasesView })
const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings, enabled: !!user && canSettingsView }) const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings, enabled: !!user && canSettingsView })
const [params, setParams] = useSearchParams() const [params, setParams] = useSearchParams()
@@ -141,7 +141,7 @@ export function AdminPage() {
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />} {section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} systemAdmin={user?.role === "admin"} />} {section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} systemAdmin={user?.role === "admin"} />}
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />} {section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} initialTab={params.get("settingsTab")} />} {section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} mailboxes={mailboxItems} initialTab={params.get("settingsTab")} />}
</main> </main>
</ScrollArea> </ScrollArea>
) )
@@ -1029,7 +1029,7 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
) )
} }
function SystemSettingsSection({ settings, domains, initialTab }: { settings?: SystemSettings; domains: Domain[]; initialTab?: string | null }) { function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { settings?: SystemSettings; domains: Domain[]; mailboxes: MailboxType[]; initialTab?: string | null }) {
const me = useMe() const me = useMe()
const user = me.data?.user const user = me.data?.user
const qc = useQueryClient() const qc = useQueryClient()
@@ -1059,6 +1059,9 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
const [telegramBotToken, setTelegramBotToken] = React.useState("") const [telegramBotToken, setTelegramBotToken] = React.useState("")
const [telegramPrivateChatId, setTelegramPrivateChatId] = React.useState("") const [telegramPrivateChatId, setTelegramPrivateChatId] = React.useState("")
const [telegramBodyMode, setTelegramBodyMode] = React.useState<"summary" | "full">("summary") const [telegramBodyMode, setTelegramBodyMode] = React.useState<"summary" | "full">("summary")
const [telegramMailboxIds, setTelegramMailboxIds] = React.useState<string[]>([])
const [telegramIncludeUnregistered, setTelegramIncludeUnregistered] = React.useState(false)
const [telegramPairing, setTelegramPairing] = React.useState<TelegramPairing | null>(null)
React.useEffect(() => { React.useEffect(() => {
if (!settings) return if (!settings) return
setSmtpRequireTls(settings.smtpRequireTls) setSmtpRequireTls(settings.smtpRequireTls)
@@ -1076,11 +1079,23 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
setTelegramBotToken("") setTelegramBotToken("")
setTelegramPrivateChatId(settings.telegramPrivateChatId || "") setTelegramPrivateChatId(settings.telegramPrivateChatId || "")
setTelegramBodyMode(settings.telegramBodyMode === "full" ? "full" : "summary") setTelegramBodyMode(settings.telegramBodyMode === "full" ? "full" : "summary")
setTelegramMailboxIds(settings.telegramMailboxIds || [])
setTelegramIncludeUnregistered(settings.telegramIncludeUnregistered)
setTelegramPairing(null)
}, [settings]) }, [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({ const discoverTelegram = useMutation({
mutationFn: () => api.discoverTelegramChat(telegramBotToken), mutationFn: () => api.discoverTelegramChat(telegramBotToken, telegramPairing?.code || ""),
onSuccess: (chat) => { onSuccess: (chat) => {
setTelegramPrivateChatId(chat.chatId) setTelegramPrivateChatId(chat.chatId)
setTelegramPairing(null)
toast({ title: "已获取 Telegram 私聊", description: chat.displayName || chat.chatId }) toast({ title: "已获取 Telegram 私聊", description: chat.displayName || chat.chatId })
}, },
onError: (error) => toast({ title: "获取失败", description: error.message }), onError: (error) => toast({ title: "获取失败", description: error.message }),
@@ -1126,6 +1141,8 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
telegramBotToken, telegramBotToken,
telegramPrivateChatId, telegramPrivateChatId,
telegramBodyMode, telegramBodyMode,
telegramMailboxIds,
telegramIncludeUnregistered,
}), }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", "settings"] }) qc.invalidateQueries({ queryKey: ["admin", "settings"] })
@@ -1171,6 +1188,8 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
settings.telegramBotTokenSet, settings.telegramBotTokenSet,
settings.telegramPrivateChatId, settings.telegramPrivateChatId,
settings.telegramBodyMode, settings.telegramBodyMode,
(settings.telegramMailboxIds || []).join(","),
settings.telegramIncludeUnregistered,
].join("|") : "loading" ].join("|") : "loading"
const tabs: { key: typeof settingsTab; label: string }[] = [ const tabs: { key: typeof settingsTab; label: string }[] = [
...(canSettingsView ? [ ...(canSettingsView ? [
@@ -1303,10 +1322,46 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
<Label> Chat ID</Label> <Label> Chat ID</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input inputMode="numeric" value={telegramPrivateChatId} onChange={(event) => setTelegramPrivateChatId(event.target.value)} placeholder="123456789" /> <Input inputMode="numeric" value={telegramPrivateChatId} onChange={(event) => setTelegramPrivateChatId(event.target.value)} placeholder="123456789" />
<Button type="button" variant="outline" className="shrink-0" disabled={discoverTelegram.isPending} onClick={() => discoverTelegram.mutate()}> <Button type="button" variant="outline" className="shrink-0" disabled={createTelegramPairing.isPending} onClick={() => createTelegramPairing.mutate()}>
<Search className="mr-2 h-4 w-4" />{discoverTelegram.isPending ? "获取中" : "自动获取"} <ShieldCheck className="mr-2 h-4 w-4" />{createTelegramPairing.isPending ? "生成中" : "安全绑定"}
</Button> </Button>
</div> </div>
{telegramPairing && (
<div className="space-y-3 border-l-2 border-primary/50 py-1 pl-3">
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 font-mono text-sm font-semibold">{telegramPairing.code}</code>
<Button type="button" variant="ghost" size="icon" title="复制绑定码" onClick={() => navigator.clipboard.writeText(telegramPairing.code)}>
<Copy className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button asChild type="button" variant="outline" size="sm">
<a href={telegramPairing.deepLink} target="_blank" rel="noreferrer"><ExternalLink className="mr-2 h-4 w-4" /></a>
</Button>
<Button type="button" size="sm" disabled={discoverTelegram.isPending} onClick={() => discoverTelegram.mutate()}>
<CheckCircle2 className="mr-2 h-4 w-4" />{discoverTelegram.isPending ? "绑定中" : "完成绑定"}
</Button>
</div>
</div>
)}
</div>
</div>
<div className="space-y-3 border-t pt-5">
<Label></Label>
<div className="grid gap-2 md:grid-cols-2">
{mailboxes.filter((mailbox) => mailbox.status === "active").map((mailbox) => (
<label key={mailbox.id} className="flex min-h-11 items-center gap-3 rounded-md border px-3 py-2">
<Checkbox
checked={telegramMailboxIds.includes(mailbox.id)}
onCheckedChange={(checked) => setTelegramMailboxIds((items) => checked === true ? Array.from(new Set([...items, mailbox.id])) : items.filter((id) => id !== mailbox.id))}
/>
<span className="min-w-0 truncate text-sm font-medium">{mailbox.address}</span>
</label>
))}
<label className="flex min-h-11 items-center gap-3 rounded-md border px-3 py-2">
<Checkbox checked={telegramIncludeUnregistered} onCheckedChange={(checked) => setTelegramIncludeUnregistered(checked === true)} />
<span className="text-sm font-medium"></span>
</label>
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
+9 -6
View File
@@ -64,13 +64,16 @@ DNS 生效通常需要几分钟到数小时。系统只能检测记录,不能
管理员可以把每封新收邮件的概要发送到自己的 Telegram 私聊: 管理员可以把每封新收邮件的概要发送到自己的 Telegram 私聊:
1. 使用 `@BotFather` 创建一个邮件通知机器人并取得 Bot Token。 1. 使用 `@BotFather` 创建一个邮件通知机器人并取得 Bot Token。
2. 在 Telegram 中打开机器人,发送 `/start` 2. 进入“管理后台 -> 系统设置 -> 通知”,填写 Bot Token
3. 进入“管理后台 -> 系统设置 -> 通知”。 3. 点击“安全绑定”生成一次性绑定码,再点击“打开机器人”。
4. 填写 Bot Token,点击“自动获取”取得私聊 Chat ID 4. 在机器人会话中发送页面生成的绑定码,然后点击“完成绑定”
5. 选择正文显示方式并点击“测试通知”。 5. 勾选需要通知的邮箱;需要接收未注册地址邮件时,另行勾选“未知收件”。
6. 测试成功后开启“私聊新邮件通知”,保存设置 6. 选择正文显示方式并点击“测试通知”
7. 测试成功后开启“私聊新邮件通知”,保存设置。
通知会显示发件人、收件邮箱、主题、收件时间、正文和附件名称。Telegram 连接失败不会影响邮局收件,系统会保留通知任务并自动重试。Bot Token 不会在设置页面重新显示;以后修改其他设置时,Token 输入框留空即可保留原值 一次性绑定码有效期为 10 分钟,只会匹配发送了该绑定码的私聊账号。通知会显示主题、发件人、收件邮箱、服务器收件时间、正文和附件摘要;识别到唯一高可信验证码时,会高亮显示并提供“复制验证码”按钮。外部 IMAP 第一次同步导入的历史邮件不会发送通知,后续新邮件才会通知
Telegram 连接失败不会影响邮局收件。系统会保留通知任务并自动重试;关闭通知、更换机器人、更换私聊账号或修改通知邮箱范围时,尚未发送的旧任务会被清除。Telegram Bot API 不提供客户端幂等键,因此网络超时发生在 Telegram 已收到请求但服务器未收到响应时,极少数通知可能重复发送。Bot Token 不会在设置页面重新显示;以后修改其他设置时,Token 输入框留空即可保留原值。
邮件通知机器人只负责部署实例的私聊提醒。项目版本频道通知由 GitHub Release 工作流统一发送,不需要在每台服务器重复配置。 邮件通知机器人只负责部署实例的私聊提醒。项目版本频道通知由 GitHub Release 工作流统一发送,不需要在每台服务器重复配置。
+27
View File
@@ -40,6 +40,7 @@
| NSX-20260805-003 | 2026-08-05 | 已完成 | 前端/UI/布局稳定性 | 邮箱页与设置页侧栏宽度/边框位置不一致 | S3 | v1.2.14 | 随 v1.2.14 发布 | | NSX-20260805-003 | 2026-08-05 | 已完成 | 前端/UI/布局稳定性 | 邮箱页与设置页侧栏宽度/边框位置不一致 | S3 | v1.2.14 | 随 v1.2.14 发布 |
| NSX-20260806-004 | 2026-08-06 | 已完成 | 前端/UI/响应式布局 | “全部邮箱”选择器右侧存在复制按钮空白占位 | S3 | v1.2.15 | 随 v1.2.15 发布 | | NSX-20260806-004 | 2026-08-06 | 已完成 | 前端/UI/响应式布局 | “全部邮箱”选择器右侧存在复制按钮空白占位 | S3 | v1.2.15 | 随 v1.2.15 发布 |
| NSX-20260806-005 | 2026-08-06 | 已完成 | 后端/通知;前端/设置;部署运维/CI | Telegram 私聊邮件通知与 Release 频道通知 | S3 | v1.2.16 | 随 v1.2.16 发布 | | 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 ## NSX-20260804-001
@@ -225,3 +226,29 @@
| 2026-08-06 | 已配置仓库频道通知密钥并完成频道实发测试;真实 Bot Token 未写入源码。 | | 2026-08-06 | 已配置仓库频道通知密钥并完成频道实发测试;真实 Bot Token 未写入源码。 |
| 2026-08-06 | 完成密钥隐藏、网络错误脱敏、通知失败隔离和 Release 重跑去重复核,状态流转为已完成。 | | 2026-08-06 | 完成密钥隐藏、网络错误脱敏、通知失败隔离和 Release 重跑去重复核,状态流转为已完成。 |
| 2026-08-06 | v1.2.16 检查、六个 Docker 镜像、GitHub Release 和 Telegram 频道通知全部成功。 | | 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 | 完成桌面端与移动端页面验收及最终回归,状态流转为已完成。 |