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:
zhouliang 2026-02-18 15:32:22 +08:00
parent 8807d8254f
commit 0367b33f52
6 changed files with 976 additions and 0 deletions

View file

@ -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
View file

@ -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
View file

@ -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
View 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
}

View file

@ -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),

View file

@ -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 用 true587 可用 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{},