From c0b2aa3d52255fb616b1b1cd8fbdea53f7cbc3bb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 31 Mar 2026 09:58:04 +0200 Subject: [PATCH] feat(email): add tests and example config for email channel Closes #8 Co-Authored-By: Claude Sonnet 4.6 --- config/config.example.json | 14 +++ pkg/channels/email/email_test.go | 189 +++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 pkg/channels/email/email_test.go diff --git a/config/config.example.json b/config/config.example.json index 012b9de3c..fdb1bf8eb 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -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": { diff --git a/pkg/channels/email/email_test.go b/pkg/channels/email/email_test.go new file mode 100644 index 000000000..133ff0650 --- /dev/null +++ b/pkg/channels/email/email_test.go @@ -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\nHi" + 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 part", + "--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) + } + }) + } +}