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