Files
mailgoserver/internal/mailview/mailview.go
T

176 lines
5.4 KiB
Go

// 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
}