// Package mailview parses a raw RFC822 message into a structure a web UI can render: // separate plain-text and HTML bodies, plus a flat list of attachments. It exists // because internal/smtpserver's own MIME walker (parseMessage in attachments.go) is // unexported, SMTP-inbound-specific, and only concatenates every text/* part into one // blob — a webmail reader needs to keep text/plain and text/html distinct (so it can // prefer HTML but still offer a plain-text view) and needs real attachment metadata // for download links, not just a body string. package mailview import ( "bytes" "encoding/base64" "io" "mime" "mime/multipart" "net/mail" "path/filepath" "strings" ) // Header is the small set of top-level headers a message view needs — never the full // header block (this isn't a general-purpose header inspector). type Header struct { From, To, Cc, Subject, Date, MessageID string } // Attachment is one file extracted from the message, decoded to its real bytes (never // left as raw base64/quoted-printable text). type Attachment struct { Filename string ContentType string Data []byte } // Message is the parsed result. TextBody/HTMLBody are independently populated when // present (e.g. a multipart/alternative body yields both) — never merged — so a // caller can prefer HTML but still fall back to plain text. type Message struct { Header Header TextBody string HTMLBody string Attachments []Attachment } // Parse walks raw's MIME structure (recursing into nested multiparts, e.g. a // multipart/alternative inside a multipart/mixed) and classifies every leaf part as // the text body, the HTML body, or an attachment. func Parse(raw []byte) (*Message, error) { msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil { return nil, err } m := &Message{Header: Header{ From: msg.Header.Get("From"), To: msg.Header.Get("To"), Cc: msg.Header.Get("Cc"), Subject: msg.Header.Get("Subject"), Date: msg.Header.Get("Date"), MessageID: msg.Header.Get("Message-Id"), }} mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) if err != nil { mediaType = "text/plain" } if strings.HasPrefix(mediaType, "multipart/") { if err := walkMultipart(m, msg.Body, params["boundary"]); err != nil { return nil, err } return m, nil } data, _ := io.ReadAll(msg.Body) data = decodeContentTransferEncoding(msg.Header.Get("Content-Transfer-Encoding"), data) if mediaType == "text/html" { m.HTMLBody = string(data) } else { m.TextBody = string(data) } return m, nil } func walkMultipart(m *Message, r io.Reader, boundary string) error { if boundary == "" { return nil } mr := multipart.NewReader(r, boundary) for { part, err := mr.NextPart() if err == io.EOF { return nil } if err != nil { // Tolerate a malformed trailing part rather than losing everything // already parsed — a webmail reader should show what it can. return nil } mediaType, params, err := mime.ParseMediaType(part.Header.Get("Content-Type")) if err != nil { mediaType = "text/plain" } disp, dispParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition")) data, _ := io.ReadAll(part) data = decodeContentTransferEncoding(part.Header.Get("Content-Transfer-Encoding"), data) if strings.HasPrefix(mediaType, "multipart/") { walkMultipart(m, bytes.NewReader(data), params["boundary"]) continue } filename := dispParams["filename"] if filename == "" { filename = params["name"] } switch { case disp == "attachment" || (filename != "" && disp != "inline"): m.Attachments = append(m.Attachments, Attachment{ Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data, }) case mediaType == "text/html": m.HTMLBody += string(data) case strings.HasPrefix(mediaType, "text/"): if m.TextBody != "" { m.TextBody += "\n" } m.TextBody += string(data) case filename != "": // Inline non-text part (e.g. an embedded image) with no explicit // disposition — still worth surfacing as a downloadable attachment // rather than silently dropping it. m.Attachments = append(m.Attachments, Attachment{ Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data, }) } } } // contentTypeFor mirrors smtpserver's getContentType: prefer the part's own // declared type, fall back to extension sniffing for the generic default. func contentTypeFor(mediaType, filename string) string { if mediaType != "" && mediaType != "application/octet-stream" { return mediaType } if guessed := mime.TypeByExtension(filepath.Ext(filename)); guessed != "" { return guessed } return "application/octet-stream" } // decodeContentTransferEncoding mirrors smtpserver's identically-named helper: // mime/multipart.Reader only auto-decodes quoted-printable transparently, never // base64, so that case needs manual decoding or attachments/HTML bodies come out as // raw base64 text instead of their real bytes. func decodeContentTransferEncoding(cte string, data []byte) []byte { if !strings.EqualFold(strings.TrimSpace(cte), "base64") { return data } cleaned := make([]byte, 0, len(data)) for _, b := range data { switch b { case ' ', '\t', '\r', '\n': continue default: cleaned = append(cleaned, b) } } decoded, err := base64.StdEncoding.DecodeString(string(cleaned)) if err != nil { return data } return decoded }