package main import ( "encoding/json" "errors" "io/fs" "net/http" "strings" "testing" ) // Fixture additions (testdata/mail/produktor.io): // // ano/cur/2.multipart — multipart/mixed: alternative (text/plain+text/html) // + image/png attachment (cid logo-cid-1), RFC 2047 encoded subject. // ano/cur/3.searchme — simple message with subject/from search targets. // // Combined with the pre-existing fixtures the unified inbox contains: // info 3 (cur 1.fixture1, 2.fixture2 + new 3.fixture3), ano 3, postmaster 1. func TestListMessagesCounts(t *testing.T) { msgs, err := listMessages("testdata/mail", "", "", 500) if err != nil { t.Fatal(err) } if len(msgs) != 7 { t.Fatalf("got %d messages, want 7 (info 3 + ano 3 + postmaster 1)", len(msgs)) } byMailbox := map[string]int{} for _, m := range msgs { byMailbox[m.Mailbox]++ if m.Size <= 0 { t.Errorf("%s/%s: size = %d, want > 0", m.Mailbox, m.Filename, m.Size) } if m.Filename == "" { t.Error("empty filename in listing") } } want := map[string]int{ "info@produktor.io": 3, "ano@produktor.io": 3, "postmaster@produktor.io": 1, } for mb, n := range want { if byMailbox[mb] != n { t.Errorf("mailbox %s: got %d messages, want %d", mb, byMailbox[mb], n) } } } func TestListMessagesSortNewestFirst(t *testing.T) { msgs, err := listMessages("testdata/mail", "", "", 500) if err != nil { t.Fatal(err) } // 3.searchme (Date 11:00 +0100) is newer than 2.multipart (10:00 +0100); // messages without a parseable Date fall back to file mtime. if msgs[0].Filename != "3.searchme" { t.Errorf("first = %s/%s, want ano/3.searchme", msgs[0].Mailbox, msgs[0].Filename) } if msgs[1].Filename != "2.multipart" { t.Errorf("second = %s/%s, want ano/2.multipart", msgs[1].Mailbox, msgs[1].Filename) } } func TestListMessagesMailboxFilter(t *testing.T) { msgs, err := listMessages("testdata/mail", "info@produktor.io", "", 500) if err != nil { t.Fatal(err) } if len(msgs) != 3 { t.Fatalf("got %d, want 3 for info@produktor.io", len(msgs)) } for _, m := range msgs { if m.Mailbox != "info@produktor.io" { t.Errorf("mailbox = %q, want info@produktor.io", m.Mailbox) } } // localpart-only filter is accepted too msgs, err = listMessages("testdata/mail", "postmaster", "", 500) if err != nil { t.Fatal(err) } if len(msgs) != 1 { t.Fatalf("localpart filter: got %d, want 1", len(msgs)) } } func TestListMessagesQuery(t *testing.T) { msgs, err := listMessages("testdata/mail", "", "acme", 500) if err != nil { t.Fatal(err) } if len(msgs) != 1 || msgs[0].Filename != "3.searchme" { t.Fatalf("q=acme: got %+v, want 1 hit (3.searchme)", msgs) } // case-insensitive on subject msgs, err = listMessages("testdata/mail", "", "INVOICE", 500) if err != nil { t.Fatal(err) } if len(msgs) != 1 || msgs[0].Subject != "Invoice #42 from Acme Corp" { t.Fatalf("q=INVOICE: got %+v, want 3.searchme", msgs) } msgs, err = listMessages("testdata/mail", "", "no such term", 500) if err != nil { t.Fatal(err) } if len(msgs) != 0 { t.Fatalf("q=no-match: got %d, want 0", len(msgs)) } } func TestListMessagesLimit(t *testing.T) { msgs, err := listMessages("testdata/mail", "", "", 2) if err != nil { t.Fatal(err) } if len(msgs) != 2 { t.Fatalf("limit 2: got %d, want 2", len(msgs)) } } func TestReadMessageMultipart(t *testing.T) { d, err := readMessage("testdata/mail", "ano@produktor.io", "2.multipart") if err != nil { t.Fatal(err) } if got := d.Headers.Get("Subject"); !strings.Contains(got, "Multipart fixture with") { t.Errorf("Subject header = %q, want RFC 2047 decoded", got) } if !strings.Contains(d.Text, "hello plain body") { t.Errorf("Text = %q, want plain body", d.Text) } if !strings.Contains(d.HTML, "html") { t.Errorf("HTML = %q, want html body", d.HTML) } if len(d.Attachments) != 1 { t.Fatalf("attachments = %d, want 1", len(d.Attachments)) } a := d.Attachments[0] if a.Filename != "logo.png" || a.CID != "logo-cid-1" || a.Size != 8 { t.Errorf("attachment = %+v, want logo.png cid=logo-cid-1 size=8", a) } if d.Headers.Get("From") == "" || d.Headers.Get("To") == "" || d.Headers.Get("Date") == "" { t.Error("expected from/to/date headers") } } func TestReadMessageSimple(t *testing.T) { d, err := readMessage("testdata/mail", "ano@produktor.io", "3.searchme") if err != nil { t.Fatal(err) } if d.Subject() != "Invoice #42 from Acme Corp" { t.Errorf("subject = %q", d.Subject()) } if !strings.Contains(d.Text, "Please pay") { t.Errorf("Text = %q, want body", d.Text) } if d.HTML != "" { t.Errorf("HTML = %q, want empty for plain message", d.HTML) } if len(d.Attachments) != 0 { t.Errorf("attachments = %d, want 0", len(d.Attachments)) } } func TestReadMessagePathSafety(t *testing.T) { cases := []struct{ mailbox, filename string }{ {"../../etc", "passwd"}, {"ano@produktor.io", "../../etc/passwd"}, {"ano@produktor.io", "..%2F1.fixtureano"}, {"ano@produktor.io", "nonexistent"}, {"ANO@produktor.io", "1.fixtureano"}, // uppercase rejected {"ano..@produktor.io", "1.fixtureano"}, {"ano@produktor.io/../info", "1.fixture1"}, } for _, c := range cases { if _, err := readMessage("testdata/mail", c.mailbox, c.filename); err == nil { t.Errorf("readMessage(%q, %q): expected error, got nil", c.mailbox, c.filename) } else if !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, errBadMailbox) { t.Errorf("readMessage(%q, %q): err = %v, want errBadMailbox or fs.ErrNotExist", c.mailbox, c.filename, err) } } } func TestAPIMessages(t *testing.T) { h := testServer(t).handler() rec := doAuth(t, h, "/admin/api/messages", "bot", "s3cret") if rec.Code != http.StatusOK { t.Fatalf("code = %d, want 200: %s", rec.Code, rec.Body.String()) } var resp messagesResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if resp.Count != 7 || len(resp.Messages) != 7 { t.Fatalf("count = %d len = %d, want 7", resp.Count, len(resp.Messages)) } if resp.Messages[0].Filename != "3.searchme" { t.Errorf("first = %+v, want 3.searchme (newest first)", resp.Messages[0]) } rec = doAuth(t, h, "/admin/api/messages?mailbox=postmaster@produktor.io", "bot", "s3cret") if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if resp.Count != 1 || resp.Messages[0].Mailbox != "postmaster@produktor.io" { t.Errorf("mailbox filter: count = %d, want 1 postmaster", resp.Count) } rec = doAuth(t, h, "/admin/api/messages?q=acme", "bot", "s3cret") if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if resp.Count != 1 { t.Errorf("q filter: count = %d, want 1", resp.Count) } rec = doAuth(t, h, "/admin/api/messages?limit=2", "bot", "s3cret") if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if len(resp.Messages) != 2 { t.Errorf("limit=2: got %d, want 2", len(resp.Messages)) } } func TestAPIMessageDetail(t *testing.T) { h := testServer(t).handler() rec := doAuth(t, h, "/admin/api/messages/ano@produktor.io/2.multipart", "bot", "s3cret") if rec.Code != http.StatusOK { t.Fatalf("code = %d, want 200: %s", rec.Code, rec.Body.String()) } var d messageDetail if err := json.Unmarshal(rec.Body.Bytes(), &d); err != nil { t.Fatal(err) } if !strings.Contains(d.Text, "hello plain body") || !strings.Contains(d.HTML, "html") { t.Errorf("detail body: text=%q html=%q", d.Text, d.HTML) } if len(d.Attachments) != 1 || d.Attachments[0].Filename != "logo.png" { t.Errorf("attachments = %+v, want logo.png", d.Attachments) } } func TestAPIMessageDetailUnsafe(t *testing.T) { h := testServer(t).handler() for _, p := range []string{ "/admin/api/messages/..%2F..%2Fetc/passwd", "/admin/api/messages/ano@produktor.io/..%2F..%2Fetc/passwd", "/admin/api/messages/ANO@produktor.io/1.fixtureano", "/admin/api/messages/ano@produktor.io/nonexistent", "/admin/api/messages/ano@produktor.io/1.fixtureano/extra", } { rec := doAuth(t, h, p, "bot", "s3cret") if rec.Code == http.StatusOK { t.Errorf("%s: code = %d, want 4xx", p, rec.Code) } } } func TestMessagesPagesRender(t *testing.T) { h := testServer(t).handler() rec := doAuth(t, h, "/admin/messages", "bot", "s3cret") body := rec.Body.String() if !strings.Contains(body, "All messages") || !strings.Contains(body, "ano@produktor.io") { t.Error("messages page missing title/mailbox grouping") } rec = doAuth(t, h, "/admin/messages", "bot", "s3cret") rec = doAuth(t, h, "/admin/message/ano@produktor.io/2.multipart", "bot", "s3cret") body = rec.Body.String() if rec.Code != http.StatusOK || !strings.Contains(body, "hello plain body") { t.Errorf("message page: code = %d, body missing text: %s", rec.Code, body[:min(len(body), 200)]) } }