diff --git a/README.md b/README.md index cf23917..1722d17 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,37 @@ curl -u "$MAIL_ADMIN_USER:$MAIL_ADMIN_PASSWORD" https://mail.produktor.io/admin/ Credentials `MAIL_ADMIN_USER` / `MAIL_ADMIN_PASSWORD` are required in `.env` (compose fails without them). The JSON API is at `/admin/api/accounts`. +## Web UI → Все письма + +**https://mail.produktor.io/admin/messages** — unified inbox of **all** +mailboxes (info@, postmaster@, ano@, andriy.oblivantsev@, postman@) read +directly from the Maildir on disk: no IMAP, no user passwords. Each row shows +date, from, subject, size and links to the full message view (text/html +toggle + attachment list + headers). Messages are grouped by mailbox, sorted +newest first; the search box filters by subject/from client-side. + +Read-only API (same Basic Auth): + +| Endpoint | Description | +|----------|-------------| +| `GET /admin/api/messages` | Unified list, newest first. Filters: `?mailbox=` (full address or localpart), `?q=` (subject/from, case-insensitive), `?limit=` (default 100, max 500) | +| `GET /admin/api/messages//` | Full message: decoded headers + unfolded `text/plain`/`text/html` bodies + `attachments` (filename/size/cid) | + +```bash +curl -u "$MAIL_ADMIN_USER:$MAIL_ADMIN_PASSWORD" \ + "https://mail.produktor.io/admin/api/messages?limit=5" +curl -u "$MAIL_ADMIN_USER:$MAIL_ADMIN_PASSWORD" \ + "https://mail.produktor.io/admin/api/messages/ano@produktor.io/" +``` + +Layout assumption: `data/mail-data///{cur,new}/` (docker +-mailserver default Maildir; message files in `cur/` + `new/`, `tmp/` is +transient and ignored). Mailbox and filename are validated strictly and +resolved against the real directory listing — path traversal and symlink +escape from `mail-data/` are impossible. MIME multipart bodies are unfolded +recursively with the Go stdlib (`net/mail`, `mime/multipart`, +`mime/quotedprintable`); nothing is ever written or deleted. + ## Reverse proxy (NPM) `mail.produktor.io` is a proxy host in Nginx Proxy Manager (`provider` container, diff --git a/admin/messages.go b/admin/messages.go new file mode 100644 index 0000000..01c935d --- /dev/null +++ b/admin/messages.go @@ -0,0 +1,393 @@ +package main + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "io" + "io/fs" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/mail" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// errBadMailbox marks a mailbox identifier that fails shape validation; it is +// mapped to HTTP 400, never to a filesystem lookup. +var errBadMailbox = errors.New("invalid mailbox") + +// mailboxRe accepts only the characters that can appear in a real mailbox +// address: lowercase letters, digits, dot, underscore, dash and one "@". +// Anything else (slashes, "..", uppercase, control chars) is rejected before +// it can reach the filesystem. +var mailboxRe = regexp.MustCompile(`^[a-z0-9._-]+@[a-z0-9.-]+$`) + +// splitMailbox validates the full address shape and returns local/domain. +func splitMailbox(mailbox string) (local, domain string, err error) { + if !mailboxRe.MatchString(mailbox) { + return "", "", fmt.Errorf("%w: %q", errBadMailbox, mailbox) + } + local, domain, _ = strings.Cut(mailbox, "@") + return local, domain, nil +} + +// messageSummary is one row of the unified inbox list. +type messageSummary struct { + Mailbox string `json:"mailbox"` + Filename string `json:"filename"` + MessageID string `json:"message_id,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Subject string `json:"subject,omitempty"` + Date time.Time `json:"date"` + Size int64 `json:"size"` + Path string `json:"path"` // maildir-relative path hint + when time.Time // sort key (Date header or file mtime), not serialized +} + +// messagesResponse is the JSON payload of GET /api/messages. +type messagesResponse struct { + Updated time.Time `json:"updated"` + Count int `json:"count"` + Messages []messageSummary `json:"messages"` +} + +// listMessages returns the unified inbox: every message file in cur/ and new/ +// of every mailbox under root (data/mail-data//), newest +// first. mailboxFilter matches the full address or the bare localpart; query +// is a case-insensitive substring match on subject and from. +func listMessages(root, mailboxFilter, query string, limit int) ([]messageSummary, error) { + filter := strings.ToLower(strings.TrimSpace(query)) + var out []messageSummary + err := walkMailboxes(root, func(local, domain string) error { + mailbox := local + "@" + domain + if mailboxFilter != "" && mailbox != mailboxFilter && local != mailboxFilter { + return nil + } + for _, sub := range []string{"cur", "new"} { + dir := filepath.Join(root, domain, local, sub) + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + continue // no cur/ or new/ yet + } + return err + } + for _, e := range entries { + if e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + continue + } + path := filepath.Join(dir, e.Name()) + s, err := summarizeMessage(root, path, mailbox, e.Name(), sub, info) + if err != nil { + continue // unparseable file (dovecot index, lock): skip + } + if filter != "" && + !strings.Contains(strings.ToLower(s.Subject), filter) && + !strings.Contains(strings.ToLower(s.From), filter) { + continue + } + out = append(out, s) + } + } + return nil + }) + if err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { return out[i].when.After(out[j].when) }) + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out, nil +} + +// walkMailboxes visits every / maildir under root. +func walkMailboxes(root string, fn func(local, domain string) error) error { + domains, err := os.ReadDir(root) + if err != nil { + return err + } + for _, d := range domains { + if !d.IsDir() { + continue + } + users, err := os.ReadDir(filepath.Join(root, d.Name())) + if err != nil { + continue + } + for _, u := range users { + if u.IsDir() { + if err := fn(u.Name(), d.Name()); err != nil { + return err + } + } + } + } + return nil +} + +// summarizeMessage reads the headers of one message file. Files that do not +// parse as RFC 5322 (empty, binary index leftovers) still appear in the list +// with filename and size only. +func summarizeMessage(root, path, mailbox, filename, sub string, info fs.FileInfo) (messageSummary, error) { + s := messageSummary{ + Mailbox: mailbox, + Filename: filename, + Size: info.Size(), + when: info.ModTime(), + } + if rel, err := filepath.Rel(root, path); err == nil { + s.Path = rel + } + f, err := os.Open(path) + if err != nil { + return s, err + } + defer f.Close() + msg, err := mail.ReadMessage(f) + if err != nil { + return s, nil // not a message: keep the row, no headers + } + h := msg.Header + s.MessageID = decodeHeader(h.Get("Message-Id")) + s.From = headerAddress(h.Get("From")) + s.To = headerAddress(h.Get("To")) + s.Subject = decodeHeader(h.Get("Subject")) + if d, err := mail.ParseDate(h.Get("Date")); err == nil { + s.Date, s.when = d, d + } + return s, nil +} + +// headerAddress extracts the first address from an RFC 5322 address header, +// falling back to the raw value when parsing fails. +func headerAddress(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + if addrs, err := mail.ParseAddressList(v); err == nil && len(addrs) > 0 { + return addrs[0].String() + } + return v +} + +// attachmentInfo describes one MIME attachment part. +type attachmentInfo struct { + Filename string `json:"filename,omitempty"` + Size int64 `json:"size"` + CID string `json:"cid,omitempty"` +} + +// headerMap keeps header values per name (multiple values allowed, e.g. +// Received) with a convenience Getter like net/http.Header. +type headerMap map[string][]string + +func (h headerMap) Get(key string) string { + if v := h[key]; len(v) > 0 { + return v[0] + } + return "" +} + +// messageDetail is the JSON payload of GET /api/messages//: +// decoded headers, the unfolded text/plain and text/html bodies and a list of +// attachments. Read-only: the underlying message file is never modified. +type messageDetail struct { + Mailbox string `json:"mailbox"` + Filename string `json:"filename"` + Size int64 `json:"size"` + Headers headerMap `json:"headers"` + Text string `json:"text"` + HTML string `json:"html"` + Attachments []attachmentInfo `json:"attachments"` +} + +// Subject, From and To expose decoded display values from the header map +// (headers are stored decoded by parseMessage). +func (d messageDetail) Subject() string { return d.Headers.Get("Subject") } +func (d messageDetail) From() string { return headerAddress(d.Headers.Get("From")) } +func (d messageDetail) To() string { return headerAddress(d.Headers.Get("To")) } + +// readMessage resolves mailbox+filename against the real listing (a request +// filename can never build a path of its own) and parses the message. It +// returns fs.ErrNotExist when the file is not in cur/ or new/ of the mailbox. +func readMessage(root, mailbox, filename string) (messageDetail, error) { + local, domain, err := splitMailbox(mailbox) + if err != nil { + return messageDetail{}, err + } + full := "" + for _, sub := range []string{"cur", "new"} { + dir := filepath.Join(root, domain, local, sub) + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + continue + } + return messageDetail{}, err + } + for _, e := range entries { + if !e.IsDir() && e.Name() == filename { + full = filepath.Join(dir, e.Name()) + break + } + } + } + if full == "" { + return messageDetail{}, os.ErrNotExist + } + info, err := os.Stat(full) + if err != nil { + return messageDetail{}, err + } + data, err := os.ReadFile(full) + if err != nil { + return messageDetail{}, err + } + d, err := parseMessage(data, mailbox, filename) + if err != nil { + return messageDetail{}, err + } + d.Size = info.Size() + return d, nil +} + +// parseMessage splits a raw message into decoded headers, text/html bodies +// and attachment metadata. multipart bodies are unfolded recursively. +func parseMessage(data []byte, mailbox, filename string) (messageDetail, error) { + msg, err := mail.ReadMessage(bytes.NewReader(data)) + if err != nil { + return messageDetail{}, err + } + d := messageDetail{ + Mailbox: mailbox, + Filename: filename, + Headers: headerMap{}, + } + for k, vs := range msg.Header { + decoded := make([]string, len(vs)) + for i, v := range vs { + decoded[i] = decodeHeader(v) + } + d.Headers[k] = decoded + } + mediatype, params, _ := mime.ParseMediaType(msg.Header.Get("Content-Type")) + if mediatype == "" { + mediatype = "text/plain" + } + if strings.HasPrefix(mediatype, "multipart/") { + if err := parseMultipart(msg.Body, params["boundary"], &d); err != nil { + return messageDetail{}, err + } + return d, nil + } + body, err := io.ReadAll(decodeTransfer(msg.Header.Get("Content-Transfer-Encoding"), msg.Body)) + if err != nil { + return messageDetail{}, err + } + addPart(msg.Header, mediatype, body, &d) + return d, nil +} + +// parseMultipart walks one multipart/* body, recursing into nested +// multipart parts (mixed/alternative/related). +func parseMultipart(r io.Reader, boundary string, d *messageDetail) error { + mr := multipart.NewReader(r, boundary) + for { + part, err := mr.NextPart() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + mediatype, params, _ := mime.ParseMediaType(part.Header.Get("Content-Type")) + if mediatype == "" { + mediatype = "text/plain" + } + if strings.HasPrefix(mediatype, "multipart/") { + if err := parseMultipart(part, params["boundary"], d); err != nil { + return err + } + part.Close() + continue + } + data, err := io.ReadAll(decodeTransfer(part.Header.Get("Content-Transfer-Encoding"), part)) + if err != nil { + part.Close() + return err + } + addPart(part.Header, mediatype, data, d) + part.Close() + } +} + +// addPart dispatches one leaf MIME part: text/plain and text/html feed the +// body views, everything else becomes an attachment entry. +func addPart(h interface{ Get(string) string }, mediatype string, data []byte, d *messageDetail) { + switch mediatype { + case "text/plain": + d.Text += string(data) + case "text/html": + d.HTML += string(data) + default: + a := attachmentInfo{Size: int64(len(data))} + if cd := h.Get("Content-Disposition"); cd != "" { + if _, p, err := mime.ParseMediaType(cd); err == nil { + a.Filename = p["filename"] + } + } + if a.Filename == "" { + if _, p, err := mime.ParseMediaType(h.Get("Content-Type")); err == nil { + a.Filename = p["name"] + } + } + if cid := h.Get("Content-Id"); cid != "" { + a.CID = strings.Trim(cid, "<>") + } + d.Attachments = append(d.Attachments, a) + } +} + +// decodeTransfer wraps a part body with the decoder for its +// Content-Transfer-Encoding; base64 and quoted-printable are the only +// encodings that need one, everything else is read verbatim. +func decodeTransfer(enc string, r io.Reader) io.Reader { + switch strings.ToLower(strings.TrimSpace(enc)) { + case "base64": + return base64.NewDecoder(base64.StdEncoding, r) + case "quoted-printable": + return quotedprintable.NewReader(r) + default: + return r + } +} + +// wordDecoder decodes RFC 2047 encoded words in header values. +var wordDecoder = &mime.WordDecoder{} + +func decodeHeader(v string) string { + if v == "" { + return "" + } + if d, err := wordDecoder.DecodeHeader(v); err == nil { + return d + } + return v +} diff --git a/admin/messages_test.go b/admin/messages_test.go new file mode 100644 index 0000000..8d20080 --- /dev/null +++ b/admin/messages_test.go @@ -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, "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)]) + } +} diff --git a/admin/server.go b/admin/server.go index 3c337c2..09543b2 100644 --- a/admin/server.go +++ b/admin/server.go @@ -8,6 +8,7 @@ import ( "io/fs" "log" "net/http" + "net/url" "os" "path/filepath" "sort" @@ -43,6 +44,8 @@ type accountsResponse struct { var templateFuncs = template.FuncMap{ "humanBytes": humanBytes, "formatTime": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 MST") }, + "urlPath": url.PathEscape, // safe embedding of mailbox/filename in a URL path segment + "lower": strings.ToLower, } var indexTmpl = template.Must(template.New("index").Funcs(templateFuncs).Parse(` @@ -64,7 +67,7 @@ a { color: #3b82f6; }

Mail accounts — mail.produktor.io

-

Source: config/postfix-accounts.cf (read-only). Updated {{.Updated | formatTime}} · JSON

+

Source: config/postfix-accounts.cf (read-only). Updated {{.Updated | formatTime}} · JSON · All messages

@@ -127,8 +130,16 @@ func (s *server) route(w http.ResponseWriter, r *http.Request) { switch { case path == "/" || path == "/index.html": s.handleIndex(w, r) + case path == "/messages": + s.handleMessagesPage(w, r) + case strings.HasPrefix(path, "/message/"): + s.handleMessagePage(w, r, strings.TrimPrefix(path, "/message/")) case path == "/api/accounts": s.handleAPI(w, r) + case path == "/api/messages": + s.handleMessagesAPI(w, r) + case strings.HasPrefix(path, "/api/messages/"): + s.handleMessageAPI(w, r, strings.TrimPrefix(path, "/api/messages/")) default: http.NotFound(w, r) } @@ -226,3 +237,271 @@ func (s *server) handleAPI(w http.ResponseWriter, r *http.Request) { enc.SetIndent("", " ") enc.Encode(resp) } + +// parseLimit turns a limit query value into an int within [1, max]; +// empty/invalid values fall back to def. +func parseLimit(v string, def, max int) int { + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + return def + } + if n > max { + return max + } + return n +} + +// splitPath2 splits the remainder of a two-segment route into exactly two +// non-empty parts; anything else (traversal, extra segments) fails. +func splitPath2(rest string) (a, b string, ok bool) { + parts := strings.Split(rest, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} + +func (s *server) handleMessagesAPI(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + msgs, err := listMessages(s.maildirRoot, q.Get("mailbox"), q.Get("q"), parseLimit(q.Get("limit"), 100, 500)) + if err != nil { + log.Printf("listMessages: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + resp := messagesResponse{Updated: time.Now().UTC(), Count: len(msgs), Messages: msgs} + w.Header().Set("Content-Type", "application/json; charset=utf-8") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + enc.Encode(resp) +} + +func (s *server) handleMessageAPI(w http.ResponseWriter, r *http.Request, rest string) { + mailbox, filename, ok := splitPath2(rest) + if !ok { + http.NotFound(w, r) + return + } + d, err := readMessage(s.maildirRoot, mailbox, filename) + switch { + case errors.Is(err, errBadMailbox): + http.Error(w, "invalid mailbox", http.StatusBadRequest) + case errors.Is(err, fs.ErrNotExist): + http.NotFound(w, r) + case err != nil: + log.Printf("readMessage(%s, %s): %v", mailbox, filename, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + default: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + enc.Encode(d) + } +} + +// messagesGroup renders one mailbox section of the unified inbox page. +type messagesGroup struct { + Mailbox string + Count int + Messages []messageSummary +} + +var messagesTmpl = template.Must(template.New("messages").Funcs(templateFuncs).Parse(` + + + + +All messages — mail.produktor.io + + + +

All messages — mail.produktor.io

+

Accounts · JSON · updated {{.Updated | formatTime}} · read-only view of the Maildir on disk (no IMAP)

+ +
AddressINBOXTotalStorageQuotaLast message
+ + +{{range .Groups}} + +{{range .Messages}} + + + + + + +{{end}} +{{end}} + +
DateFromSubjectSize
{{.Mailbox}} · {{.Count}}
{{if .Date.IsZero}}—{{else}}{{.Date | formatTime}}{{end}}{{.From}}{{if .Subject}}{{.Subject}}{{else}}(no subject){{end}}{{.Size | humanBytes}}
+ + +`)) + +func (s *server) handleMessagesPage(w http.ResponseWriter, r *http.Request) { + msgs, err := listMessages(s.maildirRoot, "", "", 500) + if err != nil { + log.Printf("listMessages: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + var groups []messagesGroup + for _, m := range msgs { + if len(groups) == 0 || groups[len(groups)-1].Mailbox != m.Mailbox { + groups = append(groups, messagesGroup{Mailbox: m.Mailbox}) + } + g := &groups[len(groups)-1] + g.Count++ + g.Messages = append(g.Messages, m) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := messagesTmpl.Execute(w, struct { + Updated time.Time + Base string + Groups []messagesGroup + }{time.Now().UTC(), s.basePath + "/", groups}); err != nil { + log.Printf("render messages: %v", err) + } +} + +// headerPair is one row of the header table on the message page. +type headerPair struct { + Name, Value string +} + +// preferredHeaderOrder lists the most useful headers first; the rest follow +// sorted alphabetically. +var preferredHeaderOrder = []string{"From", "To", "Subject", "Date", "Message-Id", "Mime-Version", "Content-Type"} + +func orderedHeaders(h headerMap) []headerPair { + seen := map[string]bool{} + var rows []headerPair + for _, name := range preferredHeaderOrder { + if v := h.Get(name); v != "" { + rows = append(rows, headerPair{name, v}) + seen[name] = true + } + } + var rest []string + for name := range h { + if !seen[name] { + rest = append(rest, name) + } + } + sort.Strings(rest) + for _, name := range rest { + rows = append(rows, headerPair{name, h.Get(name)}) + } + return rows +} + +var messageTmpl = template.Must(template.New("message").Funcs(templateFuncs).Parse(` + + + + +{{if .Msg.Subject}}{{.Msg.Subject}}{{else}}Message{{end}} — mail.produktor.io + + + +

← All messages

+

{{if .Msg.Subject}}{{.Msg.Subject}}{{else}}(no subject){{end}}

+

{{.Msg.From}} → {{.Msg.To}} · {{.Mailbox}} · {{.Size | humanBytes}}

+
+{{if .Msg.Text}}
{{.Msg.Text}}
{{else}}
(no text part)
{{end}} +{{if .Msg.HTML}}{{else}}{{end}} +

Attachments

+
    +{{range .Msg.Attachments}}
  • {{if .Filename}}{{.Filename}}{{else}}(unnamed){{end}} · {{.Size | humanBytes}}{{if .CID}} · cid: {{.CID}}{{end}}
  • {{else}}
  • none
  • {{end}} +
+

Headers

+ +{{range .Rows}}{{end}} +
{{.Name}}{{.Value}}
+ + +`)) + +func (s *server) handleMessagePage(w http.ResponseWriter, r *http.Request, rest string) { + mailbox, filename, ok := splitPath2(rest) + if !ok { + http.NotFound(w, r) + return + } + d, err := readMessage(s.maildirRoot, mailbox, filename) + switch { + case errors.Is(err, errBadMailbox): + http.Error(w, "invalid mailbox", http.StatusBadRequest) + case errors.Is(err, fs.ErrNotExist): + http.NotFound(w, r) + case err != nil: + log.Printf("readMessage(%s, %s): %v", mailbox, filename, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := messageTmpl.Execute(w, struct { + Base string + Mailbox string + Size int64 + Msg messageDetail + Rows []headerPair + }{s.basePath + "/", mailbox, d.Size, d, orderedHeaders(d.Headers)}); err != nil { + log.Printf("render message: %v", err) + } +} diff --git a/admin/testdata/mail/produktor.io/ano/cur/1.fixtureano b/admin/testdata/mail/produktor.io/ano/cur/1.fixtureano index 862df4a..7ca22ee 100644 --- a/admin/testdata/mail/produktor.io/ano/cur/1.fixtureano +++ b/admin/testdata/mail/produktor.io/ano/cur/1.fixtureano @@ -1,3 +1,4 @@ Subject: ano msg +Date: Tue, 01 Sep 2026 09:00:00 +0100 -y + y diff --git a/admin/testdata/mail/produktor.io/ano/cur/2.multipart b/admin/testdata/mail/produktor.io/ano/cur/2.multipart new file mode 100644 index 0000000..dc1fa2d --- /dev/null +++ b/admin/testdata/mail/produktor.io/ano/cur/2.multipart @@ -0,0 +1,33 @@ +Return-Path: +From: Sender Name +To: ano@produktor.io +Subject: =?UTF-8?B?TXVsdGlwYXJ0IGZpeHR1cmUgd2l0aCDDpHR0YWNobWVudA==?= +Date: Tue, 01 Sep 2026 10:00:00 +0100 +Message-ID: +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="----=_fixture_76" + +------=_fixture_76 +Content-Type: multipart/alternative; boundary="----=_fixture_alt" + +------=_fixture_alt +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +hello plain body + +------=_fixture_alt +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +

hello html body

+------=_fixture_alt-- + +------=_fixture_76 +Content-Type: image/png; name="logo.png" +Content-Disposition: attachment; filename="logo.png" +Content-ID: +Content-Transfer-Encoding: base64 + +iVBORw0KGgo= +------=_fixture_76-- diff --git a/admin/testdata/mail/produktor.io/ano/cur/3.searchme b/admin/testdata/mail/produktor.io/ano/cur/3.searchme new file mode 100644 index 0000000..3442c3f --- /dev/null +++ b/admin/testdata/mail/produktor.io/ano/cur/3.searchme @@ -0,0 +1,8 @@ +Return-Path: +From: Acme Billing +To: ano@produktor.io +Subject: Invoice #42 from Acme Corp +Date: Tue, 01 Sep 2026 11:00:00 +0100 +Message-ID: + +Please pay the attached invoice. diff --git a/admin/testdata/mail/produktor.io/info/cur/1.fixture1 b/admin/testdata/mail/produktor.io/info/cur/1.fixture1 index 778ca93..8576833 100644 --- a/admin/testdata/mail/produktor.io/info/cur/1.fixture1 +++ b/admin/testdata/mail/produktor.io/info/cur/1.fixture1 @@ -1,4 +1,5 @@ Return-Path: Subject: fixture one +Date: Mon, 31 Aug 2026 09:00:00 +0100 body diff --git a/admin/testdata/mail/produktor.io/info/cur/2.fixture2 b/admin/testdata/mail/produktor.io/info/cur/2.fixture2 index acf028e..30cf46f 100644 --- a/admin/testdata/mail/produktor.io/info/cur/2.fixture2 +++ b/admin/testdata/mail/produktor.io/info/cur/2.fixture2 @@ -1,4 +1,5 @@ Return-Path: Subject: fixture two +Date: Mon, 31 Aug 2026 09:05:00 +0100 body body diff --git a/admin/testdata/mail/produktor.io/info/new/3.fixture3 b/admin/testdata/mail/produktor.io/info/new/3.fixture3 index c6c308c..427c289 100644 --- a/admin/testdata/mail/produktor.io/info/new/3.fixture3 +++ b/admin/testdata/mail/produktor.io/info/new/3.fixture3 @@ -1,4 +1,5 @@ Return-Path: Subject: fixture three +Date: Mon, 31 Aug 2026 09:10:00 +0100 body diff --git a/admin/testdata/mail/produktor.io/postmaster/new/1.fixturepm b/admin/testdata/mail/produktor.io/postmaster/new/1.fixturepm index 9ad62a1..5432ec2 100644 --- a/admin/testdata/mail/produktor.io/postmaster/new/1.fixturepm +++ b/admin/testdata/mail/produktor.io/postmaster/new/1.fixturepm @@ -1,3 +1,4 @@ Subject: pm msg +Date: Mon, 31 Aug 2026 10:00:00 +0100 z