2026-08-06 02:17:11 +08:00
package app
import (
"bytes"
"context"
2026-08-06 17:13:08 +08:00
"crypto/rand"
"crypto/sha256"
"encoding/hex"
2026-08-06 02:17:11 +08:00
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/url"
2026-08-06 17:13:08 +08:00
"regexp"
"sort"
2026-08-06 02:17:11 +08:00
"strconv"
"strings"
"time"
2026-08-06 17:13:08 +08:00
"unicode"
"unicode/utf8"
nethtml "golang.org/x/net/html"
)
const (
telegramMailMaxAttempts = 8
telegramMessageBudget = 3800
telegramPairingTTL = 10 * time . Minute
2026-08-06 02:17:11 +08:00
)
2026-08-06 17:13:08 +08:00
type telegramPairing struct {
TokenFingerprint string
ExpiresAt time . Time
}
2026-08-06 02:17:11 +08:00
type telegramMailPayload struct {
From string `json:"from"`
FromName string `json:"fromName,omitempty"`
Recipient string `json:"recipient"`
Subject string `json:"subject"`
ReceivedAt string `json:"receivedAt"`
Body string `json:"body"`
BodyMode string `json:"bodyMode"`
2026-08-06 17:13:08 +08:00
OTP string `json:"otp,omitempty"`
2026-08-06 02:17:11 +08:00
AttachmentNames [] string `json:"attachmentNames,omitempty"`
2026-08-06 17:13:08 +08:00
AttachmentCount int `json:"attachmentCount,omitempty"`
2026-08-06 02:17:11 +08:00
}
type telegramCredentialsRequest struct {
2026-08-06 17:13:08 +08:00
BotToken string `json:"botToken"`
ChatID string `json:"chatId"`
PairingCode string `json:"pairingCode"`
2026-08-06 02:17:11 +08:00
}
type telegramAPIResponse struct {
OK bool `json:"ok"`
2026-08-06 17:13:08 +08:00
ErrorCode int `json:"error_code"`
2026-08-06 02:17:11 +08:00
Description string `json:"description"`
Result json . RawMessage `json:"result"`
2026-08-06 17:13:08 +08:00
Parameters struct {
RetryAfter int `json:"retry_after"`
} `json:"parameters"`
2026-08-06 02:17:11 +08:00
}
type telegramUpdate struct {
UpdateID int64 `json:"update_id"`
Message * struct {
2026-08-06 17:13:08 +08:00
Text string `json:"text"`
2026-08-06 02:17:11 +08:00
Chat struct {
ID int64 `json:"id"`
Type string `json:"type"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
} `json:"chat"`
} `json:"message"`
}
2026-08-06 17:13:08 +08:00
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
}
2026-08-06 02:17:11 +08:00
func normalizeTelegramBodyMode ( value string ) string {
if strings . EqualFold ( strings . TrimSpace ( value ), "full" ) {
return "full"
}
return "summary"
}
func validTelegramPrivateChatID ( value string ) bool {
id , err := strconv . ParseInt ( strings . TrimSpace ( value ), 10 , 64 )
return err == nil && id > 0
}
2026-08-06 17:13:08 +08:00
func ( a * App ) handleCreateTelegramPairing ( w http . ResponseWriter , r * http . Request ) {
2026-08-06 02:17:11 +08:00
var req telegramCredentialsRequest
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
token := strings . TrimSpace ( req . BotToken )
if token == "" {
token = strings . TrimSpace ( a . config (). TelegramBotToken )
}
if token == "" {
badRequest ( w , errors . New ( "请先填写 Telegram Bot Token" ))
return
}
2026-08-06 17:13:08 +08:00
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 )
2026-08-06 02:17:11 +08:00
if err != nil {
respondError ( w , http . StatusBadGateway , err . Error ())
return
}
2026-08-06 17:13:08 +08:00
a . telegramPairMu . Lock ()
delete ( a . telegramPairs , code )
a . telegramPairMu . Unlock ()
2026-08-06 02:17:11 +08:00
respondJSON ( w , http . StatusOK , map [ string ] string { "chatId" : chatID , "displayName" : displayName })
}
func ( a * App ) handleTestTelegram ( w http . ResponseWriter , r * http . Request ) {
var req telegramCredentialsRequest
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
token , chatID := a . telegramCredentials ( req )
if token == "" {
badRequest ( w , errors . New ( "请先填写 Telegram Bot Token" ))
return
}
if ! validTelegramPrivateChatID ( chatID ) {
badRequest ( w , errors . New ( "请先获取或填写有效的私聊 Chat ID" ))
return
}
now := a . now (). Local (). Format ( "2006-01-02 15:04:05 MST" )
text := "<b>NewSzxcn 邮箱通知测试</b>\n\nTelegram 私聊邮件通知连接正常。\n\n<b>测试时间:</b>" + html . EscapeString ( now )
if err := a . sendTelegramMessage ( r . Context (), token , chatID , text ); err != nil {
respondError ( w , http . StatusBadGateway , err . Error ())
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true })
}
func ( a * App ) telegramCredentials ( req telegramCredentialsRequest ) ( string , string ) {
cfg := a . config ()
token := strings . TrimSpace ( req . BotToken )
if token == "" {
token = strings . TrimSpace ( cfg . TelegramBotToken )
}
chatID := strings . TrimSpace ( req . ChatID )
if chatID == "" {
chatID = strings . TrimSpace ( cfg . TelegramPrivateChatID )
}
return token , chatID
}
2026-08-06 17:13:08 +08:00
func ( a * App ) discoverTelegramPrivateChat ( ctx context . Context , token , pairingCode string ) ( string , string , error ) {
2026-08-06 02:17:11 +08:00
var updates [] telegramUpdate
if err := a . callTelegram ( ctx , token , "getUpdates" , map [ string ] any {
"limit" : 100 ,
"timeout" : 0 ,
"allowed_updates" : [] string { "message" },
}, & updates ); err != nil {
return "" , "" , err
}
for i := len ( updates ) - 1 ; i >= 0 ; i -- {
message := updates [ i ]. Message
if message == nil || message . Chat . Type != "private" || message . Chat . ID <= 0 {
continue
}
2026-08-06 17:13:08 +08:00
text := strings . TrimSpace ( message . Text )
if text != pairingCode && text != "/start " + pairingCode {
continue
}
2026-08-06 02:17:11 +08:00
name := strings . TrimSpace ( strings . Join ([] string { message . Chat . FirstName , message . Chat . LastName }, " " ))
if name == "" && message . Chat . Username != "" {
name = "@" + message . Chat . Username
}
return strconv . FormatInt ( message . Chat . ID , 10 ), name , nil
}
2026-08-06 17:13:08 +08:00
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
2026-08-06 02:17:11 +08:00
}
func ( a * App ) enqueueTelegramMailNotification ( ctx context . Context , messageID string , msg storedMessage , attachments [] AttachmentInput ) {
cfg := a . config ()
2026-08-06 17:13:08 +08:00
if ! cfg . TelegramMailEnabled || strings . TrimSpace ( cfg . TelegramBotToken ) == "" || ! validTelegramPrivateChatID ( cfg . TelegramPrivateChatID ) || ! telegramMailboxAllowed ( cfg , msg . MailboxID ) {
2026-08-06 02:17:11 +08:00
return
}
recipient := normalizeEmail ( msg . RecipientAddr )
if recipient == "" && len ( msg . To ) > 0 {
recipient = normalizeEmail ( msg . To [ 0 ])
}
2026-08-06 17:13:08 +08:00
body := telegramMessageBody ( msg )
otp := detectTelegramOTP ( msg . Subject , body )
2026-08-06 02:17:11 +08:00
mode := normalizeTelegramBodyMode ( cfg . TelegramBodyMode )
limit := 800
if mode == "full" {
limit = 2600
}
2026-08-06 17:13:08 +08:00
body , truncated := truncateRunes ( body , limit )
2026-08-06 02:17:11 +08:00
if truncated {
body += "..."
}
if body == "" {
2026-08-06 17:13:08 +08:00
body = normalizeTelegramText ( msg . Snippet )
2026-08-06 02:17:11 +08:00
}
2026-08-06 17:13:08 +08:00
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 ))
2026-08-06 02:17:11 +08:00
for _ , attachment := range attachments {
2026-08-06 17:13:08 +08:00
name := sanitizeTelegramAttachmentName ( attachment . Filename )
2026-08-06 02:17:11 +08:00
if name != "" {
names = append ( names , name )
}
2026-08-06 17:13:08 +08:00
if len ( names ) >= 5 {
2026-08-06 02:17:11 +08:00
break
}
}
payload := telegramMailPayload {
2026-08-06 17:13:08 +08:00
From : from ,
FromName : fromName ,
2026-08-06 02:17:11 +08:00
Recipient : recipient ,
2026-08-06 17:13:08 +08:00
Subject : subject ,
ReceivedAt : a . now (). UTC (). Format ( time . RFC3339Nano ),
2026-08-06 02:17:11 +08:00
Body : body ,
BodyMode : mode ,
2026-08-06 17:13:08 +08:00
OTP : otp ,
2026-08-06 02:17:11 +08:00
AttachmentNames : names ,
2026-08-06 17:13:08 +08:00
AttachmentCount : len ( attachments ),
2026-08-06 02:17:11 +08:00
}
now := a . now (). UTC (). Format ( time . RFC3339Nano )
if _ , err := a . db . ExecContext ( ctx , `INSERT OR IGNORE INTO telegram_mail_outbox(id,message_id,payload_json,next_attempt_at,created_at,updated_at) VALUES(?,?,?,?,?,?)` , newID ( "tgm" ), messageID , jsonEncode ( payload ), now , now , now ); err != nil {
a . log . Warn ( "failed to enqueue Telegram mail notification" , "messageId" , messageID , "error" , err )
}
}
2026-08-06 17:13:08 +08:00
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}` )
2026-08-07 13:29:03 +08:00
telegramEmailRe = regexp . MustCompile ( `(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}` )
2026-08-06 18:53:21 +08:00
telegramURLRe = regexp . MustCompile ( `(?i)https?://[^\s<>"']+` )
2026-08-06 17:13:08 +08:00
)
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 ))
2026-08-07 13:29:03 +08:00
excludedRanges := append ( telegramEmailRe . FindAllStringIndex ( text , - 1 ), telegramURLRe . FindAllStringIndex ( text , - 1 ) ... )
2026-08-06 17:13:08 +08:00
for _ , match := range telegramOTPCandidateRe . FindAllStringIndex ( text , - 1 ) {
2026-08-07 13:29:03 +08:00
if telegramRangeOverlaps ( match , excludedRanges ) {
continue
}
2026-08-06 17:13:08 +08:00
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
}
2026-08-06 18:53:21 +08:00
if isTelegramOTPNonCode ( value ) {
continue
}
2026-08-06 17:13:08 +08:00
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
}
2026-08-07 13:29:03 +08:00
func telegramRangeOverlaps ( candidate [] int , ranges [][] int ) bool {
for _ , item := range ranges {
if len ( item ) == 2 && candidate [ 0 ] < item [ 1 ] && candidate [ 1 ] > item [ 0 ] {
return true
}
}
return false
}
2026-08-06 18:53:21 +08:00
func isTelegramOTPNonCode ( value string ) bool {
if len ( value ) == 4 {
if year , err := strconv . Atoi ( value ); err == nil && year >= 1900 && year <= 2099 {
return true
}
}
if len ( value ) == 8 {
if _ , err := time . Parse ( "20060102" , value ); err == nil {
return true
}
}
return false
}
2026-08-06 17:13:08 +08:00
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
}
}
2026-08-06 02:17:11 +08:00
func ( a * App ) telegramMailWorker ( ctx context . Context ) {
a . log . Info ( "Telegram mail notification worker started" )
ticker := time . NewTicker ( 5 * time . Second )
defer ticker . Stop ()
for {
if err := a . processDueTelegramMailNotifications ( ctx ); err != nil && ! errors . Is ( err , context . Canceled ) {
a . log . Warn ( "Telegram mail notification worker failed" , "error" , err )
}
select {
case <- ctx . Done ():
a . log . Info ( "Telegram mail notification worker stopped" )
return
case <- ticker . C :
}
}
}
func ( a * App ) processDueTelegramMailNotifications ( ctx context . Context ) error {
2026-08-06 17:13:08 +08:00
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 )
2026-08-06 02:17:11 +08:00
cfg := a . config ()
if ! cfg . TelegramMailEnabled || strings . TrimSpace ( cfg . TelegramBotToken ) == "" || ! validTelegramPrivateChatID ( cfg . TelegramPrivateChatID ) {
return nil
}
2026-08-06 17:13:08 +08:00
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<=? AND (lease_until='' OR lease_until<=?) ORDER BY next_attempt_at,created_at LIMIT 20` , telegramMailMaxAttempts , nowText , nowText )
2026-08-06 02:17:11 +08:00
if err != nil {
return err
}
type queueItem struct {
id string
payload telegramMailPayload
attempt int
2026-08-06 17:13:08 +08:00
invalid bool
2026-08-06 02:17:11 +08:00
}
items := [] queueItem {}
for rows . Next () {
var item queueItem
var raw string
if err := rows . Scan ( & item . id , & raw , & item . attempt ); err != nil {
rows . Close ()
return err
}
if err := json . Unmarshal ([] byte ( raw ), & item . payload ); err != nil {
2026-08-06 17:13:08 +08:00
item . invalid = true
2026-08-06 02:17:11 +08:00
}
items = append ( items , item )
}
if err := rows . Close (); err != nil {
return err
}
for _ , item := range items {
2026-08-06 17:13:08 +08:00
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
}
2026-08-06 02:17:11 +08:00
now := a . now (). UTC ()
2026-08-06 17:13:08 +08:00
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 {
return err
}
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 ()
2026-08-06 02:17:11 +08:00
if err != nil {
2026-08-06 17:13:08 +08:00
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
}
2026-08-06 02:17:11 +08:00
continue
}
stamp := now . Format ( time . RFC3339Nano )
2026-08-06 17:13:08 +08:00
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
}
2026-08-06 02:17:11 +08:00
}
return nil
}
2026-08-06 17:13:08 +08:00
func formatTelegramMailMessage ( payload telegramMailPayload ) telegramFormattedMessage {
2026-08-06 02:17:11 +08:00
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" )
}
2026-08-06 17:13:08 +08:00
subject , _ = truncateRunes ( subject , 180 )
from , _ = truncateRunes ( from , 220 )
recipient , _ := truncateRunes ( strings . TrimSpace ( payload . Recipient ), 160 )
2026-08-06 02:17:11 +08:00
lines := [] string {
2026-08-06 17:13:08 +08:00
"📩 <b>新邮件通知</b>" ,
2026-08-06 02:17:11 +08:00
"" ,
2026-08-06 17:13:08 +08:00
"<b>主题:</b>" + escapeTelegramWithinBudget ( subject , 420 ),
"<b>发件人:</b>" + escapeTelegramWithinBudget ( from , 500 ),
"<b>收件邮箱:</b><code>" + escapeTelegramWithinBudget ( recipient , 320 ) + "</code>" ,
2026-08-06 02:17:11 +08:00
"<b>收件时间:</b>" + html . EscapeString ( timeText ),
}
2026-08-06 17:13:08 +08:00
if payload . OTP != "" {
lines = append ( lines , "" , "🔐 <b>验证码</b>" , "<code>" + html . EscapeString ( payload . OTP ) + "</code>" )
}
2026-08-06 02:17:11 +08:00
if len ( payload . AttachmentNames ) > 0 {
names := make ([] string , 0 , len ( payload . AttachmentNames ))
for _ , name := range payload . AttachmentNames {
2026-08-06 17:13:08 +08:00
names = append ( names , escapeTelegramWithinBudget ( name , 180 ))
}
attachmentText := strings . Join ( names , "、" )
if payload . AttachmentCount > len ( payload . AttachmentNames ) {
attachmentText += fmt . Sprintf ( ",其余 %d 个未显示" , payload . AttachmentCount - len ( payload . AttachmentNames ))
2026-08-06 02:17:11 +08:00
}
2026-08-06 17:13:08 +08:00
lines = append ( lines , "" , fmt . Sprintf ( "📎 <b>附件:%d 个</b>" , max ( payload . AttachmentCount , len ( payload . AttachmentNames ))), attachmentText )
2026-08-06 02:17:11 +08:00
}
body := strings . TrimSpace ( payload . Body )
if body != "" {
label := "正文摘要"
if normalizeTelegramBodyMode ( payload . BodyMode ) == "full" {
label = "邮件正文"
}
2026-08-06 17:13:08 +08:00
prefix := strings . Join ( lines , "\n" ) + "\n\n<b>" + label + "</b>\n<blockquote>"
suffix := "</blockquote>"
2026-08-06 18:53:21 +08:00
body = formatTelegramBodyHTML ( body , telegramMessageBudget - utf8 . RuneCountInString ( prefix ) - utf8 . RuneCountInString ( suffix ))
2026-08-06 17:13:08 +08:00
lines = [] string { prefix + body + suffix }
}
htmlText := strings . Join ( lines , "\n" )
plain := formatTelegramMailPlainText ( payload )
return telegramFormattedMessage { HTML : htmlText , PlainText : plain , OTP : payload . OTP }
2026-08-06 02:17:11 +08:00
}
2026-08-06 18:53:21 +08:00
func formatTelegramBodyHTML ( value string , budget int ) string {
if budget <= 3 {
return ""
}
var out strings . Builder
used := 0
truncated := false
appendEscaped := func ( text string ) bool {
for _ , r := range text {
escaped := html . EscapeString ( string ( r ))
length := utf8 . RuneCountInString ( escaped )
if used + length > budget - 3 {
return false
}
out . WriteString ( escaped )
used += length
}
return true
}
last := 0
for _ , match := range telegramURLRe . FindAllStringIndex ( value , - 1 ) {
if ! appendEscaped ( value [ last : match [ 0 ]]) {
truncated = true
break
}
rawURL , trailing := trimTelegramURL ( value [ match [ 0 ]: match [ 1 ]])
parsed , err := url . Parse ( rawURL )
if err != nil || parsed . Host == "" || ( parsed . Scheme != "http" && parsed . Scheme != "https" ) {
if ! appendEscaped ( value [ match [ 0 ]: match [ 1 ]]) {
truncated = true
break
}
last = match [ 1 ]
continue
}
display := rawURL
if utf8 . RuneCountInString ( display ) > 72 {
display = "🔗 " + parsed . Hostname () + " 链接"
}
anchor := `<a href="` + html . EscapeString ( rawURL ) + `">` + html . EscapeString ( display ) + `</a>`
length := utf8 . RuneCountInString ( anchor )
if used + length > budget - 3 {
truncated = true
break
}
out . WriteString ( anchor )
used += length
if ! appendEscaped ( trailing ) {
truncated = true
break
}
last = match [ 1 ]
}
if ! truncated && last < len ( value ) && ! appendEscaped ( value [ last :]) {
truncated = true
}
if truncated {
out . WriteString ( "..." )
}
return out . String ()
}
func trimTelegramURL ( value string ) ( string , string ) {
trimmed := strings . TrimRight ( value , ".,;:!?)]},。;:!?)》】" )
return trimmed , value [ len ( trimmed ):]
}
2026-08-06 02:17:11 +08:00
func ( a * App ) sendTelegramMessage ( ctx context . Context , token , chatID , text string ) error {
2026-08-06 17:13:08 +08:00
_ , err := a . sendTelegramPayload ( ctx , token , map [ string ] any {
2026-08-06 02:17:11 +08:00
"chat_id" : chatID ,
"text" : text ,
"parse_mode" : "HTML" ,
"disable_web_page_preview" : true ,
2026-08-06 17:13:08 +08:00
})
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
2026-08-06 02:17:11 +08:00
}
func ( a * App ) callTelegram ( ctx context . Context , token , method string , payload any , result any ) error {
token = strings . TrimSpace ( token )
if token == "" || strings . ContainsAny ( token , "/\\\r\n" ) {
return errors . New ( "Telegram Bot Token 无效" )
}
base := strings . TrimRight ( strings . TrimSpace ( a . telegramURL ), "/" )
endpoint := base + "/bot" + url . PathEscape ( token ) + "/" + method
body , err := json . Marshal ( payload )
if err != nil {
return err
}
req , err := http . NewRequestWithContext ( ctx , http . MethodPost , endpoint , bytes . NewReader ( body ))
if err != nil {
return err
}
req . Header . Set ( "Content-Type" , "application/json" )
req . Header . Set ( "User-Agent" , "NewSzxcn-Email-Telegram/1.0" )
client := & http . Client { Timeout : 12 * time . Second , CheckRedirect : func ( * http . Request , [] * http . Request ) error { return http . ErrUseLastResponse }}
resp , err := client . Do ( req )
if err != nil {
return errors . New ( "Telegram 请求失败,请检查网络连接和机器人配置" )
}
defer resp . Body . Close ()
raw , err := io . ReadAll ( io . LimitReader ( resp . Body , 1 << 20 ))
if err != nil {
return err
}
var apiResponse telegramAPIResponse
if err := json . Unmarshal ( raw , & apiResponse ); err != nil {
return fmt . Errorf ( "Telegram 返回了无效响应(HTTP %d) " , resp . StatusCode )
}
if resp . StatusCode < 200 || resp . StatusCode >= 300 || ! apiResponse . OK {
description := strings . TrimSpace ( apiResponse . Description )
if description == "" {
description = fmt . Sprintf ( "HTTP %d" , resp . StatusCode )
}
2026-08-06 17:13:08 +08:00
return & telegramAPIError { HTTPStatus : resp . StatusCode , ErrorCode : apiResponse . ErrorCode , Description : description , RetryAfter : time . Duration ( apiResponse . Parameters . RetryAfter ) * time . Second }
2026-08-06 02:17:11 +08:00
}
if result != nil && len ( apiResponse . Result ) > 0 {
if err := json . Unmarshal ( apiResponse . Result , result ); err != nil {
return err
}
}
return nil
}