feat(channels): add email channel with SMTP outbound and IMAP polling inbound

Closes #8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sushi30 2026-03-28 07:43:11 +01:00 committed by github-actions[bot]
parent c10ca240ca
commit 1107731486
6 changed files with 421 additions and 0 deletions

3
go.mod
View file

@ -71,6 +71,9 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/gdamore/encoding v1.0.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect

6
go.sum
View file

@ -87,6 +87,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/ergochat/irc-go v0.6.0 h1:Y0AGV76aeihJfCtLaQh+OyJKFiKGrYC0VTkeMZ6XW28= github.com/ergochat/irc-go v0.6.0 h1:Y0AGV76aeihJfCtLaQh+OyJKFiKGrYC0VTkeMZ6XW28=
github.com/ergochat/irc-go v0.6.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= github.com/ergochat/irc-go v0.6.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4pSo= github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4pSo=

374
pkg/channels/email/email.go Normal file
View file

@ -0,0 +1,374 @@
package email
import (
"context"
"crypto/tls"
"fmt"
"io"
"net/smtp"
"strings"
"time"
gomail "github.com/emersion/go-message/mail"
imap "github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
// EmailChannel implements the Channel interface using SMTP (outbound) and IMAP polling (inbound).
type EmailChannel struct {
*channels.BaseChannel
config config.EmailConfig
ctx context.Context
cancel context.CancelFunc
}
// NewEmailChannel creates a new email channel.
func NewEmailChannel(cfg config.EmailConfig, messageBus *bus.MessageBus) (*EmailChannel, error) {
if cfg.SMTPHost == "" {
return nil, fmt.Errorf("email smtp_host is required")
}
if cfg.SMTPFrom == "" {
return nil, fmt.Errorf("email smtp_from is required")
}
if cfg.IMAPHost == "" {
return nil, fmt.Errorf("email imap_host is required")
}
if cfg.IMAPUser == "" {
return nil, fmt.Errorf("email imap_user is required")
}
base := channels.NewBaseChannel("email", cfg, messageBus, cfg.AllowFrom,
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &EmailChannel{
BaseChannel: base,
config: cfg,
}, nil
}
// Start begins IMAP polling.
func (c *EmailChannel) Start(ctx context.Context) error {
logger.InfoC("email", "Starting email channel")
c.ctx, c.cancel = context.WithCancel(ctx)
interval := c.config.PollIntervalSecs
if interval <= 0 {
interval = 30
}
go c.pollLoop(time.Duration(interval) * time.Second)
c.SetRunning(true)
logger.InfoCF("email", "Email channel started", map[string]any{
"smtp_host": c.config.SMTPHost,
"imap_host": c.config.IMAPHost,
"interval": interval,
})
return nil
}
// Stop cancels the polling loop.
func (c *EmailChannel) Stop(ctx context.Context) error {
logger.InfoC("email", "Stopping email channel")
c.SetRunning(false)
if c.cancel != nil {
c.cancel()
}
logger.InfoC("email", "Email channel stopped")
return nil
}
// Send delivers an outbound message via SMTP.
// msg.ChatID is the recipient email address.
func (c *EmailChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
to := msg.ChatID
if to == "" {
return fmt.Errorf("chat ID (recipient address) is empty: %w", channels.ErrSendFailed)
}
if strings.TrimSpace(msg.Content) == "" {
return nil
}
subject := c.config.DefaultSubject
if subject == "" {
subject = "Message"
}
smtpPort := c.config.SMTPPort
if smtpPort == 0 {
smtpPort = 587
}
addr := fmt.Sprintf("%s:%d", c.config.SMTPHost, smtpPort)
smtpUser := c.config.SMTPUser
if smtpUser == "" {
smtpUser = c.config.SMTPFrom
}
body := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\n\r\n%s",
c.config.SMTPFrom, to, subject, msg.Content)
var auth smtp.Auth
if c.config.SMTPPassword.String() != "" {
auth = smtp.PlainAuth("", smtpUser, c.config.SMTPPassword.String(), c.config.SMTPHost)
}
// Port 465 uses implicit TLS; port 587 and others use STARTTLS.
if smtpPort == 465 {
tlsCfg := &tls.Config{ServerName: c.config.SMTPHost}
conn, err := tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return fmt.Errorf("smtp tls dial: %w", channels.ErrTemporary)
}
client, err := smtp.NewClient(conn, c.config.SMTPHost)
if err != nil {
return fmt.Errorf("smtp new client: %w", channels.ErrTemporary)
}
defer client.Close()
if err := sendViaSMTPClient(client, auth, c.config.SMTPFrom, to, []byte(body)); err != nil {
return err
}
} else {
if err := smtp.SendMail(addr, auth, c.config.SMTPFrom, []string{to}, []byte(body)); err != nil {
return fmt.Errorf("smtp send: %w: %w", err, channels.ErrTemporary)
}
}
logger.DebugCF("email", "Message sent", map[string]any{"to": to})
return nil
}
func sendViaSMTPClient(client *smtp.Client, auth smtp.Auth, from, to string, body []byte) error {
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w: %w", err, channels.ErrSendFailed)
}
}
if err := client.Mail(from); err != nil {
return fmt.Errorf("smtp MAIL FROM: %w: %w", err, channels.ErrSendFailed)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("smtp RCPT TO: %w: %w", err, channels.ErrSendFailed)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp DATA: %w: %w", err, channels.ErrSendFailed)
}
if _, err := w.Write(body); err != nil {
return fmt.Errorf("smtp write body: %w: %w", err, channels.ErrSendFailed)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp close data: %w: %w", err, channels.ErrSendFailed)
}
return client.Quit()
}
func (c *EmailChannel) pollLoop(interval time.Duration) {
// Poll once immediately on start, then on ticker.
c.pollIMAP()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-c.ctx.Done():
return
case <-ticker.C:
c.pollIMAP()
}
}
}
func (c *EmailChannel) pollIMAP() {
imapPort := c.config.IMAPPort
if imapPort == 0 {
imapPort = 993
}
addr := fmt.Sprintf("%s:%d", c.config.IMAPHost, imapPort)
var (
client *imapclient.Client
err error
)
// Port 993 = implicit TLS, anything else = plain (STARTTLS not yet supported).
if imapPort == 993 {
tlsCfg := &tls.Config{ServerName: c.config.IMAPHost}
client, err = imapclient.DialTLS(addr, &imapclient.Options{TLSConfig: tlsCfg})
} else {
client, err = imapclient.DialInsecure(addr, nil)
}
if err != nil {
logger.WarnCF("email", "IMAP dial failed", map[string]any{"err": err})
return
}
defer client.Close()
if err := client.Login(c.config.IMAPUser, c.config.IMAPPassword.String()).Wait(); err != nil {
logger.WarnCF("email", "IMAP login failed", map[string]any{"err": err})
return
}
if _, err := client.Select("INBOX", nil).Wait(); err != nil {
logger.WarnCF("email", "IMAP SELECT INBOX failed", map[string]any{"err": err})
return
}
searchData, err := client.Search(&imap.SearchCriteria{
NotFlag: []imap.Flag{imap.FlagSeen},
}, nil).Wait()
if err != nil {
logger.WarnCF("email", "IMAP SEARCH failed", map[string]any{"err": err})
return
}
if len(searchData.AllSeqNums()) == 0 {
return
}
seqSet := imap.SeqSetNum(searchData.AllSeqNums()...)
bodySection := &imap.FetchItemBodySection{}
fetchOptions := &imap.FetchOptions{
Envelope: true,
BodySection: []*imap.FetchItemBodySection{bodySection},
}
fetchCmd := client.Fetch(seqSet, fetchOptions)
for {
msg := fetchCmd.Next()
if msg == nil {
break
}
var (
envelope *imap.Envelope
bodySectionData *imapclient.FetchItemDataBodySection
seqNum uint32
)
seqNum = msg.SeqNum
for {
item := msg.Next()
if item == nil {
break
}
switch v := item.(type) {
case imapclient.FetchItemDataEnvelope:
envelope = v.Envelope
case imapclient.FetchItemDataBodySection:
bodySectionData = &v
}
}
if envelope == nil || bodySectionData == nil {
continue
}
fromAddr := extractFrom(envelope)
if fromAddr == "" {
continue
}
messageID := envelope.MessageID
plainText := extractPlainText(bodySectionData.Literal)
sender := bus.SenderInfo{
Platform: "email",
PlatformID: fromAddr,
CanonicalID: "email:" + fromAddr,
DisplayName: displayName(envelope),
}
c.HandleMessage(c.ctx,
bus.Peer{Kind: "direct", ID: fromAddr},
messageID, fromAddr, fromAddr, plainText,
nil, nil,
sender,
)
// Mark message as \Seen
storeSeq := imap.SeqSetNum(seqNum)
storeFlags := imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagSeen},
Silent: true,
}
if err := client.Store(storeSeq, &storeFlags, nil).Close(); err != nil {
logger.WarnCF("email", "IMAP STORE \\Seen failed", map[string]any{"err": err, "seq": seqNum})
}
}
if err := fetchCmd.Close(); err != nil {
logger.WarnCF("email", "IMAP FETCH close error", map[string]any{"err": err})
}
}
func extractFrom(env *imap.Envelope) string {
if len(env.From) == 0 {
return ""
}
addr := env.From[0]
if addr.Host == "" {
return addr.Mailbox
}
return addr.Mailbox + "@" + addr.Host
}
func displayName(env *imap.Envelope) string {
if len(env.From) == 0 {
return ""
}
if env.From[0].Name != "" {
return env.From[0].Name
}
return extractFrom(env)
}
// extractPlainText reads the message body and returns the first text/plain part.
// Falls back to the raw body if parsing fails.
func extractPlainText(r io.Reader) string {
mr, err := gomail.CreateReader(r)
if err != nil {
// Fallback: read raw bytes
b, _ := io.ReadAll(r)
return strings.TrimSpace(string(b))
}
for {
p, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
break
}
inlineHeader, ok := p.Header.(*gomail.InlineHeader)
if !ok {
continue
}
ct, _, _ := inlineHeader.ContentType()
if ct == "text/plain" || ct == "" {
b, _ := io.ReadAll(p.Body)
text := strings.TrimSpace(string(b))
if text != "" {
return text
}
}
}
return ""
}

View file

@ -0,0 +1,16 @@
package email
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("email", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
if !cfg.Channels.Email.Enabled {
return nil, nil
}
return NewEmailChannel(cfg.Channels.Email, b)
})
}

View file

@ -314,6 +314,7 @@ type ChannelsConfig struct {
IRC IRCConfig `json:"irc" yaml:"irc,omitempty"` IRC IRCConfig `json:"irc" yaml:"irc,omitempty"`
VK VKConfig `json:"vk" yaml:"vk,omitempty"` VK VKConfig `json:"vk" yaml:"vk,omitempty"`
TeamsWebhook TeamsWebhookConfig `json:"teams_webhook" yaml:"teams_webhook,omitempty"` TeamsWebhook TeamsWebhookConfig `json:"teams_webhook" yaml:"teams_webhook,omitempty"`
Email EmailConfig `json:"email" yaml:"email,omitempty"`
} }
// GroupTriggerConfig controls when the bot responds in group chats. // GroupTriggerConfig controls when the bot responds in group chats.
@ -596,6 +597,26 @@ type TeamsWebhookTarget struct {
Title string `json:"title,omitempty" yaml:"-"` Title string `json:"title,omitempty" yaml:"-"`
} }
type EmailConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_EMAIL_ENABLED"`
// SMTP (outbound)
SMTPHost string `json:"smtp_host" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_HOST"`
SMTPPort int `json:"smtp_port" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_PORT"`
SMTPFrom string `json:"smtp_from" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_FROM"`
SMTPUser string `json:"smtp_user" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_USER"`
SMTPPassword SecureString `json:"smtp_password,omitzero" yaml:"smtp_password,omitempty" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_PASSWORD"`
DefaultSubject string `json:"default_subject" env:"PICOCLAW_CHANNELS_EMAIL_DEFAULT_SUBJECT"`
// IMAP (inbound)
IMAPHost string `json:"imap_host" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_HOST"`
IMAPPort int `json:"imap_port" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_PORT"`
IMAPUser string `json:"imap_user" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_USER"`
IMAPPassword SecureString `json:"imap_password,omitzero" yaml:"imap_password,omitempty" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_PASSWORD"`
PollIntervalSecs int `json:"poll_interval_secs" env:"PICOCLAW_CHANNELS_EMAIL_POLL_INTERVAL_SECS"`
// Common
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_EMAIL_ALLOW_FROM"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_EMAIL_REASONING_CHANNEL_ID"`
}
type HeartbeatConfig struct { type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5

View file

@ -19,6 +19,7 @@ import (
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk" _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
_ "github.com/sipeed/picoclaw/pkg/channels/email"
_ "github.com/sipeed/picoclaw/pkg/channels/discord" _ "github.com/sipeed/picoclaw/pkg/channels/discord"
_ "github.com/sipeed/picoclaw/pkg/channels/feishu" _ "github.com/sipeed/picoclaw/pkg/channels/feishu"
_ "github.com/sipeed/picoclaw/pkg/channels/irc" _ "github.com/sipeed/picoclaw/pkg/channels/irc"