MFA fix, added IP blacklist, update webmail client

This commit is contained in:
2026-08-14 13:04:55 +01:00
parent 6063f95504
commit 892f366a16
122 changed files with 13362 additions and 251 deletions
+175
View File
@@ -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
}
+91
View File
@@ -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)
}
}