diff --git a/internal/bridge/send_test.go b/internal/bridge/send_test.go index 5030979c..dfc44fbc 100644 --- a/internal/bridge/send_test.go +++ b/internal/bridge/send_test.go @@ -336,6 +336,9 @@ func TestBridge_SendInvite(t *testing.T) { } func TestBridge_SendAddTextBodyPartIfNotExists(t *testing.T) { + // NOTE: Prior to GODT-2887, these tests had inline images, however after the implementation to support + // inline images new parts are injected to reference inline images without content-id set. The images + // in this test have been changed to regular attachments to keep the original checks in place. const messageMultipartWithoutText = `Content-Type: multipart/mixed; boundary="Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84" Subject: A new message @@ -343,7 +346,7 @@ Date: Mon, 13 Mar 2023 16:06:16 +0100 --Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84 -Content-Disposition: inline; +Content-Disposition: attachment; filename=Cat_August_2010-4.jpeg Content-Type: image/jpeg; name="Cat_August_2010-4.jpeg" @@ -360,7 +363,7 @@ Subject: A new message Part2 Date: Mon, 13 Mar 2023 16:06:16 +0100 --Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84 -Content-Disposition: inline; +Content-Disposition: attachment; filename=Cat_August_2010-4.jpeg Content-Type: image/jpeg; name="Cat_August_2010-4.jpeg" @@ -520,3 +523,181 @@ SGVsbG8gd29ybGQK }) }) } + +func TestBridge_SendInlineImage(t *testing.T) { + const messageInlineImageOnly = `Content-Type: multipart/mixed; + boundary="Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84" +Subject: A new message +Date: Mon, 13 Mar 2023 16:06:16 +0100 + + +--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84 +Content-Disposition: inline; + filename=Cat_August_2010-4.jpeg +Content-Type: image/jpeg; + name="Cat_August_2010-4.jpeg" +Content-Transfer-Encoding: base64 + +SGVsbG8gd29ybGQ= + +--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84-- + ` + + const messageInlineImageWithHTML = `Content-Type: multipart/mixed; + boundary="Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84" +Subject: A new message Part2 +Date: Mon, 13 Mar 2023 16:06:16 +0100 + +--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84 +Content-Type: text/html;charset=utf8 +Content-Transfer-Encoding: quoted-printable + +Hello world + +--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84 +Content-Disposition: inline; + filename=Cat_August_2010-4.jpeg +Content-Type: image/jpeg; + name="Cat_August_2010-4.jpeg" +Content-Transfer-Encoding: base64 + +SGVsbG8gd29ybGQ= + +--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84-- +` + + const messageInlineImageWithText = `Content-Type: multipart/mixed; + boundary="Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84" +Subject: A new message Part3 +Date: Mon, 13 Mar 2023 16:06:16 +0100 + +--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84 +Content-Type: text/plain;charset=utf8 +Content-Transfer-Encoding: quoted-printable + +Hello world + +--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84 +Content-Disposition: inline; + filename=Cat_August_2010-4.jpeg +Content-Type: image/jpeg; + name="Cat_August_2010-4.jpeg" +Content-Transfer-Encoding: base64 + +SGVsbG8gd29ybGQ= + +--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84-- +` + + const messageInlineImageFollowedByText = `Content-Type: multipart/mixed; + boundary="Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84" +Subject: A new message Part4 +Date: Mon, 13 Mar 2023 16:06:16 +0100 + +--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84 +Content-Disposition: inline; + filename=Cat_August_2010-4.jpeg +Content-Type: image/jpeg; + name="Cat_August_2010-4.jpeg" +Content-Transfer-Encoding: base64 + +SGVsbG8gd29ybGQ= + +--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84 +Content-Type: text/plain;charset=utf8 +Content-Transfer-Encoding: quoted-printable + +Hello world + +--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84-- +` + withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { + _, _, err := s.CreateUser("recipient", password) + require.NoError(t, err) + + withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) { + smtpWaiter := waitForSMTPServerReady(bridge) + defer smtpWaiter.Done() + + senderUserID, err := bridge.LoginFull(ctx, username, password, nil, nil) + require.NoError(t, err) + + recipientUserID, err := bridge.LoginFull(ctx, "recipient", password, nil, nil) + require.NoError(t, err) + + senderInfo, err := bridge.GetUserInfo(senderUserID) + require.NoError(t, err) + + recipientInfo, err := bridge.GetUserInfo(recipientUserID) + require.NoError(t, err) + + messages := []string{ + messageInlineImageOnly, + messageInlineImageWithHTML, + messageInlineImageWithText, + messageInlineImageFollowedByText, + } + + smtpWaiter.Wait() + + for _, m := range messages { + // Dial the server. + client, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort()))) + require.NoError(t, err) + defer client.Close() //nolint:errcheck + + // Upgrade to TLS. + require.NoError(t, client.StartTLS(&tls.Config{InsecureSkipVerify: true})) + + // Authorize with SASL LOGIN. + require.NoError(t, client.Auth(sasl.NewLoginClient( + senderInfo.Addresses[0], + string(senderInfo.BridgePass)), + )) + + // Send the message. + require.NoError(t, client.SendMail( + senderInfo.Addresses[0], + []string{recipientInfo.Addresses[0]}, + strings.NewReader(m), + )) + } + + // Connect the sender IMAP client. + senderIMAPClient, err := eventuallyDial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetIMAPPort()))) + require.NoError(t, err) + require.NoError(t, senderIMAPClient.Login(senderInfo.Addresses[0], string(senderInfo.BridgePass))) + defer senderIMAPClient.Logout() //nolint:errcheck + + // Connect the recipient IMAP client. + recipientIMAPClient, err := eventuallyDial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetIMAPPort()))) + require.NoError(t, err) + require.NoError(t, recipientIMAPClient.Login(recipientInfo.Addresses[0], string(recipientInfo.BridgePass))) + defer recipientIMAPClient.Logout() //nolint:errcheck + + require.Eventually(t, func() bool { + messages, err := clientFetch(senderIMAPClient, `Sent`, imap.FetchBodyStructure) + require.NoError(t, err) + if len(messages) != 4 { + return false + } + + // messages may not be in order + for _, message := range messages { + require.Equal(t, 1, len(message.BodyStructure.Parts)) + require.Equal(t, "multipart", message.BodyStructure.MIMEType) + require.Equal(t, "mixed", message.BodyStructure.MIMESubType) + require.Equal(t, "multipart", message.BodyStructure.Parts[0].MIMEType) + require.Equal(t, "related", message.BodyStructure.Parts[0].MIMESubType) + require.Len(t, message.BodyStructure.Parts[0].Parts, 2) + require.Equal(t, "text", message.BodyStructure.Parts[0].Parts[0].MIMEType) + require.Equal(t, "html", message.BodyStructure.Parts[0].Parts[0].MIMESubType) + require.Equal(t, "image", message.BodyStructure.Parts[0].Parts[1].MIMEType) + require.Equal(t, "jpeg", message.BodyStructure.Parts[0].Parts[1].MIMESubType) + } + + return true + }, 10*time.Second, 100*time.Millisecond) + }) + }) +} diff --git a/pkg/message/parser.go b/pkg/message/parser.go index bcc0cb91..ded02bb6 100644 --- a/pkg/message/parser.go +++ b/pkg/message/parser.go @@ -32,6 +32,7 @@ import ( "github.com/ProtonMail/proton-bridge/v3/pkg/message/parser" pmmime "github.com/ProtonMail/proton-bridge/v3/pkg/mime" "github.com/emersion/go-message" + "github.com/google/uuid" "github.com/jaytaylor/html2text" "github.com/pkg/errors" "github.com/sirupsen/logrus" @@ -116,6 +117,10 @@ func parse(p *parser.Parser, allowInvalidAddressLists bool) (Message, error) { return Message{}, errors.Wrap(err, "failed to convert foreign encodings") } + if err := patchInlineImages(p); err != nil { + return Message{}, err + } + m, err := parseMessageHeader(p.Root().Header, allowInvalidAddressLists) if err != nil { return Message{}, errors.Wrap(err, "failed to parse message header") @@ -636,3 +641,168 @@ func forEachDecodedHeaderField(h message.Header, fn func(string, string) error) return nil } + +func patchInlineImages(p *parser.Parser) error { + // This code will only attempt to patch the root level children. I tested with different email clients and as soon + // as you reply/forward a message the entire content gets converted into HTML (Apple Mail/Thunderbird/Evolution). + // If you are forcing text formatting (Evolution), the inline images of the original email are stripped. + // The only reason we need to apply this modification is that Apple Mail can send out text + inline image parts + // if the text does not exceed the 76 char column limit. + // Based on this, it's unlikely we will see any other variations. + root := p.Root() + + children := root.Children() + + if len(children) < 2 { + return nil + } + + result := make([]inlinePatchJob, len(children)) + + var ( + transformationNeeded bool + prevPart *parser.Part + prevContentType string + prevContentTypeMap map[string]string + ) + + for i := 0; i < len(children); i++ { + curPart := children[i] + + contentType, contentTypeMap, err := curPart.ContentType() + if err != nil { + return fmt.Errorf("failed to get content type for for child %v:%w", i, err) + } + + if rfc822.MIMEType(contentType) == rfc822.TextPlain { + result[i] = &inlinePatchBodyOnly{part: curPart, contentTypeMap: contentTypeMap} + } else if strings.HasPrefix(contentType, "image/") { + disposition, _, err := curPart.ContentDisposition() + if err != nil { + return fmt.Errorf("failted to get content disposition for child %v:%w", i, err) + } + + if disposition == "inline" && !curPart.HasContentID() { + if rfc822.MIMEType(prevContentType) == rfc822.TextPlain { + result[i-1] = &inlinePatchBodyWithInlineImage{ + textPart: prevPart, + imagePart: curPart, + textContentTypeMap: prevContentTypeMap, + } + } else { + result[i] = &inlinePatchInlineImageOnly{part: curPart, partIndex: i, root: root} + } + transformationNeeded = true + } + } + prevPart = curPart + prevContentType = contentType + prevContentTypeMap = contentTypeMap + } + + if !transformationNeeded { + return nil + } + + for _, t := range result { + if t != nil { + t.Patch() + } + } + + return nil +} + +type inlinePatchJob interface { + Patch() +} + +// inlinePatchBodyOnly is meant to be used for standalone text parts that need to be converted to html once we applty +// one of the changes. +type inlinePatchBodyOnly struct { + part *parser.Part + contentTypeMap map[string]string +} + +func (i *inlinePatchBodyOnly) Patch() { + newBody := []byte(`

`) + newBody = append(newBody, patchNewLineWithHTMLBreaks(i.part.Body)...) + newBody = append(newBody, []byte(`

`)...) + + i.part.Body = newBody + i.part.Header.SetContentType("text/html", i.contentTypeMap) +} + +// inlinePatchBodyWithInlineImage patches a previous text part so that it refers to that inline image. +type inlinePatchBodyWithInlineImage struct { + textPart *parser.Part + textContentTypeMap map[string]string + imagePart *parser.Part +} + +// inlinePatchInlineImageOnly handle the case where the inline image is not proceeded by a text part. To avoid +// having to parse any possible previous part, we just inject a new part that references this image. +type inlinePatchInlineImageOnly struct { + part *parser.Part + partIndex int + root *parser.Part +} + +func (i inlinePatchInlineImageOnly) Patch() { + contentID := uuid.NewString() + // Convert previous part to text/html && inject image. + newBody := []byte(fmt.Sprintf(``, contentID)) + + i.part.Header.Set("content-id", contentID) + + // create new text part + textPart := &parser.Part{ + Header: message.Header{}, + Body: newBody, + } + + textPart.Header.SetContentType("text/html", map[string]string{"charset": "UTF-8"}) + + i.root.InsertChild(i.partIndex, textPart) +} + +func (i *inlinePatchBodyWithInlineImage) Patch() { + contentID := uuid.NewString() + // Convert previous part to text/html && inject image. + newBody := []byte(`

`) + newBody = append(newBody, patchNewLineWithHTMLBreaks(i.textPart.Body)...) + newBody = append(newBody, []byte(`

`)...) + newBody = append(newBody, []byte(fmt.Sprintf(``, contentID))...) + newBody = append(newBody, []byte(``)...) + + i.textPart.Body = newBody + i.textPart.Header.SetContentType("text/html", i.textContentTypeMap) + + // Add content id to curPart + i.imagePart.Header.Set("content-id", contentID) +} + +func patchNewLineWithHTMLBreaks(input []byte) []byte { + dst := make([]byte, 0, len(input)) + index := 0 + for { + slice := input[index:] + newLineIndex := bytes.IndexByte(slice, '\n') + + if newLineIndex == -1 { + dst = append(dst, input[index:]...) + return dst + } + + injectIndex := newLineIndex + if newLineIndex > 0 && slice[newLineIndex-1] == '\r' { + injectIndex-- + } + + dst = append(dst, slice[0:injectIndex]...) + dst = append(dst, '<', 'b', 'r', '/', '>') + dst = append(dst, slice[injectIndex:newLineIndex+1]...) + + index += newLineIndex + 1 + } +} diff --git a/pkg/message/parser/part.go b/pkg/message/parser/part.go index 3248fa3d..1f93bd01 100644 --- a/pkg/message/parser/part.go +++ b/pkg/message/parser/part.go @@ -27,6 +27,7 @@ import ( "github.com/PuerkitoBio/goquery" "github.com/emersion/go-message" "github.com/sirupsen/logrus" + "golang.org/x/exp/slices" "golang.org/x/net/html" "golang.org/x/net/html/charset" "golang.org/x/text/encoding" @@ -52,6 +53,14 @@ func (p *Part) ContentType() (string, map[string]string, error) { return t, params, err } +func (p *Part) ContentDisposition() (string, map[string]string, error) { + return p.Header.ContentDisposition() +} + +func (p *Part) HasContentID() bool { + return len(p.Header.Get("content-id")) != 0 +} + func (p *Part) Child(n int) (part *Part, err error) { if len(p.children) < n { return nil, errors.New("no such part") @@ -81,6 +90,14 @@ func (p *Part) AddChild(child *Part) { } } +func (p *Part) InsertChild(index int, child *Part) { + if p.isMultipartMixedOrRelated() { + p.children = slices.Insert(p.children, index, child) + } else { + p.AddChild(child) + } +} + func (p *Part) ConvertToUTF8() error { logrus.Trace("Converting part to utf-8") @@ -183,6 +200,15 @@ func (p *Part) isMultipartMixed() bool { return t == "multipart/mixed" } +func (p *Part) isMultipartMixedOrRelated() bool { + t, _, err := p.ContentType() + if err != nil { + return false + } + + return t == "multipart/mixed" || t == "multipart/related" +} + func getContentHeaders(header message.Header) message.Header { var res message.Header diff --git a/pkg/message/parser_test.go b/pkg/message/parser_test.go index 9131e236..65345981 100644 --- a/pkg/message/parser_test.go +++ b/pkg/message/parser_test.go @@ -19,6 +19,7 @@ package message import ( "bytes" + "fmt" "image/png" "io" "os" @@ -312,11 +313,13 @@ func TestParseTextPlainWithImageInline(t *testing.T) { m, err := Parse(f) require.NoError(t, err) + require.NotEmpty(t, m.Attachments[0].ContentID) + assert.Equal(t, `"Sender" `, m.Sender.String()) assert.Equal(t, `"Receiver" `, m.ToList[0].String()) - assert.Equal(t, "body", string(m.RichBody)) assert.Equal(t, "body", string(m.PlainBody)) + assert.Equal(t, fmt.Sprintf(`

body

`, m.Attachments[0].ContentID), string(m.RichBody)) // The inline image is an 8x8 mic-dropping gopher. require.Len(t, m.Attachments, 1) @@ -326,6 +329,69 @@ func TestParseTextPlainWithImageInline(t *testing.T) { assert.Equal(t, 8, img.Height) } +func TestParseTextPlainWithImageInlineWithMoreTextParts(t *testing.T) { + // Inline image test with text - image - text, ensure all parts are convert to html + f := getFileReader("text_plain_image_inline2.eml") + + m, err := Parse(f) + require.NoError(t, err) + + require.NotEmpty(t, m.Attachments[0].ContentID) + assert.Equal(t, "bodybody2", string(m.PlainBody)) + assert.Equal(t, fmt.Sprintf("

body

body2
\n

", m.Attachments[0].ContentID), string(m.RichBody)) + + // The inline image is an 8x8 mic-dropping gopher. + require.Len(t, m.Attachments, 1) + img, err := png.DecodeConfig(bytes.NewReader(m.Attachments[0].Data)) + require.NoError(t, err) + assert.Equal(t, 8, img.Width) + assert.Equal(t, 8, img.Height) +} + +func TestParseTextPlainWithImageInlineAfterOtherAttachment(t *testing.T) { + // Inline image test with text - image - text, ensure all parts are convert to html + f := getFileReader("text_plain_image_inline2.eml") + + m, err := Parse(f) + require.NoError(t, err) + + require.NotEmpty(t, m.Attachments[0].ContentID) + assert.Equal(t, "bodybody2", string(m.PlainBody)) + assert.Equal(t, fmt.Sprintf("

body

body2
\n

", m.Attachments[0].ContentID), string(m.RichBody)) + + // The inline image is an 8x8 mic-dropping gopher. + require.Len(t, m.Attachments, 1) + img, err := png.DecodeConfig(bytes.NewReader(m.Attachments[0].Data)) + require.NoError(t, err) + assert.Equal(t, 8, img.Width) + assert.Equal(t, 8, img.Height) +} + +func TestParseTextPlainWithImageBetweenAttachments(t *testing.T) { + // Inline image test with text - pdf - image - text. A new part must be created to be injected. + f := getFileReader("text_plain_image_inline_between_attachment.eml") + + m, err := Parse(f) + require.NoError(t, err) + + require.Empty(t, m.Attachments[0].ContentID) + require.NotEmpty(t, m.Attachments[1].ContentID) + assert.Equal(t, "bodybody2", string(m.PlainBody)) + assert.Equal(t, fmt.Sprintf("

body

body2
\n

", m.Attachments[1].ContentID), string(m.RichBody)) +} + +func TestParseTextPlainWithImageFirst(t *testing.T) { + // Inline image test with text - pdf - image - text. A new part must be created to be injected. + f := getFileReader("text_plain_image_inline_attachment_first.eml") + + m, err := Parse(f) + require.NoError(t, err) + + require.NotEmpty(t, m.Attachments[0].ContentID) + assert.Equal(t, "body", string(m.PlainBody)) + assert.Equal(t, fmt.Sprintf("

body

", m.Attachments[0].ContentID), string(m.RichBody)) +} + func TestParseTextPlainWithDuplicateCharset(t *testing.T) { f := getFileReader("text_plain_duplicate_charset.eml") @@ -428,11 +494,12 @@ func TestParseTextHTMLWithImageInline(t *testing.T) { assert.Equal(t, `"Sender" `, m.Sender.String()) assert.Equal(t, `"Receiver" `, m.ToList[0].String()) - assert.Equal(t, "This is body of HTML mail with attachment", string(m.RichBody)) + require.Len(t, m.Attachments, 1) + + assert.Equal(t, fmt.Sprintf(`This is body of HTML mail with attachment`, m.Attachments[0].ContentID), string(m.RichBody)) assert.Equal(t, "This is body of *HTML mail* with attachment", string(m.PlainBody)) // The inline image is an 8x8 mic-dropping gopher. - require.Len(t, m.Attachments, 1) img, err := png.DecodeConfig(bytes.NewReader(m.Attachments[0].Data)) require.NoError(t, err) assert.Equal(t, 8, img.Width) @@ -719,6 +786,23 @@ func TestParseTextPlainWithDocxAttachmentCyrillic(t *testing.T) { assert.Equal(t, "АБВГДЃЕЖЗЅИЈКЛЉМНЊОПРСТЌУФХЧЏЗШ.docx", m.Attachments[0].Name) } +func TestPatchNewLineWithHtmlBreaks(t *testing.T) { + { + input := []byte("\nfoo\nbar\n\n\nzz\nddd") + expected := []byte("
\nfoo
\nbar
\n
\n
\nzz
\nddd") + + result := patchNewLineWithHTMLBreaks(input) + require.Equal(t, expected, result) + } + { + input := []byte("\r\nfoo\r\nbar\r\n\r\n\r\nzz\r\nddd") + expected := []byte("
\r\nfoo
\r\nbar
\r\n
\r\n
\r\nzz
\r\nddd") + + result := patchNewLineWithHTMLBreaks(input) + require.Equal(t, expected, result) + } +} + func getFileReader(filename string) io.Reader { f, err := os.Open(filepath.Join("testdata", filename)) if err != nil { diff --git a/pkg/message/testdata/text_plain_image_inline2.eml b/pkg/message/testdata/text_plain_image_inline2.eml new file mode 100644 index 00000000..5e113332 --- /dev/null +++ b/pkg/message/testdata/text_plain_image_inline2.eml @@ -0,0 +1,39 @@ +From: Sender +To: Receiver +Content-Type: multipart/related; boundary=longrandomstring + +--longrandomstring + +body +--longrandomstring +Content-Type: image/png +Content-Disposition: inline +Content-Transfer-Encoding: base64 + +iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAABGdBTUEAALGPC/xhBQAAACBjSFJ +NAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFAR +IAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAA +ABaAAAAAAAAASwAAAABAAABLAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAACKADAAQAAAAB +AAAACAAAAAAAXWZ6AAAACXBIWXMAAC4jAAAuIwF4pT92AAACZmlUWHRYTUw6Y29tLmFkb2JlLnh +tcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIE +NvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5O +TkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91 +dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4 +wLyIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC +8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgI +CAgICA8dGlmZjpSZXNvbHV0aW9uVW5pdD4yPC90aWZmOlJlc29sdXRpb25Vbml0PgogICAgICAg +ICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl +4ZWxYRGltZW5zaW9uPjE2PC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UG +l4ZWxZRGltZW5zaW9uPjE2PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY +3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CgZBD4sAAAEISURBVBgZY2CAAO5F +x07Zz96xZ0Pn4lXqIKGGhgYmsFTHvAWdW6/dvnb89Yf/B5+9/r/y9IXzbVPahCH6/jMysfAJygo +JC2r++/T619Mb139J8HIb8Gs5hYMUzJ+/gJ1Jmo9H6c+L5wz3bt5iEeLmYOHn42fQ4vyacqGNQS +0xMfEHc7Cvl6CYho4rh5jUPyYefqafLKyMbH9+/d28/dFfdWtfDaZvTy7Zvv72nYGZkeEvw98/f +5j//2P4yCvxq/nU7zVs//8yM2gzMMitOnnu5cUff/8ff/v5/5Xf///vuHBhJcSRDAws9aEMr38c +W7XjNgvzexZ2rn9vbjx/IXl/M9iLM2fOZAUAKCZv7dU+UgAAAAAASUVORK5CYII= + +--longrandomstring + +body2 + +--longrandomstring-- \ No newline at end of file diff --git a/pkg/message/testdata/text_plain_image_inline_attachment_first.eml b/pkg/message/testdata/text_plain_image_inline_attachment_first.eml new file mode 100644 index 00000000..c6f97a87 --- /dev/null +++ b/pkg/message/testdata/text_plain_image_inline_attachment_first.eml @@ -0,0 +1,35 @@ +From: Sender +To: Receiver +Content-Type: multipart/related; boundary=longrandomstring + +--longrandomstring +Content-Type: image/png +Content-Disposition: inline +Content-Transfer-Encoding: base64 + +iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAABGdBTUEAALGPC/xhBQAAACBjSFJ +NAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFAR +IAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAA +ABaAAAAAAAAASwAAAABAAABLAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAACKADAAQAAAAB +AAAACAAAAAAAXWZ6AAAACXBIWXMAAC4jAAAuIwF4pT92AAACZmlUWHRYTUw6Y29tLmFkb2JlLnh +tcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIE +NvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5O +TkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91 +dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4 +wLyIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC +8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgI +CAgICA8dGlmZjpSZXNvbHV0aW9uVW5pdD4yPC90aWZmOlJlc29sdXRpb25Vbml0PgogICAgICAg +ICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl +4ZWxYRGltZW5zaW9uPjE2PC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UG +l4ZWxZRGltZW5zaW9uPjE2PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY +3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CgZBD4sAAAEISURBVBgZY2CAAO5F +x07Zz96xZ0Pn4lXqIKGGhgYmsFTHvAWdW6/dvnb89Yf/B5+9/r/y9IXzbVPahCH6/jMysfAJygo +JC2r++/T619Mb139J8HIb8Gs5hYMUzJ+/gJ1Jmo9H6c+L5wz3bt5iEeLmYOHn42fQ4vyacqGNQS +0xMfEHc7Cvl6CYho4rh5jUPyYefqafLKyMbH9+/d28/dFfdWtfDaZvTy7Zvv72nYGZkeEvw98/f +5j//2P4yCvxq/nU7zVs//8yM2gzMMitOnnu5cUff/8ff/v5/5Xf///vuHBhJcSRDAws9aEMr38c +W7XjNgvzexZ2rn9vbjx/IXl/M9iLM2fOZAUAKCZv7dU+UgAAAAAASUVORK5CYII= + +--longrandomstring + +body +--longrandomstring-- \ No newline at end of file diff --git a/pkg/message/testdata/text_plain_image_inline_between_attachment.eml b/pkg/message/testdata/text_plain_image_inline_between_attachment.eml new file mode 100644 index 00000000..9ade346f --- /dev/null +++ b/pkg/message/testdata/text_plain_image_inline_between_attachment.eml @@ -0,0 +1,46 @@ +From: Sender +To: Receiver +Content-Type: multipart/related; boundary=longrandomstring + +--longrandomstring + +body +--longrandomstring +Content-Type: application/pdf +Content-Disposition: inline +Content-Transfer-Encoding: base64 + +aGVsbG8gd29ybGQgcGRm + +--longrandomstring +Content-Type: image/png +Content-Disposition: inline +Content-Transfer-Encoding: base64 + +iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAABGdBTUEAALGPC/xhBQAAACBjSFJ +NAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFAR +IAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAA +ABaAAAAAAAAASwAAAABAAABLAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAACKADAAQAAAAB +AAAACAAAAAAAXWZ6AAAACXBIWXMAAC4jAAAuIwF4pT92AAACZmlUWHRYTUw6Y29tLmFkb2JlLnh +tcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIE +NvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5O +TkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91 +dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4 +wLyIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC +8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgI +CAgICA8dGlmZjpSZXNvbHV0aW9uVW5pdD4yPC90aWZmOlJlc29sdXRpb25Vbml0PgogICAgICAg +ICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl +4ZWxYRGltZW5zaW9uPjE2PC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UG +l4ZWxZRGltZW5zaW9uPjE2PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY +3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CgZBD4sAAAEISURBVBgZY2CAAO5F +x07Zz96xZ0Pn4lXqIKGGhgYmsFTHvAWdW6/dvnb89Yf/B5+9/r/y9IXzbVPahCH6/jMysfAJygo +JC2r++/T619Mb139J8HIb8Gs5hYMUzJ+/gJ1Jmo9H6c+L5wz3bt5iEeLmYOHn42fQ4vyacqGNQS +0xMfEHc7Cvl6CYho4rh5jUPyYefqafLKyMbH9+/d28/dFfdWtfDaZvTy7Zvv72nYGZkeEvw98/f +5j//2P4yCvxq/nU7zVs//8yM2gzMMitOnnu5cUff/8ff/v5/5Xf///vuHBhJcSRDAws9aEMr38c +W7XjNgvzexZ2rn9vbjx/IXl/M9iLM2fOZAUAKCZv7dU+UgAAAAAASUVORK5CYII= + +--longrandomstring + +body2 + +--longrandomstring-- \ No newline at end of file