diff --git a/internal/smtp/http.go b/internal/smtp/http.go index 0adaa8d..68f0adc 100644 --- a/internal/smtp/http.go +++ b/internal/smtp/http.go @@ -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 @@ -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) { @@ -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) { @@ -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] } @@ -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 @@ -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 } diff --git a/internal/smtp/message.go b/internal/smtp/message.go index 108bebd..4a6d4a9 100644 --- a/internal/smtp/message.go +++ b/internal/smtp/message.go @@ -10,7 +10,6 @@ import ( "mime/multipart" "mime/quotedprintable" "net/mail" - "net/textproto" "regexp" "strings" "time" @@ -27,14 +26,9 @@ 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 @@ -42,8 +36,19 @@ type ReceivedMessage struct { 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 @@ -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{ @@ -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() @@ -136,7 +125,9 @@ 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 } @@ -144,7 +135,7 @@ func (m *ReceivedMessage) SearchPartsByHeader(re *regexp.Regexp) []*ReceivedPart 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) @@ -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 @@ -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 { @@ -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 { diff --git a/internal/smtp/server.go b/internal/smtp/server.go index 4f13e83..eeb3791 100644 --- a/internal/smtp/server.go +++ b/internal/smtp/server.go @@ -119,6 +119,8 @@ func (s *Server) ReceivedMessages() []*ReceivedMessage { // being encoded as if for transport (e.g. quoted-printable), // but concatenated as-is. func (s *Server) SearchByHeader(re *regexp.Regexp) []*ReceivedMessage { + // TODO: Somehow unify with ReceivedMessage.SearchPartsByHeader based on ReceivedContent + s.mutex.RLock() defer s.mutex.RUnlock() @@ -126,7 +128,7 @@ func (s *Server) SearchByHeader(re *regexp.Regexp) []*ReceivedMessage { headerIdxList := make([]map[string][]string, len(receivedMessages)) for i, v := range receivedMessages { - headerIdxList[i] = v.Headers() + headerIdxList[i] = v.Content().Headers() } foundIndices := searchByHeaderCommon(headerIdxList, re) diff --git a/internal/smtp/smtp_test.go b/internal/smtp/smtp_test.go index 0cb0620..8f20938 100644 --- a/internal/smtp/smtp_test.go +++ b/internal/smtp/smtp_test.go @@ -186,12 +186,8 @@ func assertMessageEqual(t *testing.T, expected, actual *ReceivedMessage) { assert.ElementsMatch(t, expected.smtpRcptTo, actual.smtpRcptTo) assert.Equal(t, expected.rawMessageData, actual.rawMessageData) assert.Equal(t, expected.receivedAt, actual.receivedAt) - assert.Equal(t, expected.body, actual.body) - assert.Equal(t, expected.contentType, actual.contentType) - assert.Equal(t, expected.contentTypeParams, actual.contentTypeParams) - assert.Equal(t, expected.isMultipart, actual.isMultipart) - assertHeadersEqual(t, expected.headers, actual.headers) + assertContentEqual(t, expected.content, actual.content) if assert.Equal(t, len(expected.multiparts), len(actual.multiparts)) { for i, m := range expected.multiparts { @@ -202,8 +198,16 @@ func assertMessageEqual(t *testing.T, expected, actual *ReceivedMessage) { func assertMultipartEqual(t *testing.T, expected, actual *ReceivedPart) { assert.Equal(t, expected.index, actual.index) - assertHeadersEqual(t, expected.headers, actual.headers) + assertContentEqual(t, expected.content, actual.content) +} + +func assertContentEqual(t *testing.T, expected, actual *ReceivedContent) { assert.Equal(t, expected.body, actual.body) + assert.Equal(t, expected.contentType, actual.contentType) + assert.Equal(t, expected.contentTypeParams, actual.contentTypeParams) + assert.Equal(t, expected.isMultipart, actual.isMultipart) + + assertHeadersEqual(t, expected.headers, actual.headers) } // runTestSession starts a Server, runs a pre-recorded SMTP session, @@ -250,12 +254,14 @@ To: testreceiver@programmfabrik.de Hello World! A simple plain text test mail.`), receivedAt: testTime, - headers: map[string][]string{ - "From": {"testsender@programmfabrik.de"}, - "To": {"testreceiver@programmfabrik.de"}, - }, - body: []byte(`Hello World! + content: &ReceivedContent{ + headers: map[string][]string{ + "From": {"testsender@programmfabrik.de"}, + "To": {"testreceiver@programmfabrik.de"}, + }, + body: []byte(`Hello World! A simple plain text test mail.`), + }, }, { index: 1, @@ -282,15 +288,16 @@ Some text in HTML format. Trailing text is ignored.`), receivedAt: testTime, - headers: map[string][]string{ - "Mime-Version": {"1.0"}, - "From": {"testsender2@programmfabrik.de"}, - "To": {"testreceiver2@programmfabrik.de"}, - "Date": {"Tue, 25 Jun 2024 11:15:57 +0200"}, - "Subject": {"Example Message"}, - "Content-Type": {`multipart/mixed; boundary="d36c3118be4745f9a1cb4556d11fe92d"`}, - }, - body: []byte(`Preamble is ignored. + content: &ReceivedContent{ + headers: map[string][]string{ + "Mime-Version": {"1.0"}, + "From": {"testsender2@programmfabrik.de"}, + "To": {"testreceiver2@programmfabrik.de"}, + "Date": {"Tue, 25 Jun 2024 11:15:57 +0200"}, + "Subject": {"Example Message"}, + "Content-Type": {`multipart/mixed; boundary="d36c3118be4745f9a1cb4556d11fe92d"`}, + }, + body: []byte(`Preamble is ignored. --d36c3118be4745f9a1cb4556d11fe92d Content-type: text/plain; charset=utf-8 @@ -303,25 +310,38 @@ Some text in HTML format. --d36c3118be4745f9a1cb4556d11fe92d-- Trailing text is ignored.`), - contentType: "multipart/mixed", - contentTypeParams: map[string]string{ - "boundary": "d36c3118be4745f9a1cb4556d11fe92d", + contentType: "multipart/mixed", + contentTypeParams: map[string]string{ + "boundary": "d36c3118be4745f9a1cb4556d11fe92d", + }, + isMultipart: true, }, - isMultipart: true, multiparts: []*ReceivedPart{ { index: 0, - headers: map[string][]string{ - "Content-Type": {"text/plain; charset=utf-8"}, + content: &ReceivedContent{ + headers: map[string][]string{ + "Content-Type": {"text/plain; charset=utf-8"}, + }, + body: []byte(`Some plain text`), + contentType: "text/plain", + contentTypeParams: map[string]string{ + "charset": "utf-8", + }, }, - body: []byte(`Some plain text`), }, { index: 1, - headers: map[string][]string{ - "Content-Type": {"text/html; charset=utf-8"}, + content: &ReceivedContent{ + headers: map[string][]string{ + "Content-Type": {"text/html; charset=utf-8"}, + }, + body: []byte(`Some text in HTML format.`), + contentType: "text/html", + contentTypeParams: map[string]string{ + "charset": "utf-8", + }, }, - body: []byte(`Some text in HTML format.`), }, }, }, @@ -335,15 +355,17 @@ Content-Type: text/plain; charset=utf-8 Noch eine Testmail. Diesmal mit nicht-ASCII-Zeichen: äöüß`), receivedAt: testTime, - headers: map[string][]string{ - "From": {"testsender3@programmfabrik.de"}, - "To": {"testreceiver3@programmfabrik.de"}, - "Content-Type": {"text/plain; charset=utf-8"}, - }, - body: []byte(`Noch eine Testmail. Diesmal mit nicht-ASCII-Zeichen: äöüß`), - contentType: "text/plain", - contentTypeParams: map[string]string{ - "charset": "utf-8", + content: &ReceivedContent{ + headers: map[string][]string{ + "From": {"testsender3@programmfabrik.de"}, + "To": {"testreceiver3@programmfabrik.de"}, + "Content-Type": {"text/plain; charset=utf-8"}, + }, + body: []byte(`Noch eine Testmail. Diesmal mit nicht-ASCII-Zeichen: äöüß`), + contentType: "text/plain", + contentTypeParams: map[string]string{ + "charset": "utf-8", + }, }, }, { @@ -358,17 +380,19 @@ Content-Transfer-Encoding: base64 RWluZSBiYXNlNjQtZW5rb2RpZXJ0ZSBUZXN0bWFpbCBtaXQgbmljaHQtQVNDSUktWmVpY2hlbjog w6TDtsO8w58K`), receivedAt: testTime, - headers: map[string][]string{ - "From": {"testsender4@programmfabrik.de"}, - "To": {"testreceiver4@programmfabrik.de"}, - "Content-Type": {"text/plain; charset=utf-8"}, - "Content-Transfer-Encoding": {"base64"}, - }, - body: []byte(`Eine base64-enkodierte Testmail mit nicht-ASCII-Zeichen: äöüß + content: &ReceivedContent{ + headers: map[string][]string{ + "From": {"testsender4@programmfabrik.de"}, + "To": {"testreceiver4@programmfabrik.de"}, + "Content-Type": {"text/plain; charset=utf-8"}, + "Content-Transfer-Encoding": {"base64"}, + }, + body: []byte(`Eine base64-enkodierte Testmail mit nicht-ASCII-Zeichen: äöüß `), - contentType: "text/plain", - contentTypeParams: map[string]string{ - "charset": "utf-8", + contentType: "text/plain", + contentTypeParams: map[string]string{ + "charset": "utf-8", + }, }, }, { @@ -383,16 +407,18 @@ Content-Transfer-Encoding: quoted-printable Noch eine Testmail mit =C3=A4=C3=B6=C3=BC=C3=9F, diesmal enkodiert in quote= d-printable.`), receivedAt: testTime, - headers: map[string][]string{ - "From": {"testsender5@programmfabrik.de"}, - "To": {"testreceiver5@programmfabrik.de"}, - "Content-Type": {"text/plain; charset=utf-8"}, - "Content-Transfer-Encoding": {"quoted-printable"}, - }, - body: []byte(`Noch eine Testmail mit äöüß, diesmal enkodiert in quoted-printable.`), - contentType: "text/plain", - contentTypeParams: map[string]string{ - "charset": "utf-8", + content: &ReceivedContent{ + headers: map[string][]string{ + "From": {"testsender5@programmfabrik.de"}, + "To": {"testreceiver5@programmfabrik.de"}, + "Content-Type": {"text/plain; charset=utf-8"}, + "Content-Transfer-Encoding": {"quoted-printable"}, + }, + body: []byte(`Noch eine Testmail mit äöüß, diesmal enkodiert in quoted-printable.`), + contentType: "text/plain", + contentTypeParams: map[string]string{ + "charset": "utf-8", + }, }, }, { @@ -420,15 +446,16 @@ Noch eine Testmail mit =C3=A4=C3=B6=C3=BC=C3=9F, diesmal enkodiert in quote= d-printable. --d36c3118be4745f9a1cb4556d11fe92d--`), receivedAt: testTime, - headers: map[string][]string{ - "Mime-Version": {"1.0"}, - "From": {"testsender6@programmfabrik.de"}, - "To": {"testreceiver6@programmfabrik.de"}, - "Date": {"Tue, 25 Jun 2024 11:15:57 +0200"}, - "Subject": {"Example Message"}, - "Content-Type": {`multipart/mixed; boundary="d36c3118be4745f9a1cb4556d11fe92d"`}, - }, - body: []byte(`--d36c3118be4745f9a1cb4556d11fe92d + content: &ReceivedContent{ + headers: map[string][]string{ + "Mime-Version": {"1.0"}, + "From": {"testsender6@programmfabrik.de"}, + "To": {"testreceiver6@programmfabrik.de"}, + "Date": {"Tue, 25 Jun 2024 11:15:57 +0200"}, + "Subject": {"Example Message"}, + "Content-Type": {`multipart/mixed; boundary="d36c3118be4745f9a1cb4556d11fe92d"`}, + }, + body: []byte(`--d36c3118be4745f9a1cb4556d11fe92d Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: base64 @@ -441,28 +468,41 @@ Content-Transfer-Encoding: quoted-printable Noch eine Testmail mit =C3=A4=C3=B6=C3=BC=C3=9F, diesmal enkodiert in quote= d-printable. --d36c3118be4745f9a1cb4556d11fe92d--`), - contentType: "multipart/mixed", - contentTypeParams: map[string]string{ - "boundary": "d36c3118be4745f9a1cb4556d11fe92d", + contentType: "multipart/mixed", + contentTypeParams: map[string]string{ + "boundary": "d36c3118be4745f9a1cb4556d11fe92d", + }, + isMultipart: true, }, - isMultipart: true, multiparts: []*ReceivedPart{ { index: 0, - headers: map[string][]string{ - "Content-Type": {"text/plain; charset=utf-8"}, - "Content-Transfer-Encoding": {"base64"}, - }, - body: []byte(`Eine base64-enkodierte Testmail mit nicht-ASCII-Zeichen: äöüß + content: &ReceivedContent{ + headers: map[string][]string{ + "Content-Type": {"text/plain; charset=utf-8"}, + "Content-Transfer-Encoding": {"base64"}, + }, + body: []byte(`Eine base64-enkodierte Testmail mit nicht-ASCII-Zeichen: äöüß `), + contentType: "text/plain", + contentTypeParams: map[string]string{ + "charset": "utf-8", + }, + }, }, { index: 1, - headers: map[string][]string{ - "Content-Type": {"text/plain; charset=utf-8"}, - "Content-Transfer-Encoding": {"quoted-printable"}, + content: &ReceivedContent{ + headers: map[string][]string{ + "Content-Type": {"text/plain; charset=utf-8"}, + "Content-Transfer-Encoding": {"quoted-printable"}, + }, + body: []byte(`Noch eine Testmail mit äöüß, diesmal enkodiert in quoted-printable.`), + contentType: "text/plain", + contentTypeParams: map[string]string{ + "charset": "utf-8", + }, }, - body: []byte(`Noch eine Testmail mit äöüß, diesmal enkodiert in quoted-printable.`), }, }, }, @@ -477,13 +517,15 @@ Subject: Tästmail mit Ümlauten im Header Hello World! A simple plain text test mail.`), receivedAt: testTime, - headers: map[string][]string{ - "From": {"tästsender7@programmfabrik.de"}, - "To": {"testreceiver7@programmfabrik.de"}, - "Subject": {"Tästmail mit Ümlauten im Header"}, - }, - body: []byte(`Hello World! + content: &ReceivedContent{ + headers: map[string][]string{ + "From": {"tästsender7@programmfabrik.de"}, + "To": {"testreceiver7@programmfabrik.de"}, + "Subject": {"Tästmail mit Ümlauten im Header"}, + }, + body: []byte(`Hello World! A simple plain text test mail.`), + }, }, { index: 7, @@ -496,13 +538,15 @@ Subject: =?utf-8?q?T=C3=A4stmail_mit_=C3=9Cmlauten_im_Header?= Hello World! A simple plain text test mail.`), receivedAt: testTime, - headers: map[string][]string{ - "From": {"tästsender8@programmfabrik.de"}, - "To": {"testreceiver8@programmfabrik.de"}, - "Subject": {"Tästmail mit Ümlauten im Header"}, - }, - body: []byte(`Hello World! + content: &ReceivedContent{ + headers: map[string][]string{ + "From": {"tästsender8@programmfabrik.de"}, + "To": {"testreceiver8@programmfabrik.de"}, + "Subject": {"Tästmail mit Ümlauten im Header"}, + }, + body: []byte(`Hello World! A simple plain text test mail.`), + }, }, { index: 8, @@ -531,15 +575,16 @@ Noch eine Testmail mit =C3=A4=C3=B6=C3=BC=C3=9F, diesmal enkodiert in quote= d-printable. --d36c3118be4745f9a1cb4556d11fe92d--`), receivedAt: testTime, - headers: map[string][]string{ - "Mime-Version": {"1.0"}, - "From": {"testsender9@programmfabrik.de"}, - "To": {"testreceiver9@programmfabrik.de"}, - "Date": {"Tue, 25 Jun 2024 11:15:57 +0200"}, - "Subject": {"Example Message"}, - "Content-Type": {`multipart/mixed; boundary="d36c3118be4745f9a1cb4556d11fe92d"`}, - }, - body: []byte(`--d36c3118be4745f9a1cb4556d11fe92d + content: &ReceivedContent{ + headers: map[string][]string{ + "Mime-Version": {"1.0"}, + "From": {"testsender9@programmfabrik.de"}, + "To": {"testreceiver9@programmfabrik.de"}, + "Date": {"Tue, 25 Jun 2024 11:15:57 +0200"}, + "Subject": {"Example Message"}, + "Content-Type": {`multipart/mixed; boundary="d36c3118be4745f9a1cb4556d11fe92d"`}, + }, + body: []byte(`--d36c3118be4745f9a1cb4556d11fe92d Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: base64 X-Funky-Header: =?utf-8?q?T=C3=A4stmail_mit_=C3=9Cmlauten_im_Header?= @@ -554,30 +599,43 @@ X-Funky-Header: Käse Noch eine Testmail mit =C3=A4=C3=B6=C3=BC=C3=9F, diesmal enkodiert in quote= d-printable. --d36c3118be4745f9a1cb4556d11fe92d--`), - contentType: "multipart/mixed", - contentTypeParams: map[string]string{ - "boundary": "d36c3118be4745f9a1cb4556d11fe92d", + contentType: "multipart/mixed", + contentTypeParams: map[string]string{ + "boundary": "d36c3118be4745f9a1cb4556d11fe92d", + }, + isMultipart: true, }, - isMultipart: true, multiparts: []*ReceivedPart{ { index: 0, - headers: map[string][]string{ - "Content-Type": {"text/plain; charset=utf-8"}, - "Content-Transfer-Encoding": {"base64"}, - "X-Funky-Header": {"Tästmail mit Ümlauten im Header"}, - }, - body: []byte(`Eine base64-enkodierte Testmail mit nicht-ASCII-Zeichen: äöüß + content: &ReceivedContent{ + headers: map[string][]string{ + "Content-Type": {"text/plain; charset=utf-8"}, + "Content-Transfer-Encoding": {"base64"}, + "X-Funky-Header": {"Tästmail mit Ümlauten im Header"}, + }, + body: []byte(`Eine base64-enkodierte Testmail mit nicht-ASCII-Zeichen: äöüß `), + contentType: "text/plain", + contentTypeParams: map[string]string{ + "charset": "utf-8", + }, + }, }, { index: 1, - headers: map[string][]string{ - "Content-Type": {"text/plain; charset=utf-8"}, - "Content-Transfer-Encoding": {"quoted-printable"}, - "X-Funky-Header": {"Käse"}, + content: &ReceivedContent{ + headers: map[string][]string{ + "Content-Type": {"text/plain; charset=utf-8"}, + "Content-Transfer-Encoding": {"quoted-printable"}, + "X-Funky-Header": {"Käse"}, + }, + body: []byte(`Noch eine Testmail mit äöüß, diesmal enkodiert in quoted-printable.`), + contentType: "text/plain", + contentTypeParams: map[string]string{ + "charset": "utf-8", + }, }, - body: []byte(`Noch eine Testmail mit äöüß, diesmal enkodiert in quoted-printable.`), }, }, }, @@ -596,17 +654,17 @@ d-printable. m.rawMessageData = appendCRLF(m.rawMessageData) // Format message body only if not in base64 transfer encoding - cte, ok := m.headers["Content-Transfer-Encoding"] + cte, ok := m.content.headers["Content-Transfer-Encoding"] if !ok || len(cte) != 1 || cte[0] != "base64" { - m.body = formatRaw(m.body) - m.body = appendCRLF(m.body) + m.content.body = formatRaw(m.content.body) + m.content.body = appendCRLF(m.content.body) } for _, p := range m.multiparts { // Format multipart body only if not in base64 transfer encoding - cte, ok = p.headers["Content-Transfer-Encoding"] + cte, ok = p.content.headers["Content-Transfer-Encoding"] if !ok || len(cte) != 1 || cte[0] != "base64" { - p.body = formatRaw(p.body) + p.content.body = formatRaw(p.content.body) } // Multiparts do not add a trailing CRLF