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/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
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/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
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"`
|
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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue