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,
|
||||
"group_trigger_prefix": [],
|
||||
"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": {
|
||||
|
|
|
|||
5
go.mod
5
go.mod
|
|
@ -8,6 +8,8 @@ require (
|
|||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/caarlos0/env/v11 v11.3.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/gorilla/websocket v1.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/tencent-connect/botgo v0.2.1
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/text v0.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
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
|
||||
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
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.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.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.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/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
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")
|
||||
}
|
||||
}
|
||||
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{}{
|
||||
"enabled_channels": len(m.channels),
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ type ChannelsConfig struct {
|
|||
Slack SlackConfig `json:"slack"`
|
||||
LINE LINEConfig `json:"line"`
|
||||
OneBot OneBotConfig `json:"onebot"`
|
||||
Email EmailConfig `json:"email"`
|
||||
}
|
||||
|
||||
type WhatsAppConfig struct {
|
||||
|
|
@ -147,6 +148,24 @@ type LINEConfig struct {
|
|||
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 {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
|
||||
WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
|
||||
|
|
@ -305,6 +324,21 @@ func DefaultConfig() *Config {
|
|||
GroupTriggerPrefix: []string{},
|
||||
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{
|
||||
Anthropic: ProviderConfig{},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue