package cli import ( "path/filepath" "testing" "time" "git.dcglab.co.uk/steve/emcli/internal/mail" "git.dcglab.co.uk/steve/emcli/internal/store" ) // sendDeps builds Deps wired to a recording fake sender and an optional fake // mailer (for reply-to). The named account is created per the supplied template. func sendDeps(t *testing.T, acc store.Account, fm *fakeMailer) (Deps, *[]mail.OutgoingMessage, *[]byte) { t.Helper() st, err := store.Open(filepath.Join(t.TempDir(), "e.db")) if err != nil { t.Fatalf("store: %v", err) } if err := st.InitKeys(testKey(), testKey()); err != nil { t.Fatalf("InitKeys: %v", err) } t.Cleanup(func() { st.Close() }) if _, err := st.AddAccount(acc); err != nil { t.Fatalf("AddAccount: %v", err) } var sent []mail.OutgoingMessage buf := &[]byte{} d := Deps{ Store: st, Dial: func(store.Account) (Mailer, error) { return fm, nil }, Now: func() time.Time { return time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC) }, Out: bufWriter{buf}, Send: func(_ store.Account, m mail.OutgoingMessage) error { sent = append(sent, m) return nil }, } return d, &sent, buf } type bufWriter struct{ b *[]byte } func (w bufWriter) Write(p []byte) (int, error) { *w.b = append(*w.b, p...); return len(p), nil } func rwAccount() store.Account { return store.Account{ Name: "send", Mode: "RW", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls", SMTPHost: "h", SMTPPort: 465, SMTPSecurity: "tls", AuthType: "password", Username: "emcli@stevecliff.com", Password: "pw", } } func TestSendHappyPath(t *testing.T) { d, sent, buf := sendDeps(t, rwAccount(), nil) err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX") if err != nil { t.Fatalf("SendCmd: %v", err) } res := decode(t, *buf) if res["error"] != false { t.Fatalf("unexpected error: %v", res) } if len(*sent) != 1 { t.Fatalf("want 1 send, got %d", len(*sent)) } m := (*sent)[0] if len(m.To) != 1 || m.To[0] != "me@stevecliff.com" || m.Subject != "hi" { t.Fatalf("wrong outgoing message: %+v", m) } } func TestSendROBlocked(t *testing.T) { acc := rwAccount() acc.Mode = "RO" d, sent, buf := sendDeps(t, acc, nil) _ = SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX") res := decode(t, *buf) if res["error"] != true { t.Fatal("RO send must be blocked") } if len(*sent) != 0 { t.Fatal("sender must not be called when blocked") } } func TestSendWhitelistOutBlocked(t *testing.T) { acc := rwAccount() acc.WhitelistOutEnabled = true d, sent, buf := sendDeps(t, acc, nil) _ = d.Store.AddWhitelist("send", store.DirOut, "@stevecliff.com") // recipient not on the whitelist -> blocked _ = SendCmd(d, "send", []string{"stranger@elsewhere.com"}, nil, nil, "hi", "body", nil, 0, "INBOX") res := decode(t, *buf) if res["error"] != true { t.Fatal("non-whitelisted recipient must be blocked") } if len(*sent) != 0 { t.Fatal("sender must not be called when blocked") } } func TestSendWhitelistOutAllowed(t *testing.T) { acc := rwAccount() acc.WhitelistOutEnabled = true d, sent, _ := sendDeps(t, acc, nil) _ = d.Store.AddWhitelist("send", store.DirOut, "@stevecliff.com") if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil { t.Fatalf("SendCmd: %v", err) } if len(*sent) != 1 { t.Fatalf("whitelisted recipient should be sent, got %d", len(*sent)) } } func TestSendReplyToThreadsHeaders(t *testing.T) { acc := rwAccount() fm := &fakeMailer{ uidValidity: 1, maxUID: 100, headers: []mail.Header{ {UID: 42, From: "me@stevecliff.com", Subject: "orig", MessageID: "orig@stevecliff.com", References: []string{"root@stevecliff.com"}}, }, } d, sent, _ := sendDeps(t, acc, fm) if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "re: orig", "body", nil, 42, "INBOX"); err != nil { t.Fatalf("SendCmd: %v", err) } if len(*sent) != 1 { t.Fatalf("want 1 send, got %d", len(*sent)) } m := (*sent)[0] if m.InReplyTo != "orig@stevecliff.com" { t.Fatalf("InReplyTo=%q", m.InReplyTo) } // References should chain the source's references then the source itself. if len(m.References) != 2 || m.References[0] != "root@stevecliff.com" || m.References[1] != "orig@stevecliff.com" { t.Fatalf("References=%v", m.References) } } func TestSendUsesConfiguredFromAddress(t *testing.T) { acc := rwAccount() acc.FromAddress = "Steve Cliff " d, sent, _ := sendDeps(t, acc, nil) if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil { t.Fatalf("SendCmd: %v", err) } if len(*sent) != 1 { t.Fatalf("want 1 send, got %d", len(*sent)) } if got := (*sent)[0].From; got != "Steve Cliff " { t.Fatalf("From = %q, want configured from-address", got) } } func TestSendFallsBackToUsernameAsFrom(t *testing.T) { // rwAccount has no FromAddress, so From must be the login username. d, sent, _ := sendDeps(t, rwAccount(), nil) if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil { t.Fatalf("SendCmd: %v", err) } if got := (*sent)[0].From; got != "emcli@stevecliff.com" { t.Fatalf("From = %q, want username fallback", got) } } func TestSendReplyToFilteredSourceNotFound(t *testing.T) { acc := rwAccount() acc.WhitelistInEnabled = true // inbound filter active fm := &fakeMailer{ uidValidity: 1, maxUID: 100, headers: []mail.Header{ {UID: 42, From: "eve@evil.com", Subject: "spam", MessageID: "spam@evil.com"}, }, } d, sent, buf := sendDeps(t, acc, fm) _ = d.Store.AddWhitelist("send", store.DirIn, "@stevecliff.com") // 42 is from evil.com => filtered _ = SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "re", "body", nil, 42, "INBOX") res := decode(t, *buf) if res["error"] != true { t.Fatal("reply to a filtered source must fail") } ed := res["error_detail"].(map[string]any) if ed["code"] != "not_found" { t.Fatalf("want not_found, got %v", ed["code"]) } if len(*sent) != 0 { t.Fatal("must not send when reply source is filtered") } }