From 79f920bf0bdfddc852a8b0f7d2d81761b9cd0b65 Mon Sep 17 00:00:00 2001 From: zxyszx <299979470+zxyszx@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:17:11 +0800 Subject: [PATCH] feat: add Telegram mail and release notifications --- .github/release-notes/v1.2.16.md | 3 + .github/workflows/docker.yml | 57 ++++ apps/api/internal/app/app.go | 16 +- apps/api/internal/app/app_test.go | 4 + apps/api/internal/app/config.go | 8 + apps/api/internal/app/maildir_sync.go | 6 +- apps/api/internal/app/router_auth.go | 2 + apps/api/internal/app/settings_handlers.go | 40 +++ apps/api/internal/app/telegram.go | 356 +++++++++++++++++++++ apps/api/internal/app/telegram_test.go | 147 +++++++++ apps/web/src/lib/api-types.ts | 7 +- apps/web/src/lib/api.ts | 4 +- apps/web/src/pages/admin.tsx | 77 ++++- deploy/.env.example | 16 + deploy/README.md | 27 ++ docs/GUIDE.md | 15 + docs/ISSUE_LEDGER.md | 26 ++ 17 files changed, 805 insertions(+), 6 deletions(-) create mode 100644 .github/release-notes/v1.2.16.md create mode 100644 apps/api/internal/app/telegram.go create mode 100644 apps/api/internal/app/telegram_test.go diff --git a/.github/release-notes/v1.2.16.md b/.github/release-notes/v1.2.16.md new file mode 100644 index 0000000..f6e6eb0 --- /dev/null +++ b/.github/release-notes/v1.2.16.md @@ -0,0 +1,3 @@ +- 新增后台 Telegram 私聊新邮件通知,支持自动获取 Chat ID、测试通知、正文显示模式和失败自动重试。 +- 新增 GitHub Release 版本频道通知;仅首次创建 Release 时发送一次,工作流重跑不会重复推送。 +- Bot Token 不通过设置接口返回,Telegram 异常不会阻塞邮件接收或版本发布。 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 21db127..5f39485 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -240,6 +240,7 @@ jobs: cp generated-release-notes.md release-notes.md - name: Create or update GitHub release + id: release_result env: GH_TOKEN: ${{ github.token }} shell: bash @@ -248,6 +249,62 @@ jobs: title="NewSzxcn Email ${tag}" if gh release view "${tag}" >/dev/null 2>&1; then gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest + echo "created=false" >> "$GITHUB_OUTPUT" else gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest + echo "created=true" >> "$GITHUB_OUTPUT" fi + + - name: Notify Telegram release channel + if: steps.release_result.outputs.created == 'true' + continue-on-error: true + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_RELEASE_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_RELEASE_CHAT_ID }} + RELEASE_TAG: ${{ needs.release.outputs.tag }} + RELEASE_URL: ${{ needs.release.outputs.release_url }} + shell: bash + run: | + if [[ -z "${TELEGRAM_BOT_TOKEN}" || -z "${TELEGRAM_CHAT_ID}" ]]; then + echo "::notice::Telegram release notification is not configured; skipping." + exit 0 + fi + + python3 - <<'PY' + import re + import os + + notes = open("release-notes.md", "r", encoding="utf-8").read().strip() + lines = [] + for raw in notes.splitlines(): + line = re.sub(r"^#{1,6}\s+", "", raw).strip() + line = re.sub(r"\*\*([^*]+)\*\*", r"\1", line) + line = re.sub(r"\[([^]]+)\]\(([^)]+)\)", r"\1:\2", line) + lines.append(line) + body = "\n".join(lines).strip() + tag = os.environ["RELEASE_TAG"] + release_url = os.environ["RELEASE_URL"] + prefix = f"NewSzxcn Email {tag}\n\n" + suffix = f"\n\n更新地址:{release_url}" + available = max(0, 3600 - len(prefix) - len(suffix)) + if len(body) > available: + body = body[:available].rstrip() + "..." + open("telegram-release-message.txt", "w", encoding="utf-8").write(prefix + body + suffix) + PY + + jq -n \ + --arg chat_id "${TELEGRAM_CHAT_ID}" \ + --rawfile text telegram-release-message.txt \ + '{chat_id:$chat_id,text:$text,disable_web_page_preview:true}' > telegram-release-payload.json + + http_code="$(curl -sS --retry 2 --retry-all-errors --connect-timeout 10 --max-time 30 \ + -o telegram-release-response.json -w '%{http_code}' \ + -H 'Content-Type: application/json' \ + --data-binary @telegram-release-payload.json \ + "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage")" + if [[ "${http_code}" != "200" ]] || ! jq -e '.ok == true' telegram-release-response.json >/dev/null 2>&1; then + description="$(jq -r '.description // "unknown Telegram error"' telegram-release-response.json 2>/dev/null || echo "unknown Telegram error")" + echo "::warning::Telegram release notification failed (HTTP ${http_code}): ${description}" + exit 1 + fi + echo "::notice::Telegram release notification sent." diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index a3fcead..df74231 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -34,6 +34,7 @@ type App struct { maildirHealth *maildirSyncHealthTracker externalIMAP externalIMAPClientFactory turnstileURL string + telegramURL string } func (a *App) config() Config { @@ -71,7 +72,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) { } db.SetMaxOpenConns(1) - a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker()} + a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker(), telegramURL: "https://api.telegram.org"} a.externalIMAP = a if err := a.configureSQLite(context.Background()); err != nil { db.Close() @@ -107,6 +108,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) { a.startWorker(func() { a.externalIMAPWorker(workerCtx) }) a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) }) a.startWorker(func() { a.statusWebhookWorker(workerCtx) }) + a.startWorker(func() { a.telegramMailWorker(workerCtx) }) return a, nil } @@ -433,6 +435,18 @@ func (a *App) migrate(ctx context.Context) error { )`, `CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_due ON status_webhook_outbox(delivered_at,next_attempt_at,created_at)`, `CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_mailbox ON status_webhook_outbox(mailbox_id,created_at)`, + `CREATE TABLE IF NOT EXISTS telegram_mail_outbox ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL UNIQUE, + payload_json TEXT NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0, + next_attempt_at TEXT NOT NULL, + last_error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + delivered_at TEXT + )`, + `CREATE INDEX IF NOT EXISTS idx_telegram_mail_outbox_due ON telegram_mail_outbox(delivered_at,next_attempt_at,created_at)`, `CREATE TRIGGER IF NOT EXISTS trg_mailbox_delete_status_webhook_outbox AFTER DELETE ON mailboxes BEGIN DELETE FROM status_webhook_outbox WHERE mailbox_id=OLD.id; diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 210e884..950b91c 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -459,6 +459,10 @@ func systemSettingsPayload(settings SystemSettings) map[string]any { "externalImapGmailClientSecret": "", "externalImapOutlookClientId": settings.ExternalIMAPOutlookClientID, "externalImapOutlookClientSecret": "", + "telegramMailEnabled": settings.TelegramMailEnabled, + "telegramBotToken": "", + "telegramPrivateChatId": settings.TelegramPrivateChatID, + "telegramBodyMode": settings.TelegramBodyMode, } } diff --git a/apps/api/internal/app/config.go b/apps/api/internal/app/config.go index eeafb4b..92d9561 100644 --- a/apps/api/internal/app/config.go +++ b/apps/api/internal/app/config.go @@ -52,6 +52,10 @@ type Config struct { ExternalIMAPGmailClientSecret string ExternalIMAPOutlookClientID string ExternalIMAPOutlookClientSecret string + TelegramMailEnabled bool + TelegramBotToken string + TelegramPrivateChatID string + TelegramBodyMode string MailTranslateEnabled bool MailTranslateMaxChars int DeliveryWebhookSecret string @@ -110,6 +114,10 @@ func LoadConfig() Config { ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""), ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""), ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""), + TelegramMailEnabled: getenvBool("LANQIN_TELEGRAM_MAIL_ENABLED", false), + TelegramBotToken: getenv("LANQIN_TELEGRAM_BOT_TOKEN", ""), + TelegramPrivateChatID: getenv("LANQIN_TELEGRAM_PRIVATE_CHAT_ID", ""), + TelegramBodyMode: normalizeTelegramBodyMode(getenv("LANQIN_TELEGRAM_BODY_MODE", "summary")), MailTranslateEnabled: getenvBool("LANQIN_MAIL_TRANSLATE_ENABLED", true), MailTranslateMaxChars: getenvInt("LANQIN_MAIL_TRANSLATE_MAX_CHARS", 8000), DeliveryWebhookSecret: getenv("LANQIN_DELIVERY_WEBHOOK_SECRET", ""), diff --git a/apps/api/internal/app/maildir_sync.go b/apps/api/internal/app/maildir_sync.go index c35bfe7..34d876e 100644 --- a/apps/api/internal/app/maildir_sync.go +++ b/apps/api/internal/app/maildir_sync.go @@ -292,7 +292,10 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr) return false, nil } - _, err = a.insertMessage(ctx, msg, attachments) + id, err := a.insertMessage(ctx, msg, attachments) + if err == nil { + a.enqueueTelegramMailNotification(ctx, id, msg, attachments) + } return err == nil, err } @@ -365,6 +368,7 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai } id, err := a.insertMessage(ctx, msg, attachments) if err == nil && strings.EqualFold(folder.Name, "Inbox") { + a.enqueueTelegramMailNotification(ctx, id, msg, attachments) a.applyInboundControls(ctx, id, mb.ID, msg.From, msg.Subject) a.processInboundForwarding(ctx, id, mb.ID, raw) } diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index aec06cb..21df6d3 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -171,6 +171,8 @@ func (a *App) Router() http.Handler { r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/maildir-sync/health", a.handleMaildirSyncHealth) r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings) r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP) + r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/discover", a.handleDiscoverTelegramChat) + r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings/telegram/test", a.handleTestTelegram) r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates) r.With(a.requirePermission(PermissionTemplatesUpdate)).Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate) r.With(a.requirePermission(PermissionTemplatesReset)).Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate) diff --git a/apps/api/internal/app/settings_handlers.go b/apps/api/internal/app/settings_handlers.go index 8269c84..6b14b39 100644 --- a/apps/api/internal/app/settings_handlers.go +++ b/apps/api/internal/app/settings_handlers.go @@ -40,6 +40,10 @@ type SystemSettings struct { ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"` ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"` ExternalIMAPOutlookClientSecretSet bool `json:"externalImapOutlookClientSecretSet"` + TelegramMailEnabled bool `json:"telegramMailEnabled"` + TelegramBotTokenSet bool `json:"telegramBotTokenSet"` + TelegramPrivateChatID string `json:"telegramPrivateChatId"` + TelegramBodyMode string `json:"telegramBodyMode"` } type systemSettingsUpdate struct { @@ -73,6 +77,10 @@ type systemSettingsUpdate struct { ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"` ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"` ExternalIMAPOutlookClientSecret string `json:"externalImapOutlookClientSecret"` + TelegramMailEnabled bool `json:"telegramMailEnabled"` + TelegramBotToken string `json:"telegramBotToken"` + TelegramPrivateChatID string `json:"telegramPrivateChatId"` + TelegramBodyMode string `json:"telegramBodyMode"` } type PublicSettings struct { @@ -204,6 +212,22 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) badRequest(w, errors.New("外部 IMAP 加密密钥未设置")) return } + next.TelegramMailEnabled = req.TelegramMailEnabled + if strings.TrimSpace(req.TelegramBotToken) != "" { + next.TelegramBotToken = strings.TrimSpace(req.TelegramBotToken) + } + next.TelegramPrivateChatID = strings.TrimSpace(req.TelegramPrivateChatID) + next.TelegramBodyMode = normalizeTelegramBodyMode(req.TelegramBodyMode) + if next.TelegramMailEnabled { + if next.TelegramBotToken == "" { + badRequest(w, errors.New("Telegram Bot Token 未设置")) + return + } + if !validTelegramPrivateChatID(next.TelegramPrivateChatID) { + badRequest(w, errors.New("Telegram 私聊 Chat ID 无效")) + return + } + } if err := a.saveSystemSettings(r.Context(), next); err != nil { respondError(w, http.StatusInternalServerError, "failed to save settings") @@ -318,6 +342,10 @@ func (a *App) systemSettingsSnapshot() SystemSettings { ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "", ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID, ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPOutlookClientSecret) != "", + TelegramMailEnabled: cfg.TelegramMailEnabled, + TelegramBotTokenSet: strings.TrimSpace(cfg.TelegramBotToken) != "", + TelegramPrivateChatID: cfg.TelegramPrivateChatID, + TelegramBodyMode: normalizeTelegramBodyMode(cfg.TelegramBodyMode), } } @@ -402,6 +430,14 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error { cfg.ExternalIMAPOutlookClientID = value case "externalImapOutlookClientSecret": cfg.ExternalIMAPOutlookClientSecret = value + case "telegramMailEnabled": + cfg.TelegramMailEnabled = value == "true" + case "telegramBotToken": + cfg.TelegramBotToken = value + case "telegramPrivateChatId": + cfg.TelegramPrivateChatID = value + case "telegramBodyMode": + cfg.TelegramBodyMode = normalizeTelegramBodyMode(value) } } if err := rows.Err(); err != nil { @@ -443,6 +479,10 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error { "externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret, "externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID, "externalImapOutlookClientSecret": cfg.ExternalIMAPOutlookClientSecret, + "telegramMailEnabled": strconv.FormatBool(cfg.TelegramMailEnabled), + "telegramBotToken": cfg.TelegramBotToken, + "telegramPrivateChatId": cfg.TelegramPrivateChatID, + "telegramBodyMode": normalizeTelegramBodyMode(cfg.TelegramBodyMode), } now := a.now().UTC().Format(time.RFC3339Nano) tx, err := a.db.BeginTx(ctx, nil) diff --git a/apps/api/internal/app/telegram.go b/apps/api/internal/app/telegram.go new file mode 100644 index 0000000..26449dc --- /dev/null +++ b/apps/api/internal/app/telegram.go @@ -0,0 +1,356 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "html" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const telegramMailMaxAttempts = 8 + +type telegramMailPayload struct { + From string `json:"from"` + FromName string `json:"fromName,omitempty"` + Recipient string `json:"recipient"` + Subject string `json:"subject"` + ReceivedAt string `json:"receivedAt"` + Body string `json:"body"` + BodyMode string `json:"bodyMode"` + AttachmentNames []string `json:"attachmentNames,omitempty"` +} + +type telegramCredentialsRequest struct { + BotToken string `json:"botToken"` + ChatID string `json:"chatId"` +} + +type telegramAPIResponse struct { + OK bool `json:"ok"` + Description string `json:"description"` + Result json.RawMessage `json:"result"` +} + +type telegramUpdate struct { + UpdateID int64 `json:"update_id"` + Message *struct { + Chat struct { + ID int64 `json:"id"` + Type string `json:"type"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Username string `json:"username"` + } `json:"chat"` + } `json:"message"` +} + +func normalizeTelegramBodyMode(value string) string { + if strings.EqualFold(strings.TrimSpace(value), "full") { + return "full" + } + return "summary" +} + +func validTelegramPrivateChatID(value string) bool { + id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + return err == nil && id > 0 +} + +func (a *App) handleDiscoverTelegramChat(w http.ResponseWriter, r *http.Request) { + var req telegramCredentialsRequest + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + token := strings.TrimSpace(req.BotToken) + if token == "" { + token = strings.TrimSpace(a.config().TelegramBotToken) + } + if token == "" { + badRequest(w, errors.New("请先填写 Telegram Bot Token")) + return + } + chatID, displayName, err := a.discoverTelegramPrivateChat(r.Context(), token) + if err != nil { + respondError(w, http.StatusBadGateway, err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]string{"chatId": chatID, "displayName": displayName}) +} + +func (a *App) handleTestTelegram(w http.ResponseWriter, r *http.Request) { + var req telegramCredentialsRequest + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + token, chatID := a.telegramCredentials(req) + if token == "" { + badRequest(w, errors.New("请先填写 Telegram Bot Token")) + return + } + if !validTelegramPrivateChatID(chatID) { + badRequest(w, errors.New("请先获取或填写有效的私聊 Chat ID")) + return + } + now := a.now().Local().Format("2006-01-02 15:04:05 MST") + text := "NewSzxcn 邮箱通知测试\n\nTelegram 私聊邮件通知连接正常。\n\n测试时间:" + html.EscapeString(now) + if err := a.sendTelegramMessage(r.Context(), token, chatID, text); err != nil { + respondError(w, http.StatusBadGateway, err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (a *App) telegramCredentials(req telegramCredentialsRequest) (string, string) { + cfg := a.config() + token := strings.TrimSpace(req.BotToken) + if token == "" { + token = strings.TrimSpace(cfg.TelegramBotToken) + } + chatID := strings.TrimSpace(req.ChatID) + if chatID == "" { + chatID = strings.TrimSpace(cfg.TelegramPrivateChatID) + } + return token, chatID +} + +func (a *App) discoverTelegramPrivateChat(ctx context.Context, token string) (string, string, error) { + var updates []telegramUpdate + if err := a.callTelegram(ctx, token, "getUpdates", map[string]any{ + "limit": 100, + "timeout": 0, + "allowed_updates": []string{"message"}, + }, &updates); err != nil { + return "", "", err + } + for i := len(updates) - 1; i >= 0; i-- { + message := updates[i].Message + if message == nil || message.Chat.Type != "private" || message.Chat.ID <= 0 { + continue + } + name := strings.TrimSpace(strings.Join([]string{message.Chat.FirstName, message.Chat.LastName}, " ")) + if name == "" && message.Chat.Username != "" { + name = "@" + message.Chat.Username + } + return strconv.FormatInt(message.Chat.ID, 10), name, nil + } + return "", "", errors.New("未找到私聊会话,请先在 Telegram 中打开机器人并发送 /start,然后重试") +} + +func (a *App) enqueueTelegramMailNotification(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) { + cfg := a.config() + if !cfg.TelegramMailEnabled || strings.TrimSpace(cfg.TelegramBotToken) == "" || !validTelegramPrivateChatID(cfg.TelegramPrivateChatID) { + return + } + recipient := normalizeEmail(msg.RecipientAddr) + if recipient == "" && len(msg.To) > 0 { + recipient = normalizeEmail(msg.To[0]) + } + body := strings.TrimSpace(msg.BodyText) + if body == "" { + body = strings.TrimSpace(stripTags(msg.BodyHTML)) + } + mode := normalizeTelegramBodyMode(cfg.TelegramBodyMode) + limit := 800 + if mode == "full" { + limit = 2600 + } + body, truncated := truncateRunes(strings.Join(strings.Fields(body), " "), limit) + if truncated { + body += "..." + } + if body == "" { + body = strings.TrimSpace(msg.Snippet) + } + names := make([]string, 0, len(attachments)) + for _, attachment := range attachments { + name := strings.TrimSpace(attachment.Filename) + if name != "" { + names = append(names, name) + } + if len(names) >= 10 { + break + } + } + payload := telegramMailPayload{ + From: msg.From, + FromName: msg.FromName, + Recipient: recipient, + Subject: msg.Subject, + ReceivedAt: msg.ReceivedAt.Format(time.RFC3339Nano), + Body: body, + BodyMode: mode, + AttachmentNames: names, + } + now := a.now().UTC().Format(time.RFC3339Nano) + if _, err := a.db.ExecContext(ctx, `INSERT OR IGNORE INTO telegram_mail_outbox(id,message_id,payload_json,next_attempt_at,created_at,updated_at) VALUES(?,?,?,?,?,?)`, newID("tgm"), messageID, jsonEncode(payload), now, now, now); err != nil { + a.log.Warn("failed to enqueue Telegram mail notification", "messageId", messageID, "error", err) + } +} + +func (a *App) telegramMailWorker(ctx context.Context) { + a.log.Info("Telegram mail notification worker started") + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + if err := a.processDueTelegramMailNotifications(ctx); err != nil && !errors.Is(err, context.Canceled) { + a.log.Warn("Telegram mail notification worker failed", "error", err) + } + select { + case <-ctx.Done(): + a.log.Info("Telegram mail notification worker stopped") + return + case <-ticker.C: + } + } +} + +func (a *App) processDueTelegramMailNotifications(ctx context.Context) error { + cfg := a.config() + if !cfg.TelegramMailEnabled || strings.TrimSpace(cfg.TelegramBotToken) == "" || !validTelegramPrivateChatID(cfg.TelegramPrivateChatID) { + return nil + } + _, _ = a.db.ExecContext(ctx, `DELETE FROM telegram_mail_outbox WHERE updated_at AND (delivered_at IS NOT NULL OR attempt_count>=?)`, a.now().UTC().Add(-30*24*time.Hour).Format(time.RFC3339Nano), telegramMailMaxAttempts) + rows, err := a.db.QueryContext(ctx, `SELECT id,payload_json,attempt_count FROM telegram_mail_outbox WHERE delivered_at IS NULL AND attempt_count AND next_attempt_at<=? ORDER BY next_attempt_at,created_at LIMIT 20`, telegramMailMaxAttempts, a.now().UTC().Format(time.RFC3339Nano)) + if err != nil { + return err + } + type queueItem struct { + id string + payload telegramMailPayload + attempt int + } + items := []queueItem{} + for rows.Next() { + var item queueItem + var raw string + if err := rows.Scan(&item.id, &raw, &item.attempt); err != nil { + rows.Close() + return err + } + if err := json.Unmarshal([]byte(raw), &item.payload); err != nil { + rows.Close() + return err + } + items = append(items, item) + } + if err := rows.Close(); err != nil { + return err + } + for _, item := range items { + err := a.sendTelegramMessage(ctx, cfg.TelegramBotToken, cfg.TelegramPrivateChatID, formatTelegramMailMessage(item.payload)) + now := a.now().UTC() + if err != nil { + next := now.Add(sendRetryDelay(item.attempt + 1)) + _, _ = a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=attempt_count+1,next_attempt_at=?,last_error=?,updated_at=? WHERE id=? AND delivered_at IS NULL`, next.Format(time.RFC3339Nano), truncateWebhookError(err.Error()), now.Format(time.RFC3339Nano), item.id) + continue + } + stamp := now.Format(time.RFC3339Nano) + _, _ = a.db.ExecContext(ctx, `UPDATE telegram_mail_outbox SET attempt_count=attempt_count+1,last_error='',updated_at=?,delivered_at=? WHERE id=? AND delivered_at IS NULL`, stamp, stamp, item.id) + } + return nil +} + +func formatTelegramMailMessage(payload telegramMailPayload) string { + subject := strings.TrimSpace(payload.Subject) + if subject == "" || subject == "(no subject)" { + subject = "(无主题)" + } + from := strings.TrimSpace(payload.From) + if name := strings.TrimSpace(payload.FromName); name != "" { + from = name + " <" + from + ">" + } + receivedAt := parseTime(payload.ReceivedAt) + timeText := strings.TrimSpace(payload.ReceivedAt) + if !receivedAt.IsZero() { + timeText = receivedAt.Local().Format("2006-01-02 15:04:05 MST") + } + lines := []string{ + "收到新邮件", + "", + "发件人:" + html.EscapeString(from), + "收件邮箱:" + html.EscapeString(payload.Recipient), + "主题:" + html.EscapeString(subject), + "收件时间:" + html.EscapeString(timeText), + } + if len(payload.AttachmentNames) > 0 { + names := make([]string, 0, len(payload.AttachmentNames)) + for _, name := range payload.AttachmentNames { + names = append(names, html.EscapeString(name)) + } + lines = append(lines, "附件:"+strings.Join(names, "、")) + } + body := strings.TrimSpace(payload.Body) + if body != "" { + label := "正文摘要" + if normalizeTelegramBodyMode(payload.BodyMode) == "full" { + label = "邮件正文" + } + lines = append(lines, "", ""+label+":", "
"+html.EscapeString(body)+"") + } + return strings.Join(lines, "\n") +} + +func (a *App) sendTelegramMessage(ctx context.Context, token, chatID, text string) error { + return a.callTelegram(ctx, token, "sendMessage", map[string]any{ + "chat_id": chatID, + "text": text, + "parse_mode": "HTML", + "disable_web_page_preview": true, + }, nil) +} + +func (a *App) callTelegram(ctx context.Context, token, method string, payload any, result any) error { + token = strings.TrimSpace(token) + if token == "" || strings.ContainsAny(token, "/\\\r\n") { + return errors.New("Telegram Bot Token 无效") + } + base := strings.TrimRight(strings.TrimSpace(a.telegramURL), "/") + endpoint := base + "/bot" + url.PathEscape(token) + "/" + method + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "NewSzxcn-Email-Telegram/1.0") + client := &http.Client{Timeout: 12 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Do(req) + if err != nil { + return errors.New("Telegram 请求失败,请检查网络连接和机器人配置") + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return err + } + var apiResponse telegramAPIResponse + if err := json.Unmarshal(raw, &apiResponse); err != nil { + return fmt.Errorf("Telegram 返回了无效响应(HTTP %d)", resp.StatusCode) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 || !apiResponse.OK { + description := strings.TrimSpace(apiResponse.Description) + if description == "" { + description = fmt.Sprintf("HTTP %d", resp.StatusCode) + } + return fmt.Errorf("Telegram 发送失败: %s", description) + } + if result != nil && len(apiResponse.Result) > 0 { + if err := json.Unmarshal(apiResponse.Result, result); err != nil { + return err + } + } + return nil +} diff --git a/apps/api/internal/app/telegram_test.go b/apps/api/internal/app/telegram_test.go new file mode 100644 index 0000000..1a03b39 --- /dev/null +++ b/apps/api/internal/app/telegram_test.go @@ -0,0 +1,147 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestTelegramSettingsDiscoveryTestAndMailQueue(t *testing.T) { + type sentMessage struct { + ChatID string `json:"chat_id"` + Text string `json:"text"` + } + var sent []sentMessage + telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/bottest-token/getUpdates": + _, _ = w.Write([]byte(`{"ok":true,"result":[{"update_id":7,"message":{"chat":{"id":123456789,"type":"private","first_name":"Zhenxi","last_name":"Shen"}}}]}`)) + case "/bottest-token/sendMessage": + var message sentMessage + if err := json.NewDecoder(r.Body).Decode(&message); err != nil { + t.Fatalf("decode Telegram message: %v", err) + } + sent = append(sent, message) + _, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":8}}`)) + default: + http.NotFound(w, r) + } + })) + defer telegramServer.Close() + + a := newTestApp(t) + stopTestWorkers(a) + a.telegramURL = telegramServer.URL + server := httptest.NewServer(a.Router()) + defer server.Close() + admin := &testClient{t: t, server: server} + var login map[string]any + if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK { + t.Fatalf("login code=%d body=%v", code, login) + } + + var settings SystemSettings + if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK { + t.Fatalf("get settings code=%d", code) + } + payload := systemSettingsPayload(settings) + payload["telegramMailEnabled"] = true + payload["telegramBotToken"] = "test-token" + payload["telegramPrivateChatId"] = "123456789" + payload["telegramBodyMode"] = "full" + if code := admin.do("POST", "/api/admin/settings", payload, &settings); code != http.StatusOK { + t.Fatalf("save Telegram settings code=%d settings=%+v", code, settings) + } + if !settings.TelegramMailEnabled || !settings.TelegramBotTokenSet || settings.TelegramPrivateChatID != "123456789" || settings.TelegramBodyMode != "full" { + t.Fatalf("unexpected Telegram settings: %+v", settings) + } + if a.config().TelegramBotToken != "test-token" { + t.Fatal("Telegram token was not persisted in runtime config") + } + + var discovered map[string]string + if code := admin.do("POST", "/api/admin/settings/telegram/discover", map[string]string{"botToken": ""}, &discovered); code != http.StatusOK { + t.Fatalf("discover chat code=%d response=%v", code, discovered) + } + if discovered["chatId"] != "123456789" || discovered["displayName"] != "Zhenxi Shen" { + t.Fatalf("unexpected discovered chat: %v", discovered) + } + var testResult map[string]any + if code := admin.do("POST", "/api/admin/settings/telegram/test", map[string]string{"botToken": "", "chatId": ""}, &testResult); code != http.StatusOK { + t.Fatalf("test Telegram code=%d response=%v", code, testResult) + } + if len(sent) != 1 || sent[0].ChatID != "123456789" || !strings.Contains(sent[0].Text, "通知测试") { + t.Fatalf("unexpected Telegram test message: %+v", sent) + } + + sent = nil + receivedAt := time.Date(2026, 8, 6, 9, 30, 0, 0, time.UTC) + a.enqueueTelegramMailNotification(context.Background(), "mail_test_telegram", storedMessage{ + RecipientAddr: "admin@example.com", + Subject: "账单 <已生成>", + From: "billing@example.net", + FromName: "Billing & Support", + ReceivedAt: receivedAt, + BodyText: "这是邮件正文,包含