diff --git a/go.mod b/go.mod index 9eaa72a0b..d6b65d820 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 6a2194960..9106a5375 100644 --- a/go.sum +++ b/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= diff --git a/pkg/channels/email/email.go b/pkg/channels/email/email.go new file mode 100644 index 000000000..4714d2093 --- /dev/null +++ b/pkg/channels/email/email.go @@ -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 "" +} + diff --git a/pkg/channels/email/init.go b/pkg/channels/email/init.go new file mode 100644 index 000000000..e1de396c0 --- /dev/null +++ b/pkg/channels/email/init.go @@ -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) + }) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index fd4466b8c..acd153c06 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index be8f9d1c8..b649ed465 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -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"