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 }