feat: add Telegram mail and release 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
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:
@@ -0,0 +1,3 @@
|
|||||||
|
- 新增后台 Telegram 私聊新邮件通知,支持自动获取 Chat ID、测试通知、正文显示模式和失败自动重试。
|
||||||
|
- 新增 GitHub Release 版本频道通知;仅首次创建 Release 时发送一次,工作流重跑不会重复推送。
|
||||||
|
- Bot Token 不通过设置接口返回,Telegram 异常不会阻塞邮件接收或版本发布。
|
||||||
@@ -240,6 +240,7 @@ jobs:
|
|||||||
cp generated-release-notes.md release-notes.md
|
cp generated-release-notes.md release-notes.md
|
||||||
|
|
||||||
- name: Create or update GitHub release
|
- name: Create or update GitHub release
|
||||||
|
id: release_result
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -248,6 +249,62 @@ jobs:
|
|||||||
title="NewSzxcn Email ${tag}"
|
title="NewSzxcn Email ${tag}"
|
||||||
if gh release view "${tag}" >/dev/null 2>&1; then
|
if gh release view "${tag}" >/dev/null 2>&1; then
|
||||||
gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest
|
gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest
|
||||||
|
echo "created=false" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest
|
gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest
|
||||||
|
echo "created=true" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
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."
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type App struct {
|
|||||||
maildirHealth *maildirSyncHealthTracker
|
maildirHealth *maildirSyncHealthTracker
|
||||||
externalIMAP externalIMAPClientFactory
|
externalIMAP externalIMAPClientFactory
|
||||||
turnstileURL string
|
turnstileURL string
|
||||||
|
telegramURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) config() Config {
|
func (a *App) config() Config {
|
||||||
@@ -71,7 +72,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()}
|
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker(), telegramURL: "https://api.telegram.org"}
|
||||||
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()
|
||||||
@@ -107,6 +108,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
|||||||
a.startWorker(func() { a.externalIMAPWorker(workerCtx) })
|
a.startWorker(func() { a.externalIMAPWorker(workerCtx) })
|
||||||
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
|
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
|
||||||
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
|
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
|
||||||
|
a.startWorker(func() { a.telegramMailWorker(workerCtx) })
|
||||||
return a, nil
|
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_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 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
|
`CREATE TRIGGER IF NOT EXISTS trg_mailbox_delete_status_webhook_outbox
|
||||||
AFTER DELETE ON mailboxes BEGIN
|
AFTER DELETE ON mailboxes BEGIN
|
||||||
DELETE FROM status_webhook_outbox WHERE mailbox_id=OLD.id;
|
DELETE FROM status_webhook_outbox WHERE mailbox_id=OLD.id;
|
||||||
|
|||||||
@@ -459,6 +459,10 @@ func systemSettingsPayload(settings SystemSettings) map[string]any {
|
|||||||
"externalImapGmailClientSecret": "",
|
"externalImapGmailClientSecret": "",
|
||||||
"externalImapOutlookClientId": settings.ExternalIMAPOutlookClientID,
|
"externalImapOutlookClientId": settings.ExternalIMAPOutlookClientID,
|
||||||
"externalImapOutlookClientSecret": "",
|
"externalImapOutlookClientSecret": "",
|
||||||
|
"telegramMailEnabled": settings.TelegramMailEnabled,
|
||||||
|
"telegramBotToken": "",
|
||||||
|
"telegramPrivateChatId": settings.TelegramPrivateChatID,
|
||||||
|
"telegramBodyMode": settings.TelegramBodyMode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ type Config struct {
|
|||||||
ExternalIMAPGmailClientSecret string
|
ExternalIMAPGmailClientSecret string
|
||||||
ExternalIMAPOutlookClientID string
|
ExternalIMAPOutlookClientID string
|
||||||
ExternalIMAPOutlookClientSecret string
|
ExternalIMAPOutlookClientSecret string
|
||||||
|
TelegramMailEnabled bool
|
||||||
|
TelegramBotToken string
|
||||||
|
TelegramPrivateChatID string
|
||||||
|
TelegramBodyMode string
|
||||||
MailTranslateEnabled bool
|
MailTranslateEnabled bool
|
||||||
MailTranslateMaxChars int
|
MailTranslateMaxChars int
|
||||||
DeliveryWebhookSecret string
|
DeliveryWebhookSecret string
|
||||||
@@ -110,6 +114,10 @@ func LoadConfig() Config {
|
|||||||
ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""),
|
ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""),
|
||||||
ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""),
|
ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""),
|
||||||
ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""),
|
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),
|
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", ""),
|
||||||
|
|||||||
@@ -292,7 +292,10 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox
|
|||||||
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
|
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
|
||||||
return false, nil
|
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
|
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)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(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/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(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
|
||||||
r.With(a.requirePermission(PermissionTemplatesUpdate)).Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
|
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)
|
r.With(a.requirePermission(PermissionTemplatesReset)).Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ type SystemSettings struct {
|
|||||||
ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"`
|
ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"`
|
||||||
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
|
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
|
||||||
ExternalIMAPOutlookClientSecretSet bool `json:"externalImapOutlookClientSecretSet"`
|
ExternalIMAPOutlookClientSecretSet bool `json:"externalImapOutlookClientSecretSet"`
|
||||||
|
TelegramMailEnabled bool `json:"telegramMailEnabled"`
|
||||||
|
TelegramBotTokenSet bool `json:"telegramBotTokenSet"`
|
||||||
|
TelegramPrivateChatID string `json:"telegramPrivateChatId"`
|
||||||
|
TelegramBodyMode string `json:"telegramBodyMode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type systemSettingsUpdate struct {
|
type systemSettingsUpdate struct {
|
||||||
@@ -73,6 +77,10 @@ type systemSettingsUpdate struct {
|
|||||||
ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"`
|
ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"`
|
||||||
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
|
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
|
||||||
ExternalIMAPOutlookClientSecret string `json:"externalImapOutlookClientSecret"`
|
ExternalIMAPOutlookClientSecret string `json:"externalImapOutlookClientSecret"`
|
||||||
|
TelegramMailEnabled bool `json:"telegramMailEnabled"`
|
||||||
|
TelegramBotToken string `json:"telegramBotToken"`
|
||||||
|
TelegramPrivateChatID string `json:"telegramPrivateChatId"`
|
||||||
|
TelegramBodyMode string `json:"telegramBodyMode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PublicSettings struct {
|
type PublicSettings struct {
|
||||||
@@ -204,6 +212,22 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
|
|||||||
badRequest(w, errors.New("外部 IMAP 加密密钥未设置"))
|
badRequest(w, errors.New("外部 IMAP 加密密钥未设置"))
|
||||||
return
|
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 {
|
if err := a.saveSystemSettings(r.Context(), next); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to save settings")
|
respondError(w, http.StatusInternalServerError, "failed to save settings")
|
||||||
@@ -318,6 +342,10 @@ func (a *App) systemSettingsSnapshot() SystemSettings {
|
|||||||
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "",
|
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "",
|
||||||
ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID,
|
ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID,
|
||||||
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPOutlookClientSecret) != "",
|
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
|
cfg.ExternalIMAPOutlookClientID = value
|
||||||
case "externalImapOutlookClientSecret":
|
case "externalImapOutlookClientSecret":
|
||||||
cfg.ExternalIMAPOutlookClientSecret = value
|
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 {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -443,6 +479,10 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
|
|||||||
"externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret,
|
"externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret,
|
||||||
"externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID,
|
"externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID,
|
||||||
"externalImapOutlookClientSecret": cfg.ExternalIMAPOutlookClientSecret,
|
"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)
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
tx, err := a.db.BeginTx(ctx, nil)
|
tx, err := a.db.BeginTx(ctx, nil)
|
||||||
|
|||||||
@@ -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 := "<b>NewSzxcn 邮箱通知测试</b>\n\nTelegram 私聊邮件通知连接正常。\n\n<b>测试时间:</b>" + 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{
|
||||||
|
"<b>收到新邮件</b>",
|
||||||
|
"",
|
||||||
|
"<b>发件人:</b>" + html.EscapeString(from),
|
||||||
|
"<b>收件邮箱:</b>" + html.EscapeString(payload.Recipient),
|
||||||
|
"<b>主题:</b>" + html.EscapeString(subject),
|
||||||
|
"<b>收件时间:</b>" + 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, "<b>附件:</b>"+strings.Join(names, "、"))
|
||||||
|
}
|
||||||
|
body := strings.TrimSpace(payload.Body)
|
||||||
|
if body != "" {
|
||||||
|
label := "正文摘要"
|
||||||
|
if normalizeTelegramBodyMode(payload.BodyMode) == "full" {
|
||||||
|
label = "邮件正文"
|
||||||
|
}
|
||||||
|
lines = append(lines, "", "<b>"+label+":</b>", "<blockquote>"+html.EscapeString(body)+"</blockquote>")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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: "这是邮件正文,包含 <VIP> & 续费信息。",
|
||||||
|
}, []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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -233,8 +233,13 @@ export type SystemSettings = {
|
|||||||
externalImapGmailClientSecretSet: boolean
|
externalImapGmailClientSecretSet: boolean
|
||||||
externalImapOutlookClientId: string
|
externalImapOutlookClientId: string
|
||||||
externalImapOutlookClientSecretSet: boolean
|
externalImapOutlookClientSecretSet: boolean
|
||||||
|
telegramMailEnabled: boolean
|
||||||
|
telegramBotTokenSet: boolean
|
||||||
|
telegramPrivateChatId: string
|
||||||
|
telegramBodyMode: "summary" | "full"
|
||||||
}
|
}
|
||||||
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: 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 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 }
|
||||||
|
|||||||
@@ -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"
|
export * from "./api-types"
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 15_000
|
const REQUEST_TIMEOUT_MS = 15_000
|
||||||
@@ -197,6 +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 }) }),
|
||||||
|
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) }),
|
||||||
resetMailTemplate: (key: string) => request<MailTemplate>(`/api/admin/mail-templates/${encodeURIComponent(key)}/reset`, { method: "POST" }),
|
resetMailTemplate: (key: string) => request<MailTemplate>(`/api/admin/mail-templates/${encodeURIComponent(key)}/reset`, { method: "POST" }),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
|||||||
import type { PermissionKey } from "@/lib/api-types"
|
import type { PermissionKey } 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" | "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 }
|
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||||
|
|
||||||
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
|
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
|
||||||
@@ -1042,7 +1042,7 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
|||||||
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
||||||
const requestedTab = initialTab as SettingsTab | undefined
|
const requestedTab = initialTab as SettingsTab | undefined
|
||||||
const [settingsTab, setSettingsTab] = React.useState<SettingsTab>(() => requestedTab && ["base", "smtp", "storage", "mail", "externalImap", "templates", "security", "about"].includes(requestedTab) ? requestedTab : "base")
|
const [settingsTab, setSettingsTab] = React.useState<SettingsTab>(() => 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 maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
|
||||||
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
||||||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
||||||
@@ -1055,6 +1055,10 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
|||||||
const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState<string[]>([])
|
const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState<string[]>([])
|
||||||
const [externalImapEnabled, setExternalImapEnabled] = React.useState(false)
|
const [externalImapEnabled, setExternalImapEnabled] = React.useState(false)
|
||||||
const [externalImapAllowPrivateHosts, setExternalImapAllowPrivateHosts] = 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(() => {
|
React.useEffect(() => {
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
setSmtpRequireTls(settings.smtpRequireTls)
|
setSmtpRequireTls(settings.smtpRequireTls)
|
||||||
@@ -1068,7 +1072,24 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
|||||||
setUserMailboxDomainIds(settings.userMailboxDomainIds || [])
|
setUserMailboxDomainIds(settings.userMailboxDomainIds || [])
|
||||||
setExternalImapEnabled(settings.externalImapEnabled)
|
setExternalImapEnabled(settings.externalImapEnabled)
|
||||||
setExternalImapAllowPrivateHosts(settings.externalImapAllowPrivateHosts)
|
setExternalImapAllowPrivateHosts(settings.externalImapAllowPrivateHosts)
|
||||||
|
setTelegramMailEnabled(settings.telegramMailEnabled)
|
||||||
|
setTelegramBotToken("")
|
||||||
|
setTelegramPrivateChatId(settings.telegramPrivateChatId || "")
|
||||||
|
setTelegramBodyMode(settings.telegramBodyMode === "full" ? "full" : "summary")
|
||||||
}, [settings])
|
}, [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({
|
const save = useMutation({
|
||||||
mutationFn: (form: FormData) => api.updateSystemSettings({
|
mutationFn: (form: FormData) => api.updateSystemSettings({
|
||||||
publicHostname: fieldValue(form, "publicHostname", settings?.publicHostname || ""),
|
publicHostname: fieldValue(form, "publicHostname", settings?.publicHostname || ""),
|
||||||
@@ -1101,6 +1122,10 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
|||||||
externalImapGmailClientSecret: fieldValue(form, "externalImapGmailClientSecret", ""),
|
externalImapGmailClientSecret: fieldValue(form, "externalImapGmailClientSecret", ""),
|
||||||
externalImapOutlookClientId: fieldValue(form, "externalImapOutlookClientId", settings?.externalImapOutlookClientId || ""),
|
externalImapOutlookClientId: fieldValue(form, "externalImapOutlookClientId", settings?.externalImapOutlookClientId || ""),
|
||||||
externalImapOutlookClientSecret: fieldValue(form, "externalImapOutlookClientSecret", ""),
|
externalImapOutlookClientSecret: fieldValue(form, "externalImapOutlookClientSecret", ""),
|
||||||
|
telegramMailEnabled,
|
||||||
|
telegramBotToken,
|
||||||
|
telegramPrivateChatId,
|
||||||
|
telegramBodyMode,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["admin", "settings"] })
|
qc.invalidateQueries({ queryKey: ["admin", "settings"] })
|
||||||
@@ -1142,6 +1167,10 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
|||||||
settings.externalImapGmailClientSecretSet,
|
settings.externalImapGmailClientSecretSet,
|
||||||
settings.externalImapOutlookClientId,
|
settings.externalImapOutlookClientId,
|
||||||
settings.externalImapOutlookClientSecretSet,
|
settings.externalImapOutlookClientSecretSet,
|
||||||
|
settings.telegramMailEnabled,
|
||||||
|
settings.telegramBotTokenSet,
|
||||||
|
settings.telegramPrivateChatId,
|
||||||
|
settings.telegramBodyMode,
|
||||||
].join("|") : "loading"
|
].join("|") : "loading"
|
||||||
const tabs: { key: typeof settingsTab; label: string }[] = [
|
const tabs: { key: typeof settingsTab; label: string }[] = [
|
||||||
...(canSettingsView ? [
|
...(canSettingsView ? [
|
||||||
@@ -1149,6 +1178,7 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
|||||||
{ key: "smtp" as const, label: "SMTP" },
|
{ key: "smtp" as const, label: "SMTP" },
|
||||||
{ key: "storage" as const, label: "存储" },
|
{ key: "storage" as const, label: "存储" },
|
||||||
{ key: "mail" as const, label: "邮件" },
|
{ key: "mail" as const, label: "邮件" },
|
||||||
|
{ key: "notifications" as const, label: "通知" },
|
||||||
{ key: "externalImap" as const, label: "外部 IMAP" },
|
{ key: "externalImap" as const, label: "外部 IMAP" },
|
||||||
] : []),
|
] : []),
|
||||||
...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []),
|
...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []),
|
||||||
@@ -1258,6 +1288,49 @@ function SystemSettingsSection({ settings, domains, initialTab }: { settings?: S
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>}
|
</Card>}
|
||||||
|
|
||||||
|
{settingsTab === "notifications" && <Card>
|
||||||
|
<CardHeader><CardTitle>Telegram 邮件通知</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<SwitchRow label="私聊新邮件通知" checked={telegramMailEnabled} onCheckedChange={setTelegramMailEnabled} />
|
||||||
|
{telegramMailEnabled && (
|
||||||
|
<div className="space-y-5 border-t pt-5">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Bot Token</Label>
|
||||||
|
<Input type="password" value={telegramBotToken} onChange={(event) => setTelegramBotToken(event.target.value)} placeholder={settings?.telegramBotTokenSet ? "已保存,留空不变" : "123456789:..."} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>私聊 Chat ID</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<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()}>
|
||||||
|
<Search className="mr-2 h-4 w-4" />{discoverTelegram.isPending ? "获取中" : "自动获取"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>邮件正文</Label>
|
||||||
|
<Select value={telegramBodyMode} onValueChange={(value) => setTelegramBodyMode(value === "full" ? "full" : "summary")}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="summary">正文摘要</SelectItem>
|
||||||
|
<SelectItem value="full">尽量显示完整正文</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button type="button" variant="outline" disabled={testTelegram.isPending || !telegramPrivateChatId} onClick={() => testTelegram.mutate()}>
|
||||||
|
<Mail className="mr-2 h-4 w-4" />{testTelegram.isPending ? "发送中" : "测试通知"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>}
|
||||||
|
|
||||||
{settingsTab === "externalImap" && <Card>
|
{settingsTab === "externalImap" && <Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>外部 IMAP 接入</CardTitle>
|
<CardTitle>外部 IMAP 接入</CardTitle>
|
||||||
|
|||||||
@@ -183,6 +183,22 @@ LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET=
|
|||||||
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID=
|
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID=
|
||||||
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET=
|
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
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
# 系统
|
# 系统
|
||||||
# =========================
|
# =========================
|
||||||
|
|||||||
@@ -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`。
|
- Postfix 读取 `/data/lanqin.db` 中的 `domains`、`mailboxes`、`aliases`。
|
||||||
|
|||||||
@@ -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 证书与自动续期
|
## SSL 证书与自动续期
|
||||||
|
|
||||||
选择“自动配置 Nginx + SSL”后,官方 `acme.sh` 会安装定时检查任务。证书接近到期时会自动续期,续期成功后自动重载 NewSzxcn Email 和 Nginx。
|
选择“自动配置 Nginx + SSL”后,官方 `acme.sh` 会安装定时检查任务。证书接近到期时会自动续期,续期成功后自动重载 NewSzxcn Email 和 Nginx。
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
| NSX-20260804-002 | 2026-08-04 | 已完成 | 前端/UI/响应式布局 | 邮箱选择器展开后宽度变窄 | S3 | v1.2.14 | 随 v1.2.14 发布 |
|
| 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-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 | 待发布 |
|
||||||
|
|
||||||
## NSX-20260804-001
|
## NSX-20260804-001
|
||||||
|
|
||||||
@@ -198,3 +199,28 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 2026-08-06 | 用户反馈“全部邮箱”右侧存在空白块并要求修改。 |
|
| 2026-08-06 | 用户反馈“全部邮箱”右侧存在空白块并要求修改。 |
|
||||||
| 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 重跑去重复核,状态流转为已完成。 |
|
||||||
|
|||||||
Reference in New Issue
Block a user