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=?)`, 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" + } + 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: "这是邮件正文,包含 & 续费信息。", + }, []AttachmentInput{{Filename: "账单-2026.pdf"}}) + if err := a.processDueTelegramMailNotifications(context.Background()); err != nil { + t.Fatalf("process Telegram mail queue: %v", err) + } + if len(sent) != 1 { + t.Fatalf("expected one queued Telegram message, got %d", len(sent)) + } + text := sent[0].Text + for _, expected := range []string{"收到新邮件", "Billing & Support", "账单 <已生成>", "admin@example.com", "邮件正文", "账单-2026.pdf", "<VIP> & 续费信息"} { + if !strings.Contains(text, expected) { + t.Fatalf("Telegram mail message missing %q: %s", expected, text) + } + } + var delivered string + if err := a.db.QueryRow(`SELECT COALESCE(delivered_at,'') FROM telegram_mail_outbox WHERE message_id=?`, "mail_test_telegram").Scan(&delivered); err != nil || delivered == "" { + t.Fatalf("Telegram queue was not marked delivered: delivered=%q err=%v", delivered, err) + } +} + +func TestTelegramSettingsRejectEnabledWithoutCredentials(t *testing.T) { + a := newTestApp(t) + server := httptest.NewServer(a.Router()) + defer server.Close() + admin := &testClient{t: t, server: server} + var login map[string]any + if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK { + t.Fatalf("login code=%d", code) + } + var settings SystemSettings + if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK { + t.Fatalf("get settings code=%d", code) + } + payload := systemSettingsPayload(settings) + payload["telegramMailEnabled"] = true + var body map[string]any + if code := admin.do("POST", "/api/admin/settings", payload, &body); code != http.StatusBadRequest { + t.Fatalf("expected missing Telegram credentials to fail, code=%d body=%v", code, body) + } +} + +func TestTelegramNetworkErrorDoesNotExposeToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + serverURL := server.URL + server.Close() + + a := newTestApp(t) + stopTestWorkers(a) + a.telegramURL = serverURL + const token = "123456:secret-token-value" + err := a.sendTelegramMessage(context.Background(), token, "123456789", "test") + if err == nil { + t.Fatal("expected Telegram network request to fail") + } + if strings.Contains(err.Error(), token) || strings.Contains(err.Error(), "secret-token-value") { + t.Fatalf("Telegram error exposed Bot Token: %v", err) + } +} diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 9fc293c..4d1cc07 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -233,8 +233,13 @@ export type SystemSettings = { externalImapGmailClientSecretSet: boolean externalImapOutlookClientId: string externalImapOutlookClientSecretSet: boolean + telegramMailEnabled: boolean + telegramBotTokenSet: boolean + telegramPrivateChatId: string + telegramBodyMode: "summary" | "full" } -export type SystemSettingsPayload = Omit & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string } +export type SystemSettingsPayload = Omit & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string; telegramBotToken: string } +export type TelegramPrivateChat = { chatId: string; displayName: string } export type PublicDomain = { id: string; name: string } export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] } export type LoginPayload = { loginName?: string; email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 106e66d..ae09273 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult } from "./api-types" +import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult, TelegramPrivateChat } from "./api-types" export * from "./api-types" const REQUEST_TIMEOUT_MS = 15_000 @@ -197,6 +197,8 @@ export const api = { maildirSyncHealth: () => request("/api/admin/maildir-sync/health"), updateSystemSettings: (payload: SystemSettingsPayload) => request("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }), testSmtp: (to: string) => request<{ ok: boolean }>("/api/admin/settings/test-smtp", { method: "POST", body: JSON.stringify({ to }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), + discoverTelegramChat: (botToken: string) => request("/api/admin/settings/telegram/discover", { method: "POST", body: JSON.stringify({ botToken }) }), + testTelegram: (botToken: string, chatId: string) => request<{ ok: boolean }>("/api/admin/settings/telegram/test", { method: "POST", body: JSON.stringify({ botToken, chatId }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), mailTemplates: () => request>("/api/admin/mail-templates"), updateMailTemplate: (key: string, payload: { subject: string; bodyText: string; bodyHtml: string }) => request(`/api/admin/mail-templates/${encodeURIComponent(key)}`, { method: "POST", body: JSON.stringify(payload) }), resetMailTemplate: (key: string) => request(`/api/admin/mail-templates/${encodeURIComponent(key)}/reset`, { method: "POST" }), diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 6f741fb..23789e2 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -27,7 +27,7 @@ import { hasAnyPermission, hasPermission } from "@/lib/permissions" import type { PermissionKey } from "@/lib/api-types" type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings" -type SettingsTab = "base" | "smtp" | "storage" | "mail" | "externalImap" | "templates" | "security" | "about" +type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security" | "about" type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void } const sectionMeta: Record = { @@ -1042,7 +1042,7 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S const canResetTemplates = hasPermission(user, "admin.templates.reset") const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates }) const requestedTab = initialTab as SettingsTab | undefined - const [settingsTab, setSettingsTab] = React.useState(() => requestedTab && ["base", "smtp", "storage", "mail", "externalImap", "templates", "security", "about"].includes(requestedTab) ? requestedTab : "base") + const [settingsTab, setSettingsTab] = React.useState(() => requestedTab && ["base", "smtp", "storage", "mail", "notifications", "externalImap", "templates", "security", "about"].includes(requestedTab) ? requestedTab : "base") const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" }) const [smtpRequireTls, setSmtpRequireTls] = React.useState(false) const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true) @@ -1055,6 +1055,10 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState([]) const [externalImapEnabled, setExternalImapEnabled] = React.useState(false) const [externalImapAllowPrivateHosts, setExternalImapAllowPrivateHosts] = React.useState(false) + const [telegramMailEnabled, setTelegramMailEnabled] = React.useState(false) + const [telegramBotToken, setTelegramBotToken] = React.useState("") + const [telegramPrivateChatId, setTelegramPrivateChatId] = React.useState("") + const [telegramBodyMode, setTelegramBodyMode] = React.useState<"summary" | "full">("summary") React.useEffect(() => { if (!settings) return setSmtpRequireTls(settings.smtpRequireTls) @@ -1068,7 +1072,24 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S setUserMailboxDomainIds(settings.userMailboxDomainIds || []) setExternalImapEnabled(settings.externalImapEnabled) setExternalImapAllowPrivateHosts(settings.externalImapAllowPrivateHosts) + setTelegramMailEnabled(settings.telegramMailEnabled) + setTelegramBotToken("") + setTelegramPrivateChatId(settings.telegramPrivateChatId || "") + setTelegramBodyMode(settings.telegramBodyMode === "full" ? "full" : "summary") }, [settings]) + const discoverTelegram = useMutation({ + mutationFn: () => api.discoverTelegramChat(telegramBotToken), + onSuccess: (chat) => { + setTelegramPrivateChatId(chat.chatId) + toast({ title: "已获取 Telegram 私聊", description: chat.displayName || chat.chatId }) + }, + onError: (error) => toast({ title: "获取失败", description: error.message }), + }) + const testTelegram = useMutation({ + mutationFn: () => api.testTelegram(telegramBotToken, telegramPrivateChatId), + onSuccess: () => toast({ title: "Telegram 测试通知已发送" }), + onError: (error) => toast({ title: "发送失败", description: error.message }), + }) const save = useMutation({ mutationFn: (form: FormData) => api.updateSystemSettings({ publicHostname: fieldValue(form, "publicHostname", settings?.publicHostname || ""), @@ -1101,6 +1122,10 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S externalImapGmailClientSecret: fieldValue(form, "externalImapGmailClientSecret", ""), externalImapOutlookClientId: fieldValue(form, "externalImapOutlookClientId", settings?.externalImapOutlookClientId || ""), externalImapOutlookClientSecret: fieldValue(form, "externalImapOutlookClientSecret", ""), + telegramMailEnabled, + telegramBotToken, + telegramPrivateChatId, + telegramBodyMode, }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["admin", "settings"] }) @@ -1142,6 +1167,10 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S settings.externalImapGmailClientSecretSet, settings.externalImapOutlookClientId, settings.externalImapOutlookClientSecretSet, + settings.telegramMailEnabled, + settings.telegramBotTokenSet, + settings.telegramPrivateChatId, + settings.telegramBodyMode, ].join("|") : "loading" const tabs: { key: typeof settingsTab; label: string }[] = [ ...(canSettingsView ? [ @@ -1149,6 +1178,7 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S { key: "smtp" as const, label: "SMTP" }, { key: "storage" as const, label: "存储" }, { key: "mail" as const, label: "邮件" }, + { key: "notifications" as const, label: "通知" }, { key: "externalImap" as const, label: "外部 IMAP" }, ] : []), ...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []), @@ -1258,6 +1288,49 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S } + {settingsTab === "notifications" && + Telegram 邮件通知 + + + {telegramMailEnabled && ( +
+
+
+ + setTelegramBotToken(event.target.value)} placeholder={settings?.telegramBotTokenSet ? "已保存,留空不变" : "123456789:..."} /> +
+
+ +
+ setTelegramPrivateChatId(event.target.value)} placeholder="123456789" /> + +
+
+
+
+
+ + +
+
+ +
+
+
+ )} +
+
} + {settingsTab === "externalImap" && 外部 IMAP 接入 diff --git a/deploy/.env.example b/deploy/.env.example index 2ea77e4..8bbee87 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -183,6 +183,22 @@ LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET= LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID= LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET= +# ========================= +# Telegram 私聊邮件通知 +# ========================= +# 也可在管理后台“系统设置 > 通知”中配置;后台保存的设置优先于环境变量。 +# 启用后,新收邮件会先写入本地通知队列,再发送到指定 Telegram 私聊;发送失败不会影响收件。 +LANQIN_TELEGRAM_MAIL_ENABLED=false + +# 从 @BotFather 获取。不要提交真实 Token,也不要与版本发布频道机器人共用。 +LANQIN_TELEGRAM_BOT_TOKEN= + +# Telegram 私聊 Chat ID。先向机器人发送 /start,再在后台点击“自动获取”。 +LANQIN_TELEGRAM_PRIVATE_CHAT_ID= + +# summary:正文摘要;full:尽量显示完整正文。两种模式都会限制长度。 +LANQIN_TELEGRAM_BODY_MODE=summary + # ========================= # 系统 # ========================= diff --git a/deploy/README.md b/deploy/README.md index 51257cd..f3d7eab 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -148,6 +148,33 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up 配置完成后点击“检测”。 +## Telegram 通知 + +### 私聊新邮件通知 + +每台邮局可以在“管理后台 -> 系统设置 -> 通知”中独立配置 Telegram 私聊邮件通知: + +1. 使用 `@BotFather` 创建机器人并填写 Bot Token。 +2. 在 Telegram 中打开该机器人并发送 `/start`。 +3. 回到后台点击“自动获取”,系统会填写最近一个私聊 Chat ID。 +4. 选择“正文摘要”或“尽量显示完整正文”,点击“测试通知”。 +5. 测试成功后开启“私聊新邮件通知”并保存。 + +Bot Token 不会通过设置查询接口返回。新邮件通知会先持久化到 SQLite 队列,Telegram 暂时不可用时按退避策略重试;通知失败不会阻塞收件。通知包含发件人、收件邮箱、主题、收件时间、正文和附件名称,不会把附件文件上传到 Telegram。 + +手动部署也可以在 `.env` 中设置 `LANQIN_TELEGRAM_MAIL_ENABLED`、`LANQIN_TELEGRAM_BOT_TOKEN`、`LANQIN_TELEGRAM_PRIVATE_CHAT_ID` 和 `LANQIN_TELEGRAM_BODY_MODE`。后台保存的值会持久化到数据库,并在后续启动时优先使用。 + +### GitHub Release 版本频道通知 + +版本频道通知由 GitHub Release 工作流统一发送,与各台已部署邮局是否更新无关。仓库需要配置以下 GitHub Actions Secrets: + +```text +TELEGRAM_RELEASE_BOT_TOKEN +TELEGRAM_RELEASE_CHAT_ID +``` + +`TELEGRAM_RELEASE_CHAT_ID` 可以填写频道用户名(例如 `@YourChannel`)或频道数字 ID。机器人必须先添加为频道管理员,并具有发布消息权限。工作流只在检查、全部 Docker 镜像和 GitHub Release 成功后发送一次;未配置密钥时自动跳过,Telegram 发送失败也不会把版本发布标记为失败。 + ## 邮件服务边界 - Postfix 读取 `/data/lanqin.db` 中的 `domains`、`mailboxes`、`aliases`。 diff --git a/docs/GUIDE.md b/docs/GUIDE.md index d03ced5..48c8799 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -59,6 +59,21 @@ DNS 生效通常需要几分钟到数小时。系统只能检测记录,不能 无人收件不会自动创建邮箱,也不会把邮件分配给普通用户。只有管理员可以在邮箱前台左侧的“未知收件”中查看这些邮件。 +## Telegram 私聊邮件通知 + +管理员可以把每封新收邮件的概要发送到自己的 Telegram 私聊: + +1. 使用 `@BotFather` 创建一个邮件通知机器人并取得 Bot Token。 +2. 在 Telegram 中打开机器人,发送 `/start`。 +3. 进入“管理后台 -> 系统设置 -> 通知”。 +4. 填写 Bot Token,点击“自动获取”取得私聊 Chat ID。 +5. 选择正文显示方式并点击“测试通知”。 +6. 测试成功后开启“私聊新邮件通知”,保存设置。 + +通知会显示发件人、收件邮箱、主题、收件时间、正文和附件名称。Telegram 连接失败不会影响邮局收件,系统会保留通知任务并自动重试。Bot Token 不会在设置页面重新显示;以后修改其他设置时,Token 输入框留空即可保留原值。 + +邮件通知机器人只负责部署实例的私聊提醒。项目版本频道通知由 GitHub Release 工作流统一发送,不需要在每台服务器重复配置。 + ## SSL 证书与自动续期 选择“自动配置 Nginx + SSL”后,官方 `acme.sh` 会安装定时检查任务。证书接近到期时会自动续期,续期成功后自动重载 NewSzxcn Email 和 Nginx。 diff --git a/docs/ISSUE_LEDGER.md b/docs/ISSUE_LEDGER.md index cdeeaca..4fb1bd9 100644 --- a/docs/ISSUE_LEDGER.md +++ b/docs/ISSUE_LEDGER.md @@ -39,6 +39,7 @@ | NSX-20260804-002 | 2026-08-04 | 已完成 | 前端/UI/响应式布局 | 邮箱选择器展开后宽度变窄 | S3 | v1.2.14 | 随 v1.2.14 发布 | | NSX-20260805-003 | 2026-08-05 | 已完成 | 前端/UI/布局稳定性 | 邮箱页与设置页侧栏宽度/边框位置不一致 | S3 | v1.2.14 | 随 v1.2.14 发布 | | NSX-20260806-004 | 2026-08-06 | 已完成 | 前端/UI/响应式布局 | “全部邮箱”选择器右侧存在复制按钮空白占位 | S3 | v1.2.15 | 随 v1.2.15 发布 | +| NSX-20260806-005 | 2026-08-06 | 已完成 | 后端/通知;前端/设置;部署运维/CI | Telegram 私聊邮件通知与 Release 频道通知 | S3 | v1.2.16 | 待发布 | ## NSX-20260804-001 @@ -198,3 +199,28 @@ | --- | --- | | 2026-08-06 | 用户反馈“全部邮箱”右侧存在空白块并要求修改。 | | 2026-08-06 | 已移除永久占位列,改为具体邮箱状态覆盖显示复制按钮,状态流转为待验收。 | + +## NSX-20260806-005 + +| 字段 | 内容 | +| --- | --- | +| 编号 | NSX-20260806-005 | +| 日期 | 2026-08-06 | +| 状态 | 已完成 | +| 模块 | 后端/通知;前端/设置;部署运维/CI;质量复核 | +| 需求 | 后台配置 Telegram 机器人,将新邮件排版后发送到管理员私聊;GitHub Release 成功后统一向版本频道发送一次更新通知。 | +| 边界 | 邮件通知由各部署实例独立配置;版本通知只由 GitHub Release 工作流发送,不依赖已部署邮局是否更新。 | +| 实现 | 新增 Telegram 通知设置、私聊 Chat ID 自动获取、测试发送、正文模式、持久化通知队列、去重与失败重试;Release 工作流在全部镜像和 Release 成功后发送频道消息。 | +| 安全 | Bot Token 不通过设置查询接口返回,不写入仓库;频道密钥使用 GitHub Actions Secrets;Telegram 失败不阻塞收件或版本发布。 | +| 兼容性 | 数据库只新增表和设置项;默认关闭;现有配置、邮件、证书和在线更新方式不变。 | +| 目标版本 | v1.2.16 | +| 测试结果 | Go 全量测试和 vet、前端 check/build、安装脚本语法/ShellCheck/回归、工作流 YAML、密钥扫描、桌面和移动端页面检查均通过。 | +| 发布状态 | 待发布。 | + +### 历史 + +| 时间 | 记录 | +| --- | --- | +| 2026-08-06 | 用户确认后台只保留机器人私聊邮件通知,版本频道通知交由 GitHub Release 工作流统一发送。 | +| 2026-08-06 | 已配置仓库频道通知密钥并完成频道实发测试;真实 Bot Token 未写入源码。 | +| 2026-08-06 | 完成密钥隐藏、网络错误脱敏、通知失败隔离和 Release 重跑去重复核,状态流转为已完成。 |