feat(mail-admin): unified inbox view of all mailboxes (#76)
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
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, "<b>html</b>") {
|
||||
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, "<b>html</b>") {
|
||||
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)])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user