feat(channels): add email channel with IMAP/SMTP and attachments
- IMAP: use_idle for IDLE when supported, else poll with check_interval - Attachments saved under configurable attachment_dir - Tested locally with QQ/163 mailboxes
This commit is contained in:
parent
8807d8254f
commit
0367b33f52
6 changed files with 976 additions and 0 deletions
|
|
@ -70,6 +70,22 @@
|
||||||
"reconnect_interval": 5,
|
"reconnect_interval": 5,
|
||||||
"group_trigger_prefix": [],
|
"group_trigger_prefix": [],
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"enabled": false,
|
||||||
|
"imap_server": "imap.example.com--eg:imap.qq.com",
|
||||||
|
"imap_port": 993,
|
||||||
|
"username": "your_email@example.com",
|
||||||
|
"password": "your_password",
|
||||||
|
"mailbox": "INBOX",
|
||||||
|
"check_interval": 30,
|
||||||
|
"use_tls": true,
|
||||||
|
"allow_from": ["allowed_email@example.com"],
|
||||||
|
"attachment_dir": "~/.picoclaw/workspace/attachments",
|
||||||
|
"use_idle": true,
|
||||||
|
"smtp_server": "smtp.example.com--eg:smtp.qq.com",
|
||||||
|
"smtp_port": 465,
|
||||||
|
"smtp_use_tls": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
|
||||||
5
go.mod
5
go.mod
|
|
@ -8,6 +8,8 @@ require (
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
github.com/chzyer/readline v1.5.1
|
github.com/chzyer/readline v1.5.1
|
||||||
|
github.com/emersion/go-imap v1.2.1
|
||||||
|
github.com/emersion/go-message v0.17.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
|
|
@ -18,10 +20,13 @@ require (
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/tencent-connect/botgo v0.2.1
|
github.com/tencent-connect/botgo v0.2.1
|
||||||
golang.org/x/oauth2 v0.35.0
|
golang.org/x/oauth2 v0.35.0
|
||||||
|
golang.org/x/text v0.34.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
|
||||||
|
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
12
go.sum
12
go.sum
|
|
@ -30,6 +30,15 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
|
||||||
|
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
|
||||||
|
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
|
||||||
|
github.com/emersion/go-message v0.17.0 h1:NIdSKHiVUx4qKqdd0HyJFD41cW8iFguM2XJnRZWQH04=
|
||||||
|
github.com/emersion/go-message v0.17.0/go.mod h1:/9Bazlb1jwUNB0npYYBsdJ2EMOiiyN3m5UVHbY7GoNw=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
|
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
|
||||||
|
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
||||||
|
|
@ -223,7 +232,10 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|
|
||||||
897
pkg/channels/email.go
Normal file
897
pkg/channels/email.go
Normal file
|
|
@ -0,0 +1,897 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net"
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emersion/go-imap"
|
||||||
|
"github.com/emersion/go-imap/client"
|
||||||
|
charset "github.com/emersion/go-message/charset"
|
||||||
|
"github.com/emersion/go-message/mail"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Register GBK so go-message can decode mail body (e.g. QQ/163 mailboxes); otherwise "unhandled charset \"gbk\"".
|
||||||
|
charset.RegisterEncoding("gbk", simplifiedchinese.GBK)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailChannel struct {
|
||||||
|
*BaseChannel
|
||||||
|
config config.EmailConfig
|
||||||
|
imapClient *client.Client
|
||||||
|
lastUID uint32
|
||||||
|
mu sync.Mutex
|
||||||
|
cancel context.CancelFunc
|
||||||
|
checkTicker *time.Ticker
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEmailChannel(cfg config.EmailConfig, bus *bus.MessageBus) (*EmailChannel, error) {
|
||||||
|
base := NewBaseChannel("email", cfg, bus, cfg.AllowFrom)
|
||||||
|
return &EmailChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfg,
|
||||||
|
lastUID: 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EmailChannel) Start(ctx context.Context) error {
|
||||||
|
if !c.config.Enabled {
|
||||||
|
return fmt.Errorf("email channel is not enabled")
|
||||||
|
}
|
||||||
|
if c.config.IMAPServer == "" || c.config.Username == "" || c.config.Password == "" {
|
||||||
|
return fmt.Errorf("email IMAP server, username or password is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoC("email", "Starting Email channel")
|
||||||
|
|
||||||
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
|
c.mu.Lock()
|
||||||
|
c.cancel = cancel
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if err := c.connect(); err != nil {
|
||||||
|
cancel()
|
||||||
|
return fmt.Errorf("failed to connect to IMAP server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setRunning(true)
|
||||||
|
logger.InfoC("email", "Email channel started")
|
||||||
|
|
||||||
|
go c.checkLoop(runCtx)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EmailChannel) Stop(ctx context.Context) error {
|
||||||
|
logger.InfoC("email", "Stopping Email channel")
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
c.cancel = nil
|
||||||
|
}
|
||||||
|
if c.checkTicker != nil {
|
||||||
|
c.checkTicker.Stop()
|
||||||
|
c.checkTicker = nil
|
||||||
|
}
|
||||||
|
if c.imapClient != nil {
|
||||||
|
c.imapClient.Logout()
|
||||||
|
c.imapClient = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
c.setRunning(false)
|
||||||
|
logger.InfoC("email", "Email channel stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EmailChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return fmt.Errorf("email channel not running")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.config.SMTPServer) == "" {
|
||||||
|
return fmt.Errorf("email channel send: SMTP not configured (set smtp_server)")
|
||||||
|
}
|
||||||
|
|
||||||
|
from := c.config.Username
|
||||||
|
to := strings.TrimSpace(msg.ChatID)
|
||||||
|
if to == "" {
|
||||||
|
return fmt.Errorf("email channel send: missing recipient (chat_id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain-text message: From / To / Subject / Body (OutboundMessage has no Metadata, use fixed subject)
|
||||||
|
subject := "Reply from PicoClaw"
|
||||||
|
header := map[string]string{
|
||||||
|
"From": from,
|
||||||
|
"To": to,
|
||||||
|
"Subject": subject,
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
}
|
||||||
|
var raw strings.Builder
|
||||||
|
for k, v := range header {
|
||||||
|
raw.WriteString(k + ": " + v + "\r\n")
|
||||||
|
}
|
||||||
|
raw.WriteString("\r\n")
|
||||||
|
raw.WriteString(msg.Content)
|
||||||
|
body := raw.String()
|
||||||
|
|
||||||
|
port := c.config.SMTPPort
|
||||||
|
if port <= 0 {
|
||||||
|
port = 465
|
||||||
|
}
|
||||||
|
addr := fmt.Sprintf("%s:%d", c.config.SMTPServer, port)
|
||||||
|
host := c.config.SMTPServer
|
||||||
|
|
||||||
|
if c.config.SMTPUseTLS {
|
||||||
|
// Port 465: implicit TLS
|
||||||
|
tlsConfig := &tls.Config{ServerName: host}
|
||||||
|
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp tls dial: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
client, err := smtp.NewClient(conn, host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp new client: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
auth := smtp.PlainAuth("", c.config.Username, c.config.Password, host)
|
||||||
|
if err = client.Auth(auth); err != nil {
|
||||||
|
return fmt.Errorf("smtp auth: %w", err)
|
||||||
|
}
|
||||||
|
if err = client.Mail(from); err != nil {
|
||||||
|
return fmt.Errorf("smtp mail: %w", err)
|
||||||
|
}
|
||||||
|
if err = client.Rcpt(to); err != nil {
|
||||||
|
return fmt.Errorf("smtp rcpt: %w", err)
|
||||||
|
}
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp data: %w", err)
|
||||||
|
}
|
||||||
|
if _, err = w.Write([]byte(body)); err != nil {
|
||||||
|
_ = w.Close()
|
||||||
|
return fmt.Errorf("smtp write: %w", err)
|
||||||
|
}
|
||||||
|
if err = w.Close(); err != nil {
|
||||||
|
return fmt.Errorf("smtp data close: %w", err)
|
||||||
|
}
|
||||||
|
return client.Quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port 587 etc.: TCP first, then STARTTLS if needed
|
||||||
|
conn, err := net.Dial("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp dial: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
client, err := smtp.NewClient(conn, host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp new client: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
if err = client.StartTLS(&tls.Config{ServerName: host}); err != nil {
|
||||||
|
// Some servers on 587 do not require STARTTLS; continue anyway
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
auth := smtp.PlainAuth("", c.config.Username, c.config.Password, host)
|
||||||
|
if err = client.Auth(auth); err != nil {
|
||||||
|
return fmt.Errorf("smtp auth: %w", err)
|
||||||
|
}
|
||||||
|
if err = client.Mail(from); err != nil {
|
||||||
|
return fmt.Errorf("smtp mail: %w", err)
|
||||||
|
}
|
||||||
|
if err = client.Rcpt(to); err != nil {
|
||||||
|
return fmt.Errorf("smtp rcpt: %w", err)
|
||||||
|
}
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp data: %w", err)
|
||||||
|
}
|
||||||
|
if _, err = w.Write([]byte(body)); err != nil {
|
||||||
|
_ = w.Close()
|
||||||
|
return fmt.Errorf("smtp write: %w", err)
|
||||||
|
}
|
||||||
|
if err = w.Close(); err != nil {
|
||||||
|
return fmt.Errorf("smtp data close: %w", err)
|
||||||
|
}
|
||||||
|
return client.Quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EmailChannel) connect() error {
|
||||||
|
address := fmt.Sprintf("%s:%d", c.config.IMAPServer, c.config.IMAPPort)
|
||||||
|
|
||||||
|
var cl *client.Client
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if c.config.UseTLS {
|
||||||
|
cl, err = client.DialTLS(address, nil)
|
||||||
|
} else {
|
||||||
|
cl, err = client.Dial(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login
|
||||||
|
if err := cl.Login(c.config.Username, c.config.Password); err != nil {
|
||||||
|
cl.Logout()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.imapClient = cl
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
// Select mailbox
|
||||||
|
mailbox := c.config.Mailbox
|
||||||
|
if mailbox == "" {
|
||||||
|
mailbox = "INBOX"
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := cl.Select(mailbox, false)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "Unsafe Login") || strings.Contains(err.Error(), "不安全") {
|
||||||
|
return fmt.Errorf("failed to select mailbox %s: %w (hint: 163/QQ/126 require app password, not account password)", mailbox, err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to select mailbox %s: %w", mailbox, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First connect: init lastUID from Select's UidNext (max current UID = UidNext-1) to avoid full UidSearch
|
||||||
|
if status != nil && status.UidNext > 0 {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.lastUID = status.UidNext - 1
|
||||||
|
c.mu.Unlock()
|
||||||
|
} else {
|
||||||
|
// Fallback: some servers do not return UidNext, search all to get max UID
|
||||||
|
if err := c.syncLastUID(cl); err != nil {
|
||||||
|
cl.Logout()
|
||||||
|
return fmt.Errorf("failed to sync mailbox UID: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("email", "Connected to IMAP server", map[string]interface{}{
|
||||||
|
"server": c.config.IMAPServer,
|
||||||
|
"mailbox": mailbox,
|
||||||
|
"last_uid": c.lastUID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncLastUID fetches the mailbox max UID and sets lastUID so only mail after connect is processed.
|
||||||
|
func (c *EmailChannel) syncLastUID(cl *client.Client) error {
|
||||||
|
criteria := imap.NewSearchCriteria()
|
||||||
|
uids, err := cl.UidSearch(criteria)
|
||||||
|
if err != nil {
|
||||||
|
// Some servers require a condition; UID 1:* means all
|
||||||
|
all := new(imap.SeqSet)
|
||||||
|
all.AddRange(1, 4294967295)
|
||||||
|
criteria.Uid = all
|
||||||
|
uids, err = cl.UidSearch(criteria)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var maxUID uint32
|
||||||
|
for _, uid := range uids {
|
||||||
|
if uid > maxUID {
|
||||||
|
maxUID = uid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.lastUID = maxUID
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EmailChannel) checkLoop(ctx context.Context) {
|
||||||
|
interval := time.Duration(c.config.CheckInterval) * time.Second
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run one check immediately
|
||||||
|
c.checkNewEmails()
|
||||||
|
|
||||||
|
if c.config.UseIdle {
|
||||||
|
c.runIdleLoop(ctx, interval)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polling mode
|
||||||
|
c.mu.Lock()
|
||||||
|
c.checkTicker = time.NewTicker(interval)
|
||||||
|
ticker := c.checkTicker
|
||||||
|
c.mu.Unlock()
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
c.checkNewEmails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runIdleLoop uses IMAP IDLE (RFC 2177). When the server pushes a mailbox update (e.g. * EXISTS for new mail),
|
||||||
|
// we receive it on Client.Updates, close the IDLE stop channel so Idle() returns, then run checkNewEmails().
|
||||||
|
// If the server does not support IDLE, go-imap falls back to polling with PollInterval.
|
||||||
|
func (c *EmailChannel) runIdleLoop(ctx context.Context, pollInterval time.Duration) {
|
||||||
|
opts := &client.IdleOptions{
|
||||||
|
LogoutTimeout: 25 * time.Minute, // restart IDLE periodically to avoid server disconnect
|
||||||
|
PollInterval: pollInterval, // used when server does not support IDLE
|
||||||
|
}
|
||||||
|
// Buffered channel for server unilateral updates (EXISTS, EXPUNGE, etc.); prevents client from blocking.
|
||||||
|
updatesCh := make(chan client.Update, 32)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
cl := c.imapClient
|
||||||
|
c.mu.Unlock()
|
||||||
|
if cl == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cl.State() != imap.SelectedState {
|
||||||
|
if err := c.connect(); err != nil {
|
||||||
|
logger.ErrorCF("email", "Failed to reconnect in IDLE loop", map[string]interface{}{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cl.Updates = updatesCh
|
||||||
|
stop := make(chan struct{})
|
||||||
|
idleDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
idleDone <- cl.Idle(stop, opts)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
close(stop)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
close(stop)
|
||||||
|
<-idleDone
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.imapClient != nil {
|
||||||
|
c.imapClient.Updates = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
case <-updatesCh:
|
||||||
|
// Server sent e.g. * EXISTS (new mail); exit IDLE and check
|
||||||
|
close(stop)
|
||||||
|
if err := <-idleDone; err != nil {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.imapClient != nil {
|
||||||
|
c.imapClient.Updates = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
logger.ErrorCF("email", "IDLE ended with error after update", map[string]interface{}{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.checkNewEmails()
|
||||||
|
case err := <-idleDone:
|
||||||
|
// Idle returned (timeout restart or error)
|
||||||
|
if err != nil {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.imapClient != nil {
|
||||||
|
c.imapClient.Updates = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
logger.ErrorCF("email", "IDLE ended with error", map[string]interface{}{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.checkNewEmails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EmailChannel) checkNewEmails() {
|
||||||
|
c.mu.Lock()
|
||||||
|
cl := c.imapClient
|
||||||
|
lastUID := c.lastUID
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if cl == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check connection state
|
||||||
|
if cl.State() != imap.SelectedState {
|
||||||
|
// Reconnect
|
||||||
|
if err := c.connect(); err != nil {
|
||||||
|
logger.ErrorCF("email", "Failed to reconnect to IMAP server", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
cl = c.imapClient
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process mail after recorded lastUID (search by UID range, not by unread)
|
||||||
|
criteria := imap.NewSearchCriteria()
|
||||||
|
if lastUID > 0 {
|
||||||
|
// Build SeqSet for UID range (lastUID+1 to max)
|
||||||
|
seqset := new(imap.SeqSet)
|
||||||
|
seqset.AddRange(lastUID+1, 0)
|
||||||
|
criteria.Uid = seqset
|
||||||
|
criteria.WithoutFlags = []string{imap.SeenFlag}
|
||||||
|
} else {
|
||||||
|
// First run: fetch only unread
|
||||||
|
criteria.WithoutFlags = []string{imap.SeenFlag}
|
||||||
|
}
|
||||||
|
|
||||||
|
uids, err := cl.UidSearch(criteria)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("email", "Failed to search emails", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(uids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSet := new(imap.SeqSet)
|
||||||
|
fetchSet.AddNum(uids...)
|
||||||
|
|
||||||
|
messages := make(chan *imap.Message, 10)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
bodySection := &imap.BodySectionName{}
|
||||||
|
done <- cl.UidFetch(fetchSet, []imap.FetchItem{
|
||||||
|
imap.FetchEnvelope,
|
||||||
|
imap.FetchBodyStructure,
|
||||||
|
bodySection.FetchItem(),
|
||||||
|
}, messages)
|
||||||
|
}()
|
||||||
|
|
||||||
|
maxUID := uint32(0)
|
||||||
|
for msg := range messages {
|
||||||
|
if msg.Uid > maxUID {
|
||||||
|
maxUID = msg.Uid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the message
|
||||||
|
c.processEmail(msg)
|
||||||
|
|
||||||
|
// Mark as seen after fully read
|
||||||
|
seenSet := new(imap.SeqSet)
|
||||||
|
seenSet.AddNum(msg.Uid)
|
||||||
|
if err := cl.UidStore(seenSet, imap.FormatFlagsOp(imap.AddFlags, true), []interface{}{imap.SeenFlag}, nil); err != nil {
|
||||||
|
logger.DebugCF("email", "Failed to mark email as seen", map[string]interface{}{
|
||||||
|
"uid": msg.Uid, "error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
logger.ErrorCF("email", "Failed to fetch emails", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last processed UID
|
||||||
|
if maxUID > 0 {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.lastUID = maxUID
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EmailChannel) processEmail(msg *imap.Message) {
|
||||||
|
if msg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
envelope := msg.Envelope
|
||||||
|
if envelope == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract sender
|
||||||
|
senderID := ""
|
||||||
|
if len(envelope.From) > 0 {
|
||||||
|
from := envelope.From[0]
|
||||||
|
if from.MailboxName != "" {
|
||||||
|
senderID = fmt.Sprintf("%s@%s", from.MailboxName, from.HostName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if senderID == "" {
|
||||||
|
senderID = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check allowlist
|
||||||
|
if !c.IsAllowed(senderID) {
|
||||||
|
logger.DebugCF("email", "Email from unauthorized sender", map[string]interface{}{
|
||||||
|
"sender": senderID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract body and attachments (attachments saved to AttachmentDir, paths in mediaPaths)
|
||||||
|
content, mediaPaths := c.extractEmailBodyAndAttachments(msg)
|
||||||
|
if content == "" {
|
||||||
|
content = "[empty email body]"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatID is sender email
|
||||||
|
chatID := senderID
|
||||||
|
|
||||||
|
// Build metadata
|
||||||
|
metadata := map[string]string{
|
||||||
|
"subject": envelope.Subject,
|
||||||
|
"message_id": fmt.Sprintf("%d", msg.Uid),
|
||||||
|
"date": envelope.Date.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(envelope.To) > 0 {
|
||||||
|
to := envelope.To[0]
|
||||||
|
metadata["to"] = fmt.Sprintf("%s@%s", to.MailboxName, to.HostName)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("email", "Email received", map[string]interface{}{
|
||||||
|
"sender_id": senderID,
|
||||||
|
"subject": envelope.Subject,
|
||||||
|
"preview": utils.Truncate(content, 80),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Publish to message bus (attachment local paths in mediaPaths)
|
||||||
|
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractEmailBodyAndAttachments parses body and saves attachments to AttachmentDir; returns body text and local paths.
|
||||||
|
func (c *EmailChannel) extractEmailBodyAndAttachments(msg *imap.Message) (content string, mediaPaths []string) {
|
||||||
|
if msg == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := ""
|
||||||
|
if msg.Envelope != nil {
|
||||||
|
subject = msg.Envelope.Subject
|
||||||
|
}
|
||||||
|
|
||||||
|
bodySection := &imap.BodySectionName{}
|
||||||
|
bodyReader := msg.GetBody(bodySection)
|
||||||
|
if bodyReader == nil {
|
||||||
|
logger.DebugCF("email", "No body in FETCH response", map[string]interface{}{"uid": msg.Uid})
|
||||||
|
if subject != "" {
|
||||||
|
return fmt.Sprintf("Subject: %s\n\n[No body content]", subject), nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mr, err := mail.CreateReader(bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
logger.DebugCF("email", "Failed to create mail reader", map[string]interface{}{"error": err.Error()})
|
||||||
|
if subject != "" {
|
||||||
|
return fmt.Sprintf("Subject: %s\n\n[Failed to parse email body]", subject), nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
defer mr.Close()
|
||||||
|
|
||||||
|
var textParts, htmlParts []string
|
||||||
|
var attachmentRefs []string
|
||||||
|
attachmentIndex := 0
|
||||||
|
saveDir := strings.TrimSpace(c.config.AttachmentDir)
|
||||||
|
|
||||||
|
for {
|
||||||
|
p, err := mr.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.DebugCF("email", "Failed to read email part", map[string]interface{}{"error": err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := getPartContentType(p.Header)
|
||||||
|
isAttachment := isAttachmentPart(p.Header)
|
||||||
|
|
||||||
|
if isAttachment {
|
||||||
|
filename := getPartFilename(p.Header)
|
||||||
|
if filename == "" {
|
||||||
|
filename = fmt.Sprintf("attachment_%d", attachmentIndex)
|
||||||
|
}
|
||||||
|
attachmentIndex++
|
||||||
|
|
||||||
|
var localPath string
|
||||||
|
if saveDir != "" {
|
||||||
|
localPath = c.saveAttachmentToLocal(msg.Uid, attachmentIndex, filename, p.Body)
|
||||||
|
if localPath != "" {
|
||||||
|
mediaPaths = append(mediaPaths, localPath)
|
||||||
|
attachmentRefs = append(attachmentRefs, fmt.Sprintf("[attachment: %s]", filepath.Base(localPath)))
|
||||||
|
} else {
|
||||||
|
attachmentRefs = append(attachmentRefs, fmt.Sprintf("[attachment: %s (save failed)]", filename))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
attachmentRefs = append(attachmentRefs, fmt.Sprintf("[attachment: %s]", filename))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(p.Body)
|
||||||
|
if err != nil || len(body) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bodyStr := strings.TrimSpace(string(body))
|
||||||
|
if bodyStr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(contentType, "text/plain"):
|
||||||
|
textParts = append(textParts, bodyStr)
|
||||||
|
case strings.HasPrefix(contentType, "text/html"):
|
||||||
|
htmlParts = append(htmlParts, bodyStr)
|
||||||
|
case strings.HasPrefix(contentType, "text/"):
|
||||||
|
textParts = append(textParts, bodyStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyContent string
|
||||||
|
if len(textParts) > 0 {
|
||||||
|
bodyContent = strings.TrimSpace(strings.Join(textParts, "\n\n"))
|
||||||
|
} else if len(htmlParts) > 0 {
|
||||||
|
bodyContent = c.extractTextFromHTML(strings.Join(htmlParts, "\n\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if bodyContent == "" && len(attachmentRefs) == 0 {
|
||||||
|
if subject != "" {
|
||||||
|
return fmt.Sprintf("Subject: %s\n\n[No body content]", subject), mediaPaths
|
||||||
|
}
|
||||||
|
return "[Empty email]", mediaPaths
|
||||||
|
}
|
||||||
|
if bodyContent == "" {
|
||||||
|
bodyContent = "[attachments only]"
|
||||||
|
}
|
||||||
|
if len(attachmentRefs) > 0 {
|
||||||
|
bodyContent = bodyContent + "\n\n" + strings.Join(attachmentRefs, "\n")
|
||||||
|
}
|
||||||
|
if subject != "" {
|
||||||
|
bodyContent = fmt.Sprintf("Subject: %s\n\n%s", subject, bodyContent)
|
||||||
|
}
|
||||||
|
return bodyContent, mediaPaths
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveAttachmentToLocal writes the attachment stream to AttachmentDir; returns local path or empty on failure.
|
||||||
|
func (c *EmailChannel) saveAttachmentToLocal(uid uint32, index int, filename string, r io.Reader) string {
|
||||||
|
dir := strings.TrimSpace(c.config.AttachmentDir)
|
||||||
|
if dir == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
logger.DebugCF("email", "Failed to create attachment dir", map[string]interface{}{"error": err.Error(), "dir": dir})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
safeName := utils.SanitizeFilename(filename)
|
||||||
|
if safeName == "" {
|
||||||
|
safeName = "attachment"
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(safeName)
|
||||||
|
if ext == "" && filename != "" {
|
||||||
|
ext = filepath.Ext(filename)
|
||||||
|
}
|
||||||
|
localName := fmt.Sprintf("%d_%d_%s%s", uid, index, strings.TrimSuffix(safeName, ext), ext)
|
||||||
|
localPath := filepath.Join(dir, localName)
|
||||||
|
f, err := os.Create(localPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.DebugCF("email", "Failed to create attachment file", map[string]interface{}{"error": err.Error(), "path": localPath})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := io.Copy(f, r); err != nil {
|
||||||
|
_ = os.Remove(localPath)
|
||||||
|
logger.DebugCF("email", "Failed to write attachment", map[string]interface{}{"error": err.Error(), "path": localPath})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return localPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPartFilename gets the attachment filename from MIME part header and decodes RFC 2047 (e.g. =?GBK?Q?...?=) to UTF-8.
|
||||||
|
func getPartFilename(h mail.PartHeader) string {
|
||||||
|
if h == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var raw string
|
||||||
|
if ah, ok := h.(*mail.AttachmentHeader); ok {
|
||||||
|
s, _ := ah.Filename()
|
||||||
|
raw = strings.TrimSpace(s)
|
||||||
|
} else {
|
||||||
|
disp := h.Get("Content-Disposition")
|
||||||
|
if disp == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw = parseFilenameFromDisposition(disp)
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return decodeRFC2047Filename(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFilenameFromDisposition parses the filename= value from Content-Disposition header.
|
||||||
|
func parseFilenameFromDisposition(disp string) string {
|
||||||
|
dispLower := strings.ToLower(disp)
|
||||||
|
if !strings.Contains(dispLower, "attachment") && !strings.Contains(dispLower, "inline") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
const fn = "filename="
|
||||||
|
i := strings.Index(dispLower, fn)
|
||||||
|
if i < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
disp = disp[i+len(fn):]
|
||||||
|
disp = strings.TrimLeft(disp, " \t")
|
||||||
|
if len(disp) >= 2 && (disp[0] == '"' || disp[0] == '\'') {
|
||||||
|
end := strings.IndexByte(disp[1:], disp[0])
|
||||||
|
if end >= 0 {
|
||||||
|
return strings.TrimSpace(disp[1 : 1+end])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx := strings.IndexAny(disp, " \t;"); idx > 0 {
|
||||||
|
disp = disp[:idx]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(disp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rfc2047WordDecoder decodes =?charset?Q?encoded?= to UTF-8; supports GBK/GB2312.
|
||||||
|
var rfc2047WordDecoder = &mime.WordDecoder{
|
||||||
|
CharsetReader: func(charset string, r io.Reader) (io.Reader, error) {
|
||||||
|
charset = strings.ToLower(strings.TrimSpace(charset))
|
||||||
|
switch charset {
|
||||||
|
case "gbk", "gb2312":
|
||||||
|
return simplifiedchinese.GBK.NewDecoder().Reader(r), nil
|
||||||
|
default:
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeRFC2047Filename(s string) string {
|
||||||
|
if s == "" || !strings.Contains(s, "=?") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
decoded, err := rfc2047WordDecoder.DecodeHeader(s)
|
||||||
|
if err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPartContentType returns the Content-Type main type (e.g. "text/plain") from PartHeader.
|
||||||
|
func getPartContentType(h mail.PartHeader) string {
|
||||||
|
if h == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw := h.Get("Content-Type")
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Take the part before the first semicolon and trim
|
||||||
|
if i := strings.IndexByte(raw, ';'); i >= 0 {
|
||||||
|
raw = raw[:i]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.ToLower(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAttachmentPart reports whether the part should be treated as an attachment (not shown as body).
|
||||||
|
func isAttachmentPart(h mail.PartHeader) bool {
|
||||||
|
if h == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, ok := h.(*mail.AttachmentHeader); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
disp := strings.ToLower(strings.TrimSpace(h.Get("Content-Disposition")))
|
||||||
|
if strings.HasPrefix(disp, "attachment") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ct := getPartContentType(h)
|
||||||
|
// Non-text/* (e.g. image, PDF) is treated as attachment
|
||||||
|
if ct != "" && !strings.HasPrefix(ct, "text/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTextFromHTML strips HTML tags and returns plain text (simple impl, no external HTML lib).
|
||||||
|
func (c *EmailChannel) extractTextFromHTML(html string) string {
|
||||||
|
text := html
|
||||||
|
// Remove script and style tags and their content
|
||||||
|
text = c.removeTagContent(text, "script")
|
||||||
|
text = c.removeTagContent(text, "style")
|
||||||
|
|
||||||
|
// Strip all HTML tags
|
||||||
|
var result strings.Builder
|
||||||
|
inTag := false
|
||||||
|
for i, r := range text {
|
||||||
|
if r == '<' {
|
||||||
|
inTag = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == '>' {
|
||||||
|
inTag = false
|
||||||
|
// Add space after tag if next char is not space
|
||||||
|
if i+1 < len(text) && text[i+1] != ' ' && text[i+1] != '\n' {
|
||||||
|
result.WriteRune(' ')
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !inTag {
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize whitespace
|
||||||
|
cleaned := strings.TrimSpace(result.String())
|
||||||
|
cleaned = strings.ReplaceAll(cleaned, "\n\n\n", "\n\n")
|
||||||
|
cleaned = strings.ReplaceAll(cleaned, " ", " ")
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeTagContent removes the named tag and its content (finds <tagName>...</tagName> and strips it).
|
||||||
|
func (c *EmailChannel) removeTagContent(html, tagName string) string {
|
||||||
|
startTag := "<" + tagName
|
||||||
|
endTag := "</" + tagName + ">"
|
||||||
|
|
||||||
|
for {
|
||||||
|
startIdx := strings.Index(strings.ToLower(html), strings.ToLower(startTag))
|
||||||
|
if startIdx == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find end of opening tag
|
||||||
|
endIdx := strings.Index(html[startIdx:], ">")
|
||||||
|
if endIdx == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
endIdx += startIdx + 1
|
||||||
|
|
||||||
|
// Find matching closing tag
|
||||||
|
closeIdx := strings.Index(strings.ToLower(html[endIdx:]), strings.ToLower(endTag))
|
||||||
|
if closeIdx == -1 {
|
||||||
|
// No closing tag, remove only the opening tag
|
||||||
|
html = html[:startIdx] + html[endIdx:]
|
||||||
|
} else {
|
||||||
|
closeIdx += endIdx + len(endTag)
|
||||||
|
html = html[:startIdx] + html[closeIdx:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
@ -175,6 +175,18 @@ func (m *Manager) initChannels() error {
|
||||||
logger.InfoC("channels", "OneBot channel enabled successfully")
|
logger.InfoC("channels", "OneBot channel enabled successfully")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if m.config.Channels.Email.Enabled {
|
||||||
|
logger.DebugC("channels", "Attempting to initialize Email channel")
|
||||||
|
email, err := NewEmailChannel(m.config.Channels.Email, m.bus)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("channels", "Failed to initialize Email channel", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
m.channels["email"] = email
|
||||||
|
logger.InfoC("channels", "Email channel enabled successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
||||||
"enabled_channels": len(m.channels),
|
"enabled_channels": len(m.channels),
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ type ChannelsConfig struct {
|
||||||
Slack SlackConfig `json:"slack"`
|
Slack SlackConfig `json:"slack"`
|
||||||
LINE LINEConfig `json:"line"`
|
LINE LINEConfig `json:"line"`
|
||||||
OneBot OneBotConfig `json:"onebot"`
|
OneBot OneBotConfig `json:"onebot"`
|
||||||
|
Email EmailConfig `json:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WhatsAppConfig struct {
|
type WhatsAppConfig struct {
|
||||||
|
|
@ -147,6 +148,24 @@ type LINEConfig struct {
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EmailConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_EMAIL_ENABLED"`
|
||||||
|
IMAPServer string `json:"imap_server" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_SERVER"`
|
||||||
|
IMAPPort int `json:"imap_port" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_PORT"`
|
||||||
|
Username string `json:"username" env:"PICOCLAW_CHANNELS_EMAIL_USERNAME"`
|
||||||
|
Password string `json:"password" env:"PICOCLAW_CHANNELS_EMAIL_PASSWORD"`
|
||||||
|
Mailbox string `json:"mailbox" env:"PICOCLAW_CHANNELS_EMAIL_MAILBOX"` // 默认 "INBOX"
|
||||||
|
CheckInterval int `json:"check_interval" env:"PICOCLAW_CHANNELS_EMAIL_CHECK_INTERVAL"` // seconds, default 30; used for polling when IDLE disabled or unsupported
|
||||||
|
UseIdle bool `json:"use_idle" env:"PICOCLAW_CHANNELS_EMAIL_USE_IDLE"` // use IMAP IDLE (push) when server supports it; otherwise poll
|
||||||
|
UseTLS bool `json:"use_tls" env:"PICOCLAW_CHANNELS_EMAIL_USE_TLS"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_EMAIL_ALLOW_FROM"`
|
||||||
|
AttachmentDir string `json:"attachment_dir" env:"PICOCLAW_CHANNELS_EMAIL_ATTACHMENT_DIR"`
|
||||||
|
// SMTP send (optional, if not configured, Send is not available)
|
||||||
|
SMTPServer string `json:"smtp_server" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_SERVER"`
|
||||||
|
SMTPPort int `json:"smtp_port" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_PORT"` // 465 或 587
|
||||||
|
SMTPUseTLS bool `json:"smtp_use_tls" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_USE_TLS"` // 465 用 true,587 可用 false+STARTTLS
|
||||||
|
}
|
||||||
|
|
||||||
type OneBotConfig struct {
|
type OneBotConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
|
||||||
WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
|
WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
|
||||||
|
|
@ -305,6 +324,21 @@ func DefaultConfig() *Config {
|
||||||
GroupTriggerPrefix: []string{},
|
GroupTriggerPrefix: []string{},
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
|
Email: EmailConfig{
|
||||||
|
Enabled: false,
|
||||||
|
IMAPServer: "",
|
||||||
|
IMAPPort: 993,
|
||||||
|
Username: "",
|
||||||
|
Password: "",
|
||||||
|
Mailbox: "INBOX",
|
||||||
|
CheckInterval: 30,
|
||||||
|
UseIdle: true,
|
||||||
|
UseTLS: true,
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
SMTPServer: "",
|
||||||
|
SMTPPort: 465,
|
||||||
|
SMTPUseTLS: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: ProvidersConfig{
|
||||||
Anthropic: ProviderConfig{},
|
Anthropic: ProviderConfig{},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue