feat(email): add tests and example config for email channel

Closes #8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
github-actions[bot] 2026-03-31 09:58:04 +02:00
parent b8835ca2b4
commit c0b2aa3d52
2 changed files with 203 additions and 0 deletions

View file

@ -271,6 +271,20 @@
"enabled": false
},
"reasoning_channel_id": ""
},
"email": {
"enabled": false,
"smtp_host": "smtp.example.com",
"smtp_port": 587,
"smtp_from": "bot@example.com",
"smtp_user": "",
"default_subject": "Message from picoclaw",
"imap_host": "imap.example.com",
"imap_port": 993,
"imap_user": "bot@example.com",
"poll_interval_secs": 30,
"allow_from": [],
"reasoning_channel_id": ""
}
},
"tools": {

View file

@ -0,0 +1,189 @@
package email
import (
"strings"
"testing"
imap "github.com/emersion/go-imap/v2"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestNewEmailChannel(t *testing.T) {
msgBus := bus.NewMessageBus()
validCfg := config.EmailConfig{
SMTPHost: "smtp.example.com",
SMTPFrom: "bot@example.com",
IMAPHost: "imap.example.com",
IMAPUser: "bot@example.com",
}
t.Run("missing smtp_host", func(t *testing.T) {
cfg := validCfg
cfg.SMTPHost = ""
_, err := NewEmailChannel(cfg, msgBus)
if err == nil {
t.Error("expected error for missing smtp_host, got nil")
}
})
t.Run("missing smtp_from", func(t *testing.T) {
cfg := validCfg
cfg.SMTPFrom = ""
_, err := NewEmailChannel(cfg, msgBus)
if err == nil {
t.Error("expected error for missing smtp_from, got nil")
}
})
t.Run("missing imap_host", func(t *testing.T) {
cfg := validCfg
cfg.IMAPHost = ""
_, err := NewEmailChannel(cfg, msgBus)
if err == nil {
t.Error("expected error for missing imap_host, got nil")
}
})
t.Run("missing imap_user", func(t *testing.T) {
cfg := validCfg
cfg.IMAPUser = ""
_, err := NewEmailChannel(cfg, msgBus)
if err == nil {
t.Error("expected error for missing imap_user, got nil")
}
})
t.Run("valid config", func(t *testing.T) {
ch, err := NewEmailChannel(validCfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch.Name() != "email" {
t.Errorf("Name() = %q, want %q", ch.Name(), "email")
}
if ch.IsRunning() {
t.Error("new channel should not be running")
}
})
}
func TestExtractFrom(t *testing.T) {
tests := []struct {
name string
from []imap.Address
want string
}{
{
name: "full address",
from: []imap.Address{{Mailbox: "user", Host: "example.com"}},
want: "user@example.com",
},
{
name: "no host",
from: []imap.Address{{Mailbox: "local"}},
want: "local",
},
{
name: "empty from list",
from: []imap.Address{},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := &imap.Envelope{From: tt.from}
got := extractFrom(env)
if got != tt.want {
t.Errorf("extractFrom() = %q, want %q", got, tt.want)
}
})
}
}
func TestDisplayName(t *testing.T) {
tests := []struct {
name string
from []imap.Address
want string
}{
{
name: "address with display name",
from: []imap.Address{{Name: "Alice", Mailbox: "alice", Host: "example.com"}},
want: "Alice",
},
{
name: "address without display name",
from: []imap.Address{{Mailbox: "alice", Host: "example.com"}},
want: "alice@example.com",
},
{
name: "empty from list",
from: []imap.Address{},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := &imap.Envelope{From: tt.from}
got := displayName(env)
if got != tt.want {
t.Errorf("displayName() = %q, want %q", got, tt.want)
}
})
}
}
func TestExtractPlainText(t *testing.T) {
plainMIME := "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHello, world!"
htmlMIME := "MIME-Version: 1.0\r\nContent-Type: text/html; charset=utf-8\r\n\r\n<html><body>Hi</body></html>"
multipartMIME := strings.Join([]string{
"MIME-Version: 1.0",
`Content-Type: multipart/alternative; boundary="boundary"`,
"",
"--boundary",
"Content-Type: text/plain; charset=utf-8",
"",
"Plain text part",
"--boundary",
"Content-Type: text/html; charset=utf-8",
"",
"<html><body>HTML part</body></html>",
"--boundary--",
}, "\r\n")
tests := []struct {
name string
input string
want string
}{
{
name: "text/plain MIME",
input: plainMIME,
want: "Hello, world!",
},
{
name: "html-only MIME",
input: htmlMIME,
want: "",
},
{
name: "multipart with text/plain",
input: multipartMIME,
want: "Plain text part",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractPlainText(strings.NewReader(tt.input))
if got != tt.want {
t.Errorf("extractPlainText() = %q, want %q", got, tt.want)
}
})
}
}