Skip to content

Commit

Permalink
SMTP: ReceivedContent refactoring step 1
Browse files Browse the repository at this point in the history
  • Loading branch information
Lucas Hinderberger committed Jul 1, 2024
1 parent 0ab6e00 commit 661b3f3
Show file tree
Hide file tree
Showing 4 changed files with 302 additions and 199 deletions.
38 changes: 25 additions & 13 deletions internal/smtp/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,14 @@ func (h *smtpHTTPHandler) handleMessageMeta(w http.ResponseWriter, r *http.Reque
return
}

content := msg.Content()

out := buildMessageBasicMeta(msg)

out["body_size"] = len(msg.Body())
out["body_size"] = len(content.Body())

headers := make(map[string]any)
for k, v := range msg.Headers() {
for k, v := range content.Headers() {
headers[k] = v
}
out["headers"] = headers
Expand All @@ -167,12 +169,14 @@ func (h *smtpHTTPHandler) handleMessageBody(w http.ResponseWriter, r *http.Reque
return
}

contentType, ok := msg.Headers()["Content-Type"]
content := msg.Content()

contentType, ok := content.Headers()["Content-Type"]
if ok {
w.Header()["Content-Type"] = contentType
}

w.Write(msg.Body())
w.Write(content.Body())
}

func (h *smtpHTTPHandler) handleMultipartIndex(w http.ResponseWriter, r *http.Request, idx int) {
Expand Down Expand Up @@ -234,20 +238,24 @@ func (h *smtpHTTPHandler) handleMultipartBody(
if msg == nil {
return
}

if !ensureIsMultipart(w, msg) {
return
}

part := retrievePart(w, msg, partIdx)
if part == nil {
return
}

contentType, ok := part.Headers()["Content-Type"]
content := part.Content()

contentType, ok := content.Headers()["Content-Type"]
if ok {
w.Header()["Content-Type"] = contentType
}

w.Write(part.Body())
w.Write(content.Body())
}

func (h *smtpHTTPHandler) handleRawMessageData(w http.ResponseWriter, r *http.Request, idx int) {
Expand Down Expand Up @@ -291,23 +299,25 @@ func retrievePart(w http.ResponseWriter, msg *ReceivedMessage, partIdx int) *Rec
}

func buildMessageBasicMeta(msg *ReceivedMessage) map[string]any {
content := msg.Content()

out := map[string]any{
"idx": msg.Index(),
"isMultipart": msg.IsMultipart(),
"isMultipart": content.IsMultipart(),
"receivedAt": msg.ReceivedAt(),
}

from, ok := msg.Headers()["From"]
from, ok := content.Headers()["From"]
if ok {
out["from"] = from
}

to, ok := msg.Headers()["To"]
to, ok := content.Headers()["To"]
if ok {
out["to"] = to
}

subject, ok := msg.Headers()["Subject"]
subject, ok := content.Headers()["Subject"]
if ok && len(subject) == 1 {
out["subject"] = subject[0]
}
Expand All @@ -316,13 +326,15 @@ func buildMessageBasicMeta(msg *ReceivedMessage) map[string]any {
}

func buildMultipartMeta(part *ReceivedPart) map[string]any {
content := part.Content()

out := map[string]any{
"idx": part.Index(),
"body_size": len(part.Body()),
"body_size": len(content.Body()),
}

headers := make(map[string]any)
for k, v := range part.Headers() {
for k, v := range content.Headers() {
headers[k] = v
}
out["headers"] = headers
Expand All @@ -334,7 +346,7 @@ func buildMultipartMeta(part *ReceivedPart) map[string]any {
// message, returns true and does nothing further if so, returns false after
// replying with Status 404 if not.
func ensureIsMultipart(w http.ResponseWriter, msg *ReceivedMessage) bool {
if msg.IsMultipart() {
if msg.Content().IsMultipart() {
return true
}

Expand Down
157 changes: 94 additions & 63 deletions internal/smtp/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"net/textproto"
"regexp"
"strings"
"time"
Expand All @@ -27,23 +26,29 @@ type ReceivedMessage struct {
rawMessageData []byte
receivedAt time.Time

headers mail.Header
body []byte

contentType string
contentTypeParams map[string]string
content *ReceivedContent

isMultipart bool
multiparts []*ReceivedPart
multiparts []*ReceivedPart
}

// ReceivedPart contains a single part of a multipart message as received
// via SMTP.
type ReceivedPart struct {
index int

headers textproto.MIMEHeader
content *ReceivedContent
}

// ReceivedContent contains the contents of an email message or multipart part.
type ReceivedContent struct {
headers map[string][]string
body []byte

contentType string
contentTypeParams map[string]string
isMultipart bool

// TODO: Move multiparts here from ReceivedMessage in the next step
}

// NewReceivedMessage parses a raw message as received via SMTP into a
Expand All @@ -61,16 +66,14 @@ func NewReceivedMessage(
maxMessageSize = DefaultMaxMessageSize
}

parsedMsg, err := mail.ReadMessage(bytes.NewReader(rawMessageData))
parsedMsg, err := mail.ReadMessage(io.LimitReader(bytes.NewReader(rawMessageData), maxMessageSize))
if err != nil {
return nil, fmt.Errorf("could not parse message: %w", err)
}

preprocessHeaders(parsedMsg.Header)

body, err := io.ReadAll(wrapBodyReader(parsedMsg.Body, parsedMsg.Header, maxMessageSize))
content, err := NewReceivedContent(parsedMsg.Header, parsedMsg.Body, maxMessageSize)
if err != nil {
return nil, fmt.Errorf("could not read message body: %w", err)
return nil, fmt.Errorf("could not parse content: %w", err)
}

msg := &ReceivedMessage{
Expand All @@ -79,30 +82,16 @@ func NewReceivedMessage(
smtpRcptTo: rcptTo,
rawMessageData: rawMessageData,
receivedAt: receivedAt,
headers: parsedMsg.Header,
body: body,
}

rawContentType := msg.headers.Get("Content-Type")
if rawContentType != "" {
msg.contentType, msg.contentTypeParams, err = mime.ParseMediaType(rawContentType)
if err != nil {
return nil, fmt.Errorf("could not parse Content-Type: %w", err)
}

// case-sensitive comparison of the content type is permitted here,
// since mime.ParseMediaType is documented to return the media type
// in lower case.
msg.isMultipart = strings.HasPrefix(msg.contentType, "multipart/")
content: content,
}

if msg.isMultipart {
boundary, ok := msg.contentTypeParams["boundary"]
if content.IsMultipart() {
boundary, ok := content.ContentTypeParams()["boundary"]
if !ok {
return nil, fmt.Errorf("encountered multipart message without defined boundary")
}

r := multipart.NewReader(bytes.NewReader(msg.body), boundary)
r := multipart.NewReader(bytes.NewReader(content.body), boundary)

for i := 0; ; i++ {
rawPart, err := r.NextRawPart()
Expand Down Expand Up @@ -136,15 +125,17 @@ func NewReceivedMessage(
// If no matching multiparts are found, this may return nil or an empty
// list.
func (m *ReceivedMessage) SearchPartsByHeader(re *regexp.Regexp) []*ReceivedPart {
if !m.IsMultipart() {
// TODO: Somehow unify with Server.SearchByHeader based on ReceivedContent

if !m.content.IsMultipart() {
return nil
}

multiparts := m.Multiparts()

headerIdxList := make([]map[string][]string, len(multiparts))
for i, v := range multiparts {
headerIdxList[i] = v.Headers()
headerIdxList[i] = v.Content().Headers()
}

foundIndices := searchByHeaderCommon(headerIdxList, re)
Expand All @@ -159,45 +150,81 @@ func (m *ReceivedMessage) SearchPartsByHeader(re *regexp.Regexp) []*ReceivedPart

// NewReceivedPart parses a MIME multipart part into a ReceivedPart struct.
//
// maxMessageSize is passed through to NewReceivedContent (see its documentation for details).
func NewReceivedPart(index int, p *multipart.Part, maxMessageSize int64) (*ReceivedPart, error) {
content, err := NewReceivedContent(p.Header, p, maxMessageSize)
if err != nil {
return nil, fmt.Errorf("could not parse content: %w", err)
}

part := &ReceivedPart{
index: index,
content: content,
}

return part, nil
}

// NewReceivedContent parses a message or part headers and body into a ReceivedContent struct.
//
// Incoming data is truncated after the given maximum message size.
// If a maxMessageSize of 0 is given, this function will default to using
// DefaultMaxMessageSize.
func NewReceivedPart(index int, p *multipart.Part, maxMessageSize int64) (*ReceivedPart, error) {
func NewReceivedContent(
headers map[string][]string, bodyReader io.Reader, maxMessageSize int64,
) (*ReceivedContent, error) {
if maxMessageSize == 0 {
maxMessageSize = DefaultMaxMessageSize
}

preprocessHeaders(p.Header)
headers = preprocessHeaders(headers)

body, err := io.ReadAll(wrapBodyReader(p, p.Header, maxMessageSize))
body, err := io.ReadAll(wrapBodyReader(bodyReader, headers, maxMessageSize))
if err != nil {
return nil, fmt.Errorf("could not read message part body: %w", err)
return nil, fmt.Errorf("could not read body: %w", err)
}

part := &ReceivedPart{
index: index,
headers: p.Header,
content := &ReceivedContent{
headers: headers,
body: body,
}

return part, nil
rawContentType, ok := headers["Content-Type"]
if ok && rawContentType[0] != "" && len(rawContentType) > 0 {
content.contentType, content.contentTypeParams, err = mime.ParseMediaType(rawContentType[0])
if err != nil {
return nil, fmt.Errorf("could not parse Content-Type: %w", err)
}

// case-sensitive comparison of the content type is permitted here,
// since mime.ParseMediaType is documented to return the media type
// in lower case.
content.isMultipart = strings.HasPrefix(content.contentType, "multipart/")
}

return content, nil
}

// preprocessHeaders modifies the given headers in-place by decoding
// header values that were encoded according to RFC2047.
func preprocessHeaders(headers map[string][]string) {
// preprocessHeaders decodes header values that were encoded according to RFC2047.
func preprocessHeaders(headers map[string][]string) map[string][]string {
var decoder mime.WordDecoder

for _, vs := range headers {
out := make(map[string][]string)

for k, vs := range headers {
out[k] = make([]string, len(vs))

for i := range vs {
dec, err := decoder.DecodeHeader(vs[i])
if err != nil {
logrus.Warn("could not decode Q-Encoding in header:", err)
} else {
vs[i] = dec
out[k][i] = dec
}
}
}

return out
}

// wrapBodyReader wraps the reader for a message / part body with size
Expand Down Expand Up @@ -229,24 +256,32 @@ func wrapBodyReader(r io.Reader, headers map[string][]string, maxMessageSize int
// Getters
// =======

func (m *ReceivedMessage) ContentType() string {
return m.contentType
func (c *ReceivedContent) ContentType() string {
return c.contentType
}

func (m *ReceivedMessage) Body() []byte {
return m.body
func (c *ReceivedContent) ContentTypeParams() map[string]string {
return c.contentTypeParams
}

func (m *ReceivedMessage) Headers() mail.Header {
return m.headers
func (c *ReceivedContent) Body() []byte {
return c.body
}

func (m *ReceivedMessage) Index() int {
return m.index
func (c *ReceivedContent) Headers() map[string][]string {
return c.headers
}

func (c *ReceivedContent) IsMultipart() bool {
return c.isMultipart
}

func (m *ReceivedMessage) IsMultipart() bool {
return m.isMultipart
func (m *ReceivedMessage) Content() *ReceivedContent {
return m.content
}

func (m *ReceivedMessage) Index() int {
return m.index
}

func (m *ReceivedMessage) Multiparts() []*ReceivedPart {
Expand All @@ -269,12 +304,8 @@ func (m *ReceivedMessage) SmtpRcptTo() []string {
return m.smtpRcptTo
}

func (p *ReceivedPart) Body() []byte {
return p.body
}

func (p *ReceivedPart) Headers() textproto.MIMEHeader {
return p.headers
func (p *ReceivedPart) Content() *ReceivedContent {
return p.content
}

func (p *ReceivedPart) Index() int {
Expand Down
Loading

0 comments on commit 661b3f3

Please sign in to comment.