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:
parent
c10ca240ca
commit
1107731486
6 changed files with 421 additions and 0 deletions
3
go.mod
3
go.mod
|
|
@ -71,6 +71,9 @@ require (
|
|||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // 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/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
|
|
|
|||
6
go.sum
6
go.sum
|
|
@ -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/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/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/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
|
||||
github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4pSo=
|
||||
|
|
|
|||
374
pkg/channels/email/email.go
Normal file
374
pkg/channels/email/email.go
Normal 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 ""
|
||||
}
|
||||
|
||||
16
pkg/channels/email/init.go
Normal file
16
pkg/channels/email/init.go
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -314,6 +314,7 @@ type ChannelsConfig struct {
|
|||
IRC IRCConfig `json:"irc" yaml:"irc,omitempty"`
|
||||
VK VKConfig `json:"vk" yaml:"vk,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.
|
||||
|
|
@ -596,6 +597,26 @@ type TeamsWebhookTarget struct {
|
|||
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 {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
_ "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/feishu"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue