Implements Phase 5 of AGENTS.md §11 (FR-8, FR-15): - Message detail pane (GET /partials/message) with body, recommended action, reply identity, template picker, editable draft, and history. - internal/reply: OmniRoute (OpenAI-compatible) chat client and a template-based draft generator. All non-JEV models go through OmniRoute (§10). - internal/mailops: SMTP send over smtp.gmail.com:587 with STARTTLS and the app password, forcing IPv4 (§9); FR-15 reply identity (send as the arrived-at X-Pm-Original-To address, else "Sam Rolfe"); threading via In-Reply-To/References; -check-smtp safe verification; and a [Gmail]/Sent Mail thread check. - Migration v5 stores messages.message_id_header and drafts.sent_at; mailfetch parses Message-ID. - Send is user-triggered only: CSRF + UI confirm + confirm=yes guard, no auto-send, duplicate-send refused. A sent reply files the original under Replied-to/<address> and logs every step (FR-13). - Tests for reply, mailops SMTP/identity/thread check, store drafts and migration v5, and the server draft/edit/send guards. - docs/phase5-replies.md, README, .env.example, docker-compose updated. Co-Authored-By: Paperclip <noreply@paperclip.ing>
210 lines
6.1 KiB
Go
210 lines
6.1 KiB
Go
package mailops
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/config"
|
|
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
|
)
|
|
|
|
type fakeConn struct {
|
|
selected string
|
|
uidValidity uint32
|
|
created []string
|
|
moves []string
|
|
stores []string
|
|
searched []string
|
|
searchUIDs []uint32
|
|
searchErr error
|
|
loggedOut bool
|
|
}
|
|
|
|
func (f *fakeConn) Login(string, string) error { return nil }
|
|
func (f *fakeConn) Select(mailbox string) (uint32, error) {
|
|
f.selected = mailbox
|
|
return f.uidValidity, nil
|
|
}
|
|
func (f *fakeConn) CreateMailbox(name string) error {
|
|
f.created = append(f.created, name)
|
|
return nil
|
|
}
|
|
func (f *fakeConn) Move(uid uint32, dest string) error {
|
|
f.moves = append(f.moves, fmt.Sprintf("%d->%s", uid, dest))
|
|
return nil
|
|
}
|
|
func (f *fakeConn) StoreFlags(uid uint32, op, flag string) error {
|
|
f.stores = append(f.stores, fmt.Sprintf("%d %s%s", uid, op, flag))
|
|
return nil
|
|
}
|
|
func (f *fakeConn) SearchGmailID(id string) ([]uint32, error) {
|
|
f.searched = append(f.searched, id)
|
|
return f.searchUIDs, f.searchErr
|
|
}
|
|
func (f *fakeConn) SearchGmailThreadID(id string) ([]uint32, error) {
|
|
f.searched = append(f.searched, "thrid:"+id)
|
|
return f.searchUIDs, f.searchErr
|
|
}
|
|
func (f *fakeConn) Logout() error { f.loggedOut = true; return nil }
|
|
|
|
func configured() *config.Config {
|
|
return &config.Config{
|
|
Env: "test",
|
|
IMAPHost: config.DefaultIMAPHost,
|
|
IMAPPort: config.DefaultIMAPPort,
|
|
GmailUser: "user@example.test",
|
|
GmailAppPassword: "app-password",
|
|
SMTPHost: config.DefaultSMTPHost,
|
|
SMTPPort: config.DefaultSMTPPort,
|
|
}
|
|
}
|
|
|
|
func newFakeClient(fc *fakeConn) *Client {
|
|
c := New(configured(), slog.New(slog.DiscardHandler))
|
|
c.dial = func(context.Context, string, int, time.Duration) (Conn, error) { return fc, nil }
|
|
return c
|
|
}
|
|
|
|
func TestMailboxForFolder(t *testing.T) {
|
|
cases := []struct {
|
|
folder string
|
|
addr string
|
|
want string
|
|
}{
|
|
{store.FolderInbox, "", "INBOX"},
|
|
{store.FolderDelete, "", "Delete"},
|
|
{store.FolderSaved, "", "Saved"},
|
|
{store.FolderReplied, "Hello@Where-Woof.com", "Replied-to/hello@where-woof.com"},
|
|
{store.FolderReplied, "", "Replied-to/unknown"},
|
|
{"nonsense", "", "INBOX"},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := MailboxForFolder(tc.folder, tc.addr); got != tc.want {
|
|
t.Errorf("MailboxForFolder(%q, %q) = %q, want %q", tc.folder, tc.addr, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSanitizeLabelAddress(t *testing.T) {
|
|
if got := SanitizeLabelAddress(" <Sam+news@Example.com> "); got != "sam+news@example.com" {
|
|
t.Errorf("SanitizeLabelAddress() = %q", got)
|
|
}
|
|
if got := SanitizeLabelAddress(`weird"name@x`); got != "weird_name@x" {
|
|
t.Errorf("SanitizeLabelAddress() = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestMoveDeleteCreatesLabelAndUsesGmailID(t *testing.T) {
|
|
fc := &fakeConn{uidValidity: 7, searchUIDs: []uint32{42}}
|
|
c := newFakeClient(fc)
|
|
|
|
label, err := c.Move(context.Background(), store.Message{
|
|
ID: 1, GmailID: "1234000000000000000", Mailbox: "INBOX",
|
|
}, store.FolderDelete)
|
|
if err != nil {
|
|
t.Fatalf("Move() error = %v", err)
|
|
}
|
|
if label != "Delete" {
|
|
t.Errorf("label = %q, want Delete", label)
|
|
}
|
|
if len(fc.created) != 1 || fc.created[0] != "Delete" {
|
|
t.Errorf("created = %v, want [Delete]", fc.created)
|
|
}
|
|
if fc.selected != "INBOX" {
|
|
t.Errorf("selected = %q, want INBOX", fc.selected)
|
|
}
|
|
if len(fc.moves) != 1 || fc.moves[0] != "42->Delete" {
|
|
t.Errorf("moves = %v, want [42->Delete]", fc.moves)
|
|
}
|
|
if len(fc.searched) != 1 || fc.searched[0] != "1234000000000000000" {
|
|
t.Errorf("searched = %v", fc.searched)
|
|
}
|
|
if !fc.loggedOut {
|
|
t.Error("connection was not logged out")
|
|
}
|
|
}
|
|
|
|
func TestMoveRestoreDoesNotCreateInbox(t *testing.T) {
|
|
fc := &fakeConn{uidValidity: 7, searchUIDs: []uint32{9}}
|
|
c := newFakeClient(fc)
|
|
|
|
label, err := c.Move(context.Background(), store.Message{
|
|
ID: 2, GmailID: "555", Mailbox: "Delete",
|
|
}, store.FolderInbox)
|
|
if err != nil {
|
|
t.Fatalf("Move() error = %v", err)
|
|
}
|
|
if label != "INBOX" {
|
|
t.Errorf("label = %q, want INBOX", label)
|
|
}
|
|
if len(fc.created) != 0 {
|
|
t.Errorf("created = %v, want none for INBOX", fc.created)
|
|
}
|
|
if fc.selected != "Delete" {
|
|
t.Errorf("selected = %q, want Delete", fc.selected)
|
|
}
|
|
if len(fc.moves) != 1 || fc.moves[0] != "9->INBOX" {
|
|
t.Errorf("moves = %v", fc.moves)
|
|
}
|
|
}
|
|
|
|
func TestMoveNoopWhenAlreadyInFolder(t *testing.T) {
|
|
fc := &fakeConn{}
|
|
c := newFakeClient(fc)
|
|
if _, err := c.Move(context.Background(), store.Message{ID: 3, GmailID: "1", Mailbox: "Saved"}, store.FolderSaved); err != nil {
|
|
t.Fatalf("Move() error = %v", err)
|
|
}
|
|
if len(fc.moves) != 0 || len(fc.searched) != 0 {
|
|
t.Errorf("no-op move did work: moves=%v searched=%v", fc.moves, fc.searched)
|
|
}
|
|
}
|
|
|
|
func TestMoveRejectsNonGmailID(t *testing.T) {
|
|
c := newFakeClient(&fakeConn{})
|
|
_, err := c.Move(context.Background(), store.Message{ID: 4, GmailID: "uid:7:9", Mailbox: "INBOX"}, store.FolderDelete)
|
|
if err == nil {
|
|
t.Fatal("Move() error = nil, want failure for a uid: fallback id")
|
|
}
|
|
}
|
|
|
|
func TestMoveNotConfigured(t *testing.T) {
|
|
c := New(&config.Config{Env: "test", IMAPHost: config.DefaultIMAPHost, IMAPPort: config.DefaultIMAPPort}, nil)
|
|
_, err := c.Move(context.Background(), store.Message{ID: 5, GmailID: "1", Mailbox: "INBOX"}, store.FolderDelete)
|
|
if !errors.Is(err, ErrNotConfigured) {
|
|
t.Fatalf("Move() error = %v, want ErrNotConfigured", err)
|
|
}
|
|
}
|
|
|
|
func TestMarkMapsFlags(t *testing.T) {
|
|
cases := []struct {
|
|
mark string
|
|
want string
|
|
}{
|
|
{"read", "3 +\\Seen"},
|
|
{"unread", "3 -\\Seen"},
|
|
{"star", "3 +\\Flagged"},
|
|
{"unstar", "3 -\\Flagged"},
|
|
}
|
|
for _, tc := range cases {
|
|
fc := &fakeConn{uidValidity: 1, searchUIDs: []uint32{3}}
|
|
c := newFakeClient(fc)
|
|
if err := c.Mark(context.Background(), store.Message{ID: 6, GmailID: "777"}, tc.mark); err != nil {
|
|
t.Fatalf("Mark(%q) error = %v", tc.mark, err)
|
|
}
|
|
if len(fc.stores) != 1 || fc.stores[0] != tc.want {
|
|
t.Errorf("Mark(%q) stores = %v, want %q", tc.mark, fc.stores, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMarkUnknown(t *testing.T) {
|
|
c := newFakeClient(&fakeConn{})
|
|
if err := c.Mark(context.Background(), store.Message{ID: 7, GmailID: "1"}, "bogus"); err == nil {
|
|
t.Fatal("Mark(bogus) error = nil, want failure")
|
|
}
|
|
}
|