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