feat(mail-admin): unified inbox view of all mailboxes (#76)
This commit is contained in:
@@ -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/<domain>/<localpart>), 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 <domain>/<localpart> 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/<mailbox>/<filename>:
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user