MFA fix, added IP blacklist, update webmail client
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package mailview
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSimpleTextMessage(t *testing.T) {
|
||||
raw := "From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n\r\nhello there"
|
||||
m, err := Parse([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Header.From != "a@example.com" || m.Header.Subject != "hi" {
|
||||
t.Errorf("headers = %+v", m.Header)
|
||||
}
|
||||
if m.TextBody != "hello there" {
|
||||
t.Errorf("TextBody = %q", m.TextBody)
|
||||
}
|
||||
if m.HTMLBody != "" || len(m.Attachments) != 0 {
|
||||
t.Errorf("expected no HTML body or attachments, got HTMLBody=%q attachments=%d", m.HTMLBody, len(m.Attachments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMultipartAlternativeKeepsBothBodies(t *testing.T) {
|
||||
raw := "" +
|
||||
"From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n" +
|
||||
"Content-Type: multipart/alternative; boundary=\"B\"\r\n\r\n" +
|
||||
"--B\r\nContent-Type: text/plain\r\n\r\nplain version\r\n" +
|
||||
"--B\r\nContent-Type: text/html\r\n\r\n<p>html version</p>\r\n" +
|
||||
"--B--\r\n"
|
||||
m, err := Parse([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(m.TextBody) != "plain version" {
|
||||
t.Errorf("TextBody = %q", m.TextBody)
|
||||
}
|
||||
if strings.TrimSpace(m.HTMLBody) != "<p>html version</p>" {
|
||||
t.Errorf("HTMLBody = %q", m.HTMLBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAttachmentDecodesBase64(t *testing.T) {
|
||||
raw := "" +
|
||||
"From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n" +
|
||||
"Content-Type: multipart/mixed; boundary=\"B\"\r\n\r\n" +
|
||||
"--B\r\nContent-Type: text/plain\r\n\r\nsee attached\r\n" +
|
||||
"--B\r\nContent-Type: application/octet-stream\r\nContent-Disposition: attachment; filename=\"a.txt\"\r\n" +
|
||||
"Content-Transfer-Encoding: BASE64\r\n\r\nSGVsbG8sIHdvcmxkIQ==\r\n" +
|
||||
"--B--\r\n"
|
||||
m, err := Parse([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Attachments) != 1 {
|
||||
t.Fatalf("got %d attachments, want 1", len(m.Attachments))
|
||||
}
|
||||
if got := string(m.Attachments[0].Data); got != "Hello, world!" {
|
||||
t.Errorf("attachment data = %q, want decoded base64", got)
|
||||
}
|
||||
if m.Attachments[0].Filename != "a.txt" {
|
||||
t.Errorf("filename = %q", m.Attachments[0].Filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNestedMultipartMixedWithAlternativeBody(t *testing.T) {
|
||||
raw := "" +
|
||||
"From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n" +
|
||||
"Content-Type: multipart/mixed; boundary=\"OUTER\"\r\n\r\n" +
|
||||
"--OUTER\r\nContent-Type: multipart/alternative; boundary=\"INNER\"\r\n\r\n" +
|
||||
"--INNER\r\nContent-Type: text/plain\r\n\r\nplain body\r\n" +
|
||||
"--INNER\r\nContent-Type: text/html\r\n\r\n<p>html body</p>\r\n" +
|
||||
"--INNER--\r\n" +
|
||||
"--OUTER\r\nContent-Type: text/plain\r\nContent-Disposition: attachment; filename=\"notes.txt\"\r\n\r\n" +
|
||||
"attached notes\r\n" +
|
||||
"--OUTER--\r\n"
|
||||
m, err := Parse([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(m.TextBody) != "plain body" {
|
||||
t.Errorf("TextBody = %q", m.TextBody)
|
||||
}
|
||||
if strings.TrimSpace(m.HTMLBody) != "<p>html body</p>" {
|
||||
t.Errorf("HTMLBody = %q", m.HTMLBody)
|
||||
}
|
||||
if len(m.Attachments) != 1 || m.Attachments[0].Filename != "notes.txt" {
|
||||
t.Fatalf("attachments = %+v", m.Attachments)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user