feat(channels): add Email channel and refactor to latest structure
Email: IMAP + optional SMTP, allow_from, attachments. Moved to pkg/channels/email/ with initChannel, identity.BuildCanonicalID, bus.Peer/SenderInfo. Bus and gateway updated for context-aware publish, media, and graceful shutdown.
This commit is contained in:
parent
4afd3f6f55
commit
f13824b696
6 changed files with 82 additions and 70 deletions
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"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/discord"
|
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/email"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package channels
|
package email
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
@ -23,7 +23,9 @@ import (
|
||||||
"golang.org/x/text/encoding/simplifiedchinese"
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
@ -45,7 +47,7 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type EmailChannel struct {
|
type EmailChannel struct {
|
||||||
*BaseChannel
|
*channels.BaseChannel
|
||||||
config config.EmailConfig
|
config config.EmailConfig
|
||||||
imapClient *client.Client
|
imapClient *client.Client
|
||||||
lastUID uint32
|
lastUID uint32
|
||||||
|
|
@ -62,7 +64,7 @@ type EmailChannel struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEmailChannel(cfg config.EmailConfig, bus *bus.MessageBus) (*EmailChannel, error) {
|
func NewEmailChannel(cfg config.EmailConfig, bus *bus.MessageBus) (*EmailChannel, error) {
|
||||||
base := NewBaseChannel("email", cfg, bus, cfg.AllowFrom)
|
base := channels.NewBaseChannel("email", cfg, bus, cfg.AllowFrom)
|
||||||
return &EmailChannel{
|
return &EmailChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
|
@ -182,14 +184,14 @@ func (c *EmailChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
if c.config.SMTPUseTLS {
|
if c.config.SMTPUseTLS {
|
||||||
// Port 465: implicit TLS
|
// Port 465: implicit TLS
|
||||||
tlsConfig := &tls.Config{ServerName: host}
|
tlsConfig := &tls.Config{ServerName: host}
|
||||||
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
conn, tlserr := tls.Dial("tcp", addr, tlsConfig)
|
||||||
if err != nil {
|
if tlserr != nil {
|
||||||
return fmt.Errorf("smtp tls dial: %w", err)
|
return fmt.Errorf("smtp tls dial: %w", tlserr)
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
client, err := smtp.NewClient(conn, host)
|
client, newClientErr := smtp.NewClient(conn, host)
|
||||||
if err != nil {
|
if newClientErr != nil {
|
||||||
return fmt.Errorf("smtp new client: %w", err)
|
return fmt.Errorf("smtp new client: %w", newClientErr)
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
auth := smtp.PlainAuth("", c.config.Username, c.config.Password, host)
|
auth := smtp.PlainAuth("", c.config.Username, c.config.Password, host)
|
||||||
|
|
@ -202,9 +204,9 @@ func (c *EmailChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
if err = client.Rcpt(toRaw); err != nil {
|
if err = client.Rcpt(toRaw); err != nil {
|
||||||
return fmt.Errorf("smtp rcpt: %w", err)
|
return fmt.Errorf("smtp rcpt: %w", err)
|
||||||
}
|
}
|
||||||
w, err := client.Data()
|
w, dataErr := client.Data()
|
||||||
if err != nil {
|
if dataErr != nil {
|
||||||
return fmt.Errorf("smtp data: %w", err)
|
return fmt.Errorf("smtp data: %w", dataErr)
|
||||||
}
|
}
|
||||||
if _, err = w.Write(body); err != nil {
|
if _, err = w.Write(body); err != nil {
|
||||||
_ = w.Close()
|
_ = w.Close()
|
||||||
|
|
@ -275,9 +277,9 @@ func (c *EmailChannel) connect() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
if err := cl.Login(c.config.Username, c.config.Password); err != nil {
|
if loginErr := cl.Login(c.config.Username, c.config.Password); loginErr != nil {
|
||||||
cl.Logout()
|
cl.Logout()
|
||||||
return err
|
return loginErr
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
|
@ -617,7 +619,7 @@ func (c *EmailChannel) CheckNewEmails(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the message
|
// Process the message
|
||||||
c.processEmail(msg)
|
c.processEmail(ctx, msg)
|
||||||
|
|
||||||
// Mark as seen after fully read
|
// Mark as seen after fully read
|
||||||
seenSet := new(imap.SeqSet)
|
seenSet := new(imap.SeqSet)
|
||||||
|
|
@ -660,7 +662,7 @@ func (c *EmailChannel) CheckNewEmails(ctx context.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *EmailChannel) processEmail(msg *imap.Message) {
|
func (c *EmailChannel) processEmail(ctx context.Context, msg *imap.Message) {
|
||||||
if msg == nil {
|
if msg == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -683,8 +685,15 @@ func (c *EmailChannel) processEmail(msg *imap.Message) {
|
||||||
senderID = "unknown"
|
senderID = "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check allowlist
|
// SenderInfo for allow-list and routing (canonical format: email:addr)
|
||||||
if !c.IsAllowed(senderID) {
|
senderInfo := bus.SenderInfo{
|
||||||
|
Platform: "email",
|
||||||
|
PlatformID: senderID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("email", senderID),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check allowlist (HandleMessage will also check; we avoid duplicate work by passing SenderInfo)
|
||||||
|
if !c.IsAllowedSender(senderInfo) {
|
||||||
logger.DebugCF("email", "Email from unauthorized sender", map[string]any{
|
logger.DebugCF("email", "Email from unauthorized sender", map[string]any{
|
||||||
"sender": senderID,
|
"sender": senderID,
|
||||||
})
|
})
|
||||||
|
|
@ -697,14 +706,16 @@ func (c *EmailChannel) processEmail(msg *imap.Message) {
|
||||||
content = "[empty email body]"
|
content = "[empty email body]"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatID is sender email
|
// ChatID is sender email (1:1 conversation)
|
||||||
chatID := senderID
|
chatID := senderID
|
||||||
|
messageID := fmt.Sprintf("%d", msg.Uid)
|
||||||
|
|
||||||
// Build metadata
|
// Build metadata
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"subject": envelope.Subject,
|
"subject": envelope.Subject,
|
||||||
"message_id": fmt.Sprintf("%d", msg.Uid),
|
"message_id": messageID,
|
||||||
"date": envelope.Date.Format(time.RFC3339),
|
"date": envelope.Date.Format(time.RFC3339),
|
||||||
|
"platform": "email",
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(envelope.To) > 0 {
|
if len(envelope.To) > 0 {
|
||||||
|
|
@ -712,14 +723,15 @@ func (c *EmailChannel) processEmail(msg *imap.Message) {
|
||||||
metadata["to"] = fmt.Sprintf("%s@%s", to.MailboxName, to.HostName)
|
metadata["to"] = fmt.Sprintf("%s@%s", to.MailboxName, to.HostName)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoCF("email", "Email received", map[string]any{
|
logger.DebugCF("email", "Received message", map[string]any{
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
"subject": envelope.Subject,
|
"subject": envelope.Subject,
|
||||||
"preview": utils.Truncate(content, 80),
|
"preview": utils.Truncate(content, 50),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Publish to message bus (attachment local paths in mediaPaths)
|
// Publish to message bus (attachment local paths in mediaPaths)
|
||||||
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
|
peer := bus.Peer{Kind: "direct", ID: senderID}
|
||||||
|
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, senderInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractEmailBodyAndAttachments parses body and saves attachments to AttachmentDir; returns body text and local paths.
|
// extractEmailBodyAndAttachments parses body and saves attachments to AttachmentDir; returns body text and local paths.
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
//go:build mockey
|
//go:build mockey
|
||||||
|
|
||||||
package channels
|
package email
|
||||||
|
|
||||||
// Tests in this file use github.com/bytedance/mockey and require -gcflags="all=-N -l" to run.
|
// Tests in this file use github.com/bytedance/mockey and require -gcflags="all=-N -l" to run.
|
||||||
// Run with: go test -tags=mockey -gcflags="all=-N -l" ./pkg/channels/...
|
// Run with: go test -tags=mockey -gcflags="all=-N -l" ./pkg/channels/email/...
|
||||||
// Without -tags=mockey they are not compiled; without -gcflags they may fail due to Mockey.
|
// Without -tags=mockey they are not compiled; without -gcflags they may fail due to Mockey.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -35,16 +35,15 @@ func TestEmailChannel_checkNewEmails(t *testing.T) {
|
||||||
// mock connect to return mockClient
|
// mock connect to return mockClient
|
||||||
mockey.PatchConvey("checkNewEmails", t, func() {
|
mockey.PatchConvey("checkNewEmails", t, func() {
|
||||||
// --------------- mock start ---------------
|
// --------------- mock start ---------------
|
||||||
c := &EmailChannel{
|
msgBus := bus.NewMessageBus()
|
||||||
BaseChannel: &BaseChannel{
|
c, err := NewEmailChannel(config.EmailConfig{
|
||||||
bus: bus.NewMessageBus(),
|
|
||||||
},
|
|
||||||
config: config.EmailConfig{
|
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
AllowFrom: config.FlexibleStringSlice{},
|
AllowFrom: config.FlexibleStringSlice{},
|
||||||
},
|
}, msgBus)
|
||||||
lastUID: 20,
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
c.lastUID = 20
|
||||||
mockClient := &client.Client{}
|
mockClient := &client.Client{}
|
||||||
c.imapClient = mockClient
|
c.imapClient = mockClient
|
||||||
// mock login and select to return mockClient
|
// mock login and select to return mockClient
|
||||||
|
|
@ -97,7 +96,7 @@ func TestEmailChannel_checkNewEmails(t *testing.T) {
|
||||||
c.CheckNewEmails(context.Background())
|
c.CheckNewEmails(context.Background())
|
||||||
timeoutCtx, cancel := context.WithTimeout(ctx, time.Second)
|
timeoutCtx, cancel := context.WithTimeout(ctx, time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
messge, ok := c.bus.ConsumeInbound(timeoutCtx)
|
messge, ok := msgBus.ConsumeInbound(timeoutCtx)
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
assert.True(t, strings.Contains(messge.Content, "Hello world"))
|
assert.True(t, strings.Contains(messge.Content, "Hello world"))
|
||||||
})
|
})
|
||||||
|
|
@ -115,19 +114,17 @@ func TestEmailChannel_runIdleLoop(t *testing.T) {
|
||||||
mockey.PatchConvey("runIdleLoop", t, func() {
|
mockey.PatchConvey("runIdleLoop", t, func() {
|
||||||
// --------------- mock start ---------------
|
// --------------- mock start ---------------
|
||||||
hasCheckEmail := false
|
hasCheckEmail := false
|
||||||
c := &EmailChannel{
|
msgBus := bus.NewMessageBus()
|
||||||
BaseChannel: &BaseChannel{
|
c, err := NewEmailChannel(config.EmailConfig{
|
||||||
bus: bus.NewMessageBus(),
|
|
||||||
},
|
|
||||||
config: config.EmailConfig{
|
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
AllowFrom: config.FlexibleStringSlice{},
|
AllowFrom: config.FlexibleStringSlice{},
|
||||||
},
|
}, msgBus)
|
||||||
lastUID: 20,
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
c.lastUID = 20
|
||||||
mockClient := &client.Client{}
|
mockClient := &client.Client{}
|
||||||
c.imapClient = mockClient
|
c.imapClient = mockClient
|
||||||
// mock login and select to return mockClient
|
|
||||||
mockey.Mock(mockey.GetMethod(c, "CheckNewEmails")).To(func(*EmailChannel, context.Context) {
|
mockey.Mock(mockey.GetMethod(c, "CheckNewEmails")).To(func(*EmailChannel, context.Context) {
|
||||||
hasCheckEmail = true
|
hasCheckEmail = true
|
||||||
}).Build()
|
}).Build()
|
||||||
|
|
@ -164,20 +161,18 @@ func TestEmailChannel_lifecycleCheck(t *testing.T) {
|
||||||
|
|
||||||
mockey.PatchConvey("lifecycle test", t, func() {
|
mockey.PatchConvey("lifecycle test", t, func() {
|
||||||
// --------------- mock start ---------------
|
// --------------- mock start ---------------
|
||||||
c := &EmailChannel{
|
msgBus := bus.NewMessageBus()
|
||||||
BaseChannel: &BaseChannel{
|
c, err := NewEmailChannel(config.EmailConfig{
|
||||||
bus: bus.NewMessageBus(),
|
|
||||||
},
|
|
||||||
config: config.EmailConfig{
|
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
CheckInterval: 1,
|
CheckInterval: 1,
|
||||||
ForcedPolling: true,
|
ForcedPolling: true,
|
||||||
IMAPServer: "imap.example.com",
|
IMAPServer: "imap.example.com",
|
||||||
Username: "testuser",
|
Username: "testuser",
|
||||||
Password: "testpassword",
|
Password: "testpassword",
|
||||||
},
|
}, msgBus)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// mock login and select to return mockClient
|
|
||||||
mockey.Mock(mockey.GetMethod(c, "connect")).To(func(*EmailChannel) error {
|
mockey.Mock(mockey.GetMethod(c, "connect")).To(func(*EmailChannel) error {
|
||||||
return nil
|
return nil
|
||||||
}).Build()
|
}).Build()
|
||||||
|
|
@ -186,7 +181,7 @@ func TestEmailChannel_lifecycleCheck(t *testing.T) {
|
||||||
}).Build()
|
}).Build()
|
||||||
// --------------- mock end ---------------
|
// --------------- mock end ---------------
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := c.Start(ctx)
|
err = c.Start(ctx)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
wg := sync.WaitGroup{}
|
wg := sync.WaitGroup{}
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package channels
|
package email
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
13
pkg/channels/email/init.go
Normal file
13
pkg/channels/email/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
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) {
|
||||||
|
return NewEmailChannel(cfg.Channels.Email, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -206,16 +206,7 @@ func (m *Manager) initChannels() error {
|
||||||
m.initChannel("onebot", "OneBot")
|
m.initChannel("onebot", "OneBot")
|
||||||
}
|
}
|
||||||
if m.config.Channels.Email.Enabled {
|
if m.config.Channels.Email.Enabled {
|
||||||
logger.DebugC("channels", "Attempting to initialize Email channel")
|
m.initChannel("email", "Email")
|
||||||
email, err := NewEmailChannel(m.config.Channels.Email, m.bus)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("channels", "Failed to initialize Email channel", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
m.channels["email"] = email
|
|
||||||
logger.InfoC("channels", "Email channel enabled successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token != "" {
|
if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token != "" {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue