176 lines
5.4 KiB
Go
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
|
||
|
|
}
|