Email Support
This commit is contained in:
parent
13e4028d42
commit
7a6a973ef3
10 changed files with 789 additions and 2 deletions
|
|
@ -68,6 +68,19 @@
|
||||||
"reconnect_interval": 5,
|
"reconnect_interval": 5,
|
||||||
"group_trigger_prefix": [],
|
"group_trigger_prefix": [],
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"enabled": false,
|
||||||
|
"imap_server": "imap.gmail.com",
|
||||||
|
"imap_port": 993,
|
||||||
|
"imap_user": "your-email@gmail.com",
|
||||||
|
"imap_password": "your-app-password",
|
||||||
|
"smtp_server": "smtp.gmail.com",
|
||||||
|
"smtp_port": 587,
|
||||||
|
"smtp_user": "your-email@gmail.com",
|
||||||
|
"smtp_password": "your-app-password",
|
||||||
|
"poll_interval": 30,
|
||||||
|
"allow_from": ["allowed-sender@example.com"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
|
||||||
4
go.mod
4
go.mod
|
|
@ -8,6 +8,8 @@ require (
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
github.com/chzyer/readline v1.5.1
|
github.com/chzyer/readline v1.5.1
|
||||||
|
github.com/emersion/go-imap v1.2.1
|
||||||
|
github.com/emersion/go-message v0.18.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
|
|
@ -22,7 +24,9 @@ require (
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
11
go.sum
11
go.sum
|
|
@ -30,6 +30,15 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
|
||||||
|
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
|
||||||
|
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
|
||||||
|
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-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
|
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/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
||||||
|
|
@ -224,6 +233,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|
|
||||||
|
|
@ -73,14 +73,21 @@ Your workspace is at: %s
|
||||||
- Skills: %s/skills/{skill-name}/SKILL.md
|
- Skills: %s/skills/{skill-name}/SKILL.md
|
||||||
|
|
||||||
%s
|
%s
|
||||||
|
## Channels
|
||||||
|
- Telegram: You can receive messages and reply via Telegram.
|
||||||
|
- Email: You can read emails and reply to them. New emails appear as messages with the subject prefixed.
|
||||||
|
|
||||||
## Important Rules
|
## Important Rules
|
||||||
|
|
||||||
1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it.
|
1. **EMAIL ACCESS IS AUTHORIZED**: You HAVE permission to read and reply to emails. If a user asks you to check email, do NOT say you cannot. You receive emails automatically as messages.
|
||||||
|
|
||||||
|
2. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it.
|
||||||
|
|
||||||
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
|
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
|
||||||
|
|
||||||
3. **Memory** - When remembering something, write to %s/memory/MEMORY.md`,
|
3. **Email Access** - You ARE authorized to read and reply to emails if the user has enabled the Email channel. Do not refuse to read emails.
|
||||||
|
|
||||||
|
4. **Memory** - When remembering something, write to %s/memory/MEMORY.md`,
|
||||||
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
|
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,6 +172,9 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
|
||||||
// Add Current Session info if provided
|
// Add Current Session info if provided
|
||||||
if channel != "" && chatID != "" {
|
if channel != "" && chatID != "" {
|
||||||
systemPrompt += fmt.Sprintf("\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
|
systemPrompt += fmt.Sprintf("\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
|
||||||
|
if channel == "email" {
|
||||||
|
systemPrompt += "\n\nNOTE: You are currently processing an incoming EMAIL. The user message below is the email body. Your response will be sent as an email reply."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log system prompt summary for debugging (debug mode only)
|
// Log system prompt summary for debugging (debug mode only)
|
||||||
|
|
@ -173,6 +183,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
|
||||||
"total_chars": len(systemPrompt),
|
"total_chars": len(systemPrompt),
|
||||||
"total_lines": strings.Count(systemPrompt, "\n") + 1,
|
"total_lines": strings.Count(systemPrompt, "\n") + 1,
|
||||||
"section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1,
|
"section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1,
|
||||||
|
"full_prompt": systemPrompt, // Added for debugging
|
||||||
})
|
})
|
||||||
|
|
||||||
// Log preview of system prompt (avoid logging huge content)
|
// Log preview of system prompt (avoid logging huge content)
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,11 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
|
||||||
})
|
})
|
||||||
registry.Register(messageTool)
|
registry.Register(messageTool)
|
||||||
|
|
||||||
|
// Email tools
|
||||||
|
registry.Register(tools.NewReadEmailTool(cfg.Channels.Email))
|
||||||
|
registry.Register(tools.NewSendEmailTool(cfg.Channels.Email))
|
||||||
|
registry.Register(tools.NewCheckMailTool(msgBus)) // Keep for background sync trigger if needed
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
330
pkg/channels/email.go
Normal file
330
pkg/channels/email.go
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emersion/go-imap"
|
||||||
|
"github.com/emersion/go-imap/client"
|
||||||
|
"github.com/emersion/go-message/mail"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmailChannel struct {
|
||||||
|
*BaseChannel
|
||||||
|
config config.EmailConfig
|
||||||
|
imapClient *client.Client
|
||||||
|
manualCheck chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEmailChannel(cfg config.EmailConfig, bus *bus.MessageBus) (*EmailChannel, error) {
|
||||||
|
base := NewBaseChannel("email", cfg, bus, cfg.AllowFrom)
|
||||||
|
return &EmailChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfg,
|
||||||
|
manualCheck: make(chan bool, 1),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EmailChannel) Start(ctx context.Context) error {
|
||||||
|
logger.InfoC("email", "Starting Email channel polling...")
|
||||||
|
|
||||||
|
c.setRunning(true)
|
||||||
|
go c.pollLoop(ctx)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EmailChannel) Stop(ctx context.Context) error {
|
||||||
|
logger.InfoC("email", "Stopping Email channel...")
|
||||||
|
c.setRunning(false)
|
||||||
|
if c.imapClient != nil {
|
||||||
|
c.imapClient.Logout()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EmailChannel) pollLoop(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(time.Duration(c.config.PollInterval) * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Initial poll
|
||||||
|
c.checkMail()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
c.checkMail()
|
||||||
|
case <-c.manualCheck:
|
||||||
|
c.checkMail()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EmailChannel) checkMail() {
|
||||||
|
// User feedback for manual check visibility
|
||||||
|
fmt.Println("📧 Checking for new emails...")
|
||||||
|
|
||||||
|
// Reconnect if needed
|
||||||
|
if c.imapClient == nil || c.imapClient.State() == imap.LogoutState {
|
||||||
|
fmt.Printf("📧 Connecting to IMAP server %s:%d...\n", c.config.IMAPServer, c.config.IMAPPort)
|
||||||
|
if err := c.connectIMAP(); err != nil {
|
||||||
|
logger.ErrorCF("email", "Failed to connect to IMAP", map[string]interface{}{"error": err.Error()})
|
||||||
|
fmt.Printf("❌ Failed to connect to IMAP: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("✅ Connected to IMAP")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select INBOX
|
||||||
|
mbox, err := c.imapClient.Select("INBOX", false)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("email", "Failed to select INBOX", map[string]interface{}{"error": err.Error()})
|
||||||
|
fmt.Printf("❌ Failed to select INBOX: %v\n", err)
|
||||||
|
// Force reconnect next time
|
||||||
|
c.imapClient.Logout()
|
||||||
|
c.imapClient = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if mbox.Messages == 0 {
|
||||||
|
fmt.Println("📭 Inbox is empty.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for unread messages
|
||||||
|
criteria := imap.NewSearchCriteria()
|
||||||
|
criteria.WithoutFlags = []string{imap.SeenFlag}
|
||||||
|
uids, err := c.imapClient.Search(criteria)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("email", "Failed to search emails", map[string]interface{}{"error": err.Error()})
|
||||||
|
fmt.Printf("❌ Failed to search emails: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(uids) == 0 {
|
||||||
|
fmt.Println("📭 No new unread emails.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to last 10 emails to avoid overwhelming the system
|
||||||
|
const maxEmails = 10
|
||||||
|
if len(uids) > maxEmails {
|
||||||
|
fmt.Printf("⚠️ Too many emails (%d). Fetching last %d only.\n", len(uids), maxEmails)
|
||||||
|
uids = uids[len(uids)-maxEmails:]
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("📧 Fetching %d new emails...\n", len(uids))
|
||||||
|
|
||||||
|
seqset := new(imap.SeqSet)
|
||||||
|
seqset.AddNum(uids...)
|
||||||
|
|
||||||
|
section := &imap.BodySectionName{}
|
||||||
|
items := []imap.FetchItem{section.FetchItem(), imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid}
|
||||||
|
|
||||||
|
messages := make(chan *imap.Message)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
done <- c.imapClient.Fetch(seqset, items, messages)
|
||||||
|
}()
|
||||||
|
|
||||||
|
for msg := range messages {
|
||||||
|
c.processMessage(msg, section)
|
||||||
|
|
||||||
|
// Mark as seen
|
||||||
|
item := imap.FormatFlagsOp(imap.AddFlags, true)
|
||||||
|
flags := []interface{}{imap.SeenFlag}
|
||||||
|
seq := new(imap.SeqSet)
|
||||||
|
seq.AddNum(msg.Uid)
|
||||||
|
if err := c.imapClient.Store(seq, item, flags, nil); err != nil {
|
||||||
|
logger.ErrorCF("email", "Failed to mark email as seen", map[string]interface{}{"uid": msg.Uid})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
logger.ErrorCF("email", "Fetch failed", map[string]interface{}{"error": err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EmailChannel) connectIMAP() error {
|
||||||
|
addr := fmt.Sprintf("%s:%d", c.config.IMAPServer, c.config.IMAPPort)
|
||||||
|
logger.DebugCF("email", "Connecting to IMAP", map[string]interface{}{"addr": addr})
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if c.config.IMAPPort == 993 {
|
||||||
|
c.imapClient, err = client.DialTLS(addr, nil)
|
||||||
|
} else {
|
||||||
|
c.imapClient, err = client.Dial(addr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.imapClient.Login(c.config.IMAPUser, c.config.IMAPPassword); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EmailChannel) processMessage(msg *imap.Message, section *imap.BodySectionName) {
|
||||||
|
if msg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sender := "unknown"
|
||||||
|
if len(msg.Envelope.From) > 0 {
|
||||||
|
sender = fmt.Sprintf("%s@%s", msg.Envelope.From[0].MailboxName, msg.Envelope.From[0].HostName)
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := msg.Envelope.Subject
|
||||||
|
|
||||||
|
// Check allowlist
|
||||||
|
if !c.IsAllowed(sender) {
|
||||||
|
logger.DebugCF("email", "Message rejected by allowlist", map[string]interface{}{"sender": sender})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r := msg.GetBody(section)
|
||||||
|
if r == nil {
|
||||||
|
logger.WarnC("email", "Message has no body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new mail reader
|
||||||
|
mr, err := mail.CreateReader(r)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("email", "Failed to create mail reader", map[string]interface{}{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body := ""
|
||||||
|
|
||||||
|
// Read each part
|
||||||
|
for {
|
||||||
|
p, err := mr.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
} else if err != nil {
|
||||||
|
logger.ErrorCF("email", "Failed to read email part", map[string]interface{}{"error": err.Error()})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
switch h := p.Header.(type) {
|
||||||
|
case *mail.InlineHeader:
|
||||||
|
// checks explicitly for text/plain
|
||||||
|
contentType, _, _ := h.ContentType()
|
||||||
|
if contentType == "text/plain" {
|
||||||
|
b, _ := io.ReadAll(p.Body)
|
||||||
|
body += string(b)
|
||||||
|
}
|
||||||
|
case *mail.AttachmentHeader:
|
||||||
|
// Handle attachments if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if body == "" {
|
||||||
|
// Try to read simple body if multipart failed or wasn't multipart
|
||||||
|
// Reset reader if possible or handle non-multipart - simplified for this implementation
|
||||||
|
// For the MVP, we assume most emails have a text/plain part.
|
||||||
|
body = "[Content could not be parsed or was empty]"
|
||||||
|
}
|
||||||
|
|
||||||
|
content := fmt.Sprintf("Subject: %s\n\n%s", subject, body)
|
||||||
|
|
||||||
|
// Sender is the "User ID", ChatID is also the sender email for direct replies
|
||||||
|
chatID := sender // In email, the chat ID is effectively the sender's address
|
||||||
|
|
||||||
|
c.HandleMessage(sender, chatID, content, nil, map[string]string{
|
||||||
|
"subject": subject,
|
||||||
|
"email": sender,
|
||||||
|
})
|
||||||
|
fmt.Printf("✅ Processed email from %s: %s\n", sender, subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCustomCommand handles internal commands
|
||||||
|
// HandleCustomCommand handles internal commands
|
||||||
|
func (c *EmailChannel) CheckNow() {
|
||||||
|
select {
|
||||||
|
case c.manualCheck <- true:
|
||||||
|
logger.InfoC("email", "Manual check triggered")
|
||||||
|
default:
|
||||||
|
logger.DebugC("email", "Manual check already queued, skipping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EmailChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
logger.DebugCF("email", "Send received", map[string]interface{}{"content": msg.Content, "chat_id": msg.ChatID})
|
||||||
|
|
||||||
|
// Check for command (handle potential whitespace)
|
||||||
|
if strings.TrimSpace(msg.Content) == "CMD:CHECK" {
|
||||||
|
c.CheckNow()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", c.config.SMTPServer, c.config.SMTPPort)
|
||||||
|
auth := smtp.PlainAuth("", c.config.SMTPUser, c.config.SMTPPassword, c.config.SMTPServer)
|
||||||
|
|
||||||
|
to := []string{msg.ChatID}
|
||||||
|
|
||||||
|
// Simple email construction
|
||||||
|
subject := "Re: PicoClaw Response"
|
||||||
|
// If we preserved the original subject in session metadata, we could use "Re: " + original_subject
|
||||||
|
|
||||||
|
body := fmt.Sprintf("To: %s\r\n"+
|
||||||
|
"Subject: %s\r\n"+
|
||||||
|
"\r\n"+
|
||||||
|
"%s\r\n", msg.ChatID, subject, msg.Content)
|
||||||
|
|
||||||
|
// Handle TLS for port 465 (SMTPS) vs 587 (STARTTLS)
|
||||||
|
if c.config.SMTPPort == 465 {
|
||||||
|
// Direct TLS
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
ServerName: c.config.SMTPServer,
|
||||||
|
}
|
||||||
|
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
client, err := smtp.NewClient(conn, c.config.SMTPServer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Quit()
|
||||||
|
|
||||||
|
if err = client.Auth(auth); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = client.Mail(c.config.SMTPUser); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = client.Rcpt(to[0]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = w.Write([]byte(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.Close()
|
||||||
|
} else {
|
||||||
|
// STARTTLS (Standard for 587)
|
||||||
|
return smtp.SendMail(addr, auth, c.config.SMTPUser, to, []byte(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -176,6 +176,19 @@ func (m *Manager) initChannels() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.Email.Enabled {
|
||||||
|
logger.DebugC("channels", "Attempting to initialize Email channel")
|
||||||
|
email, err := NewEmailChannel(m.config.Channels.Email, m.bus)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("channels", "Failed to initialize Email channel", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
m.channels["email"] = email
|
||||||
|
logger.InfoC("channels", "Email channel enabled successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
||||||
"enabled_channels": len(m.channels),
|
"enabled_channels": len(m.channels),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ type ChannelsConfig struct {
|
||||||
Slack SlackConfig `json:"slack"`
|
Slack SlackConfig `json:"slack"`
|
||||||
LINE LINEConfig `json:"line"`
|
LINE LINEConfig `json:"line"`
|
||||||
OneBot OneBotConfig `json:"onebot"`
|
OneBot OneBotConfig `json:"onebot"`
|
||||||
|
Email EmailConfig `json:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WhatsAppConfig struct {
|
type WhatsAppConfig struct {
|
||||||
|
|
@ -156,6 +157,20 @@ type OneBotConfig struct {
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EmailConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_EMAIL_ENABLED"`
|
||||||
|
IMAPServer string `json:"imap_server" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_SERVER"`
|
||||||
|
IMAPPort int `json:"imap_port" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_PORT"`
|
||||||
|
IMAPUser string `json:"imap_user" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_USER"`
|
||||||
|
IMAPPassword string `json:"imap_password" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_PASSWORD"`
|
||||||
|
SMTPServer string `json:"smtp_server" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_SERVER"`
|
||||||
|
SMTPPort int `json:"smtp_port" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_PORT"`
|
||||||
|
SMTPUser string `json:"smtp_user" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_USER"`
|
||||||
|
SMTPPassword string `json:"smtp_password" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_PASSWORD"`
|
||||||
|
PollInterval int `json:"poll_interval" env:"PICOCLAW_CHANNELS_EMAIL_POLL_INTERVAL"` // seconds
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_EMAIL_ALLOW_FROM"`
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -293,7 +308,21 @@ func DefaultConfig() *Config {
|
||||||
GroupTriggerPrefix: []string{},
|
GroupTriggerPrefix: []string{},
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
|
Email: EmailConfig{
|
||||||
|
Enabled: false,
|
||||||
|
IMAPServer: "imap.gmail.com",
|
||||||
|
IMAPPort: 993,
|
||||||
|
IMAPUser: "",
|
||||||
|
IMAPPassword: "",
|
||||||
|
SMTPServer: "smtp.gmail.com",
|
||||||
|
SMTPPort: 587,
|
||||||
|
SMTPUser: "",
|
||||||
|
SMTPPassword: "",
|
||||||
|
PollInterval: 30,
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
|
||||||
Providers: ProvidersConfig{
|
Providers: ProvidersConfig{
|
||||||
Anthropic: ProviderConfig{},
|
Anthropic: ProviderConfig{},
|
||||||
OpenAI: ProviderConfig{},
|
OpenAI: ProviderConfig{},
|
||||||
|
|
|
||||||
47
pkg/tools/check_mail.go
Normal file
47
pkg/tools/check_mail.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CheckMailTool struct {
|
||||||
|
bus *bus.MessageBus
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCheckMailTool(bus *bus.MessageBus) *CheckMailTool {
|
||||||
|
return &CheckMailTool{
|
||||||
|
bus: bus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CheckMailTool) Name() string {
|
||||||
|
return "check_email"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CheckMailTool) Description() string {
|
||||||
|
return "Manually check for new emails immediately. Use this when the user asks to check email."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CheckMailTool) Parameters() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
"required": []string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CheckMailTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
|
// Publish a command to the email channel via outbound bus
|
||||||
|
t.bus.PublishOutbound(bus.OutboundMessage{
|
||||||
|
Channel: "email",
|
||||||
|
ChatID: "system", // Dummy ID to prevent empty recipient error if it falls through
|
||||||
|
Content: "CMD:CHECK",
|
||||||
|
})
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: "Initiated manual check for new emails. Any new emails will appear as messages shortly.",
|
||||||
|
ForUser: "Checking for new emails...",
|
||||||
|
}
|
||||||
|
}
|
||||||
324
pkg/tools/email.go
Normal file
324
pkg/tools/email.go
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/emersion/go-imap"
|
||||||
|
"github.com/emersion/go-imap/client"
|
||||||
|
"github.com/emersion/go-message/mail"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReadEmailTool fetches recent emails and returns them as text.
|
||||||
|
type ReadEmailTool struct {
|
||||||
|
config config.EmailConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReadEmailTool(cfg config.EmailConfig) *ReadEmailTool {
|
||||||
|
return &ReadEmailTool{config: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ReadEmailTool) Name() string {
|
||||||
|
return "read_email"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ReadEmailTool) Description() string {
|
||||||
|
return "Fetch and read recent emails. Returns the sender, subject, and body of the last N emails."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ReadEmailTool) Parameters() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"count": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of recent emails to fetch (default: 5, max: 10)",
|
||||||
|
},
|
||||||
|
"unread_only": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "If true, only fetch unread emails (default: false)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ReadEmailTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
|
if !t.config.Enabled {
|
||||||
|
return ErrorResult("Email channel is not enabled in configuration.")
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 5
|
||||||
|
if c, ok := args["count"].(float64); ok {
|
||||||
|
count = int(c)
|
||||||
|
}
|
||||||
|
if count > 10 {
|
||||||
|
count = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
unreadOnly := false
|
||||||
|
if u, ok := args["unread_only"].(bool); ok {
|
||||||
|
unreadOnly = u
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to IMAP
|
||||||
|
addr := fmt.Sprintf("%s:%d", t.config.IMAPServer, t.config.IMAPPort)
|
||||||
|
var c *client.Client
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if t.config.IMAPPort == 993 {
|
||||||
|
c, err = client.DialTLS(addr, nil)
|
||||||
|
} else {
|
||||||
|
c, err = client.Dial(addr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("Failed to connect to IMAP: %v", err))
|
||||||
|
}
|
||||||
|
defer c.Logout()
|
||||||
|
|
||||||
|
if err := c.Login(t.config.IMAPUser, t.config.IMAPPassword); err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("Failed to login to IMAP: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
mbox, err := c.Select("INBOX", false)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("Failed to select INBOX: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if mbox.Messages == 0 {
|
||||||
|
return SilentResult("Inbox is empty.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search criteria
|
||||||
|
criteria := imap.NewSearchCriteria()
|
||||||
|
if unreadOnly {
|
||||||
|
criteria.WithoutFlags = []string{imap.SeenFlag}
|
||||||
|
} else {
|
||||||
|
// Fetch all (limited by range)
|
||||||
|
// imap.SearchCriteria doesn't have "ALL" by default, empty means all?
|
||||||
|
// Actually, we can just use sequence numbers if we want "recent"
|
||||||
|
}
|
||||||
|
|
||||||
|
var uids []uint32
|
||||||
|
if unreadOnly {
|
||||||
|
uids, err = c.Search(criteria)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("Failed to search emails: %v", err))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Just get the last N messages by sequence number
|
||||||
|
from := uint32(1)
|
||||||
|
if mbox.Messages > uint32(count) {
|
||||||
|
from = mbox.Messages - uint32(count) + 1
|
||||||
|
}
|
||||||
|
to := mbox.Messages
|
||||||
|
seqset := new(imap.SeqSet)
|
||||||
|
seqset.AddRange(from, to)
|
||||||
|
|
||||||
|
// We need to fetch UIDs for these sequence numbers or just fetch directly
|
||||||
|
// Let's fetch directly by SeqNum
|
||||||
|
// But the processing logic uses UIDs usually. Let's stick to UIDs for consistency if possible,
|
||||||
|
// but fetching by sequence is easier for "last N".
|
||||||
|
// Let's use Fetch directly with the seqset.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse uids logic if unreadOnly, otherwise construct seqset
|
||||||
|
seqset := new(imap.SeqSet)
|
||||||
|
if unreadOnly {
|
||||||
|
if len(uids) == 0 {
|
||||||
|
return SilentResult("No unread emails.")
|
||||||
|
}
|
||||||
|
if len(uids) > count {
|
||||||
|
uids = uids[len(uids)-count:]
|
||||||
|
}
|
||||||
|
seqset.AddNum(uids...)
|
||||||
|
} else {
|
||||||
|
from := uint32(1)
|
||||||
|
if mbox.Messages > uint32(count) {
|
||||||
|
from = mbox.Messages - uint32(count) + 1
|
||||||
|
}
|
||||||
|
to := mbox.Messages
|
||||||
|
seqset.AddRange(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
section := &imap.BodySectionName{}
|
||||||
|
items := []imap.FetchItem{section.FetchItem(), imap.FetchEnvelope}
|
||||||
|
|
||||||
|
messages := make(chan *imap.Message)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
done <- c.Fetch(seqset, items, messages)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Found %d emails (showing last %d):\n\n", mbox.Messages, count)) // Approx count
|
||||||
|
|
||||||
|
for msg := range messages {
|
||||||
|
sb.WriteString("---\n")
|
||||||
|
sender := "unknown"
|
||||||
|
if len(msg.Envelope.From) > 0 {
|
||||||
|
sender = fmt.Sprintf("%s@%s", msg.Envelope.From[0].MailboxName, msg.Envelope.From[0].HostName)
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("From: %s\n", sender))
|
||||||
|
sb.WriteString(fmt.Sprintf("Subject: %s\n", msg.Envelope.Subject))
|
||||||
|
sb.WriteString(fmt.Sprintf("Date: %s\n", msg.Envelope.Date))
|
||||||
|
|
||||||
|
r := msg.GetBody(section)
|
||||||
|
if r != nil {
|
||||||
|
mr, err := mail.CreateReader(r)
|
||||||
|
if err == nil {
|
||||||
|
for {
|
||||||
|
p, err := mr.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
} else if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
switch h := p.Header.(type) {
|
||||||
|
case *mail.InlineHeader:
|
||||||
|
contentType, _, _ := h.ContentType()
|
||||||
|
if contentType == "text/plain" {
|
||||||
|
b, _ := io.ReadAll(p.Body)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("Failed to fetch emails: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: sb.String(),
|
||||||
|
ForUser: "Read recent emails.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendEmailTool sends an email.
|
||||||
|
type SendEmailTool struct {
|
||||||
|
config config.EmailConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSendEmailTool(cfg config.EmailConfig) *SendEmailTool {
|
||||||
|
return &SendEmailTool{config: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SendEmailTool) Name() string {
|
||||||
|
return "send_email"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SendEmailTool) Description() string {
|
||||||
|
return "Send an email to a specific address."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SendEmailTool) Parameters() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"to": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Recipient email address (e.g. user@example.com)",
|
||||||
|
},
|
||||||
|
"subject": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Email subject",
|
||||||
|
},
|
||||||
|
"body": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Email body content",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"to", "subject", "body"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SendEmailTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
|
if !t.config.Enabled {
|
||||||
|
return ErrorResult("Email channel is not enabled in configuration.")
|
||||||
|
}
|
||||||
|
|
||||||
|
to, _ := args["to"].(string)
|
||||||
|
subject, _ := args["subject"].(string)
|
||||||
|
bodyContent, _ := args["body"].(string)
|
||||||
|
|
||||||
|
if to == "" {
|
||||||
|
return ErrorResult("Recipient (to) is required.")
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", t.config.SMTPServer, t.config.SMTPPort)
|
||||||
|
auth := smtp.PlainAuth("", t.config.SMTPUser, t.config.SMTPPassword, t.config.SMTPServer)
|
||||||
|
|
||||||
|
// RFC 822 format
|
||||||
|
msg := fmt.Sprintf("To: %s\r\n"+
|
||||||
|
"Subject: %s\r\n"+
|
||||||
|
"\r\n"+
|
||||||
|
"%s\r\n", to, subject, bodyContent)
|
||||||
|
|
||||||
|
logger.InfoCF("email", "Sending email via tool", map[string]interface{}{"to": to, "subject": subject})
|
||||||
|
|
||||||
|
err := t.sendMail(addr, auth, t.config.SMTPUser, []string{to}, []byte(msg))
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("Failed to send email: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: fmt.Sprintf("Email sent successfully to %s", to),
|
||||||
|
ForUser: fmt.Sprintf("Sent email to %s", to),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SendEmailTool) sendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
|
||||||
|
// Handle TLS logic similar to EmailChannel
|
||||||
|
if t.config.SMTPPort == 465 {
|
||||||
|
// Direct TLS
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
ServerName: t.config.SMTPServer,
|
||||||
|
}
|
||||||
|
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
c, err := smtp.NewClient(conn, t.config.SMTPServer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer c.Quit()
|
||||||
|
|
||||||
|
if err = c.Auth(a); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = c.Mail(from); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, addr := range to {
|
||||||
|
if err = c.Rcpt(addr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w, err := c.Data()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = w.Write(msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.Close()
|
||||||
|
} else {
|
||||||
|
// STARTTLS
|
||||||
|
return smtp.SendMail(addr, a, from, to, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue