Files
mail-server/admin/messages.go

394 lines
11 KiB
Go

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
}