fix(GODT-2887): Inline images with Apple Mail
Fix sending of inline images with Apple Mail when not using rich text.
This commit is contained in:
parent
0f320dbd80
commit
96773f3225
|
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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(`<html><body><p>`)
|
||||
newBody = append(newBody, patchNewLineWithHTMLBreaks(i.part.Body)...)
|
||||
newBody = append(newBody, []byte(`</p></body></html>`)...)
|
||||
|
||||
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(`<html><body><img src="cid:%v"/></body></html>`, 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(`<html><body><p>`)
|
||||
newBody = append(newBody, patchNewLineWithHTMLBreaks(i.textPart.Body)...)
|
||||
newBody = append(newBody, []byte(`</p>`)...)
|
||||
newBody = append(newBody, []byte(fmt.Sprintf(`<img src="cid:%v"/>`, contentID))...)
|
||||
newBody = append(newBody, []byte(`</body></html>`)...)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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" <sender@pm.me>`, m.Sender.String())
|
||||
assert.Equal(t, `"Receiver" <receiver@pm.me>`, m.ToList[0].String())
|
||||
|
||||
assert.Equal(t, "body", string(m.RichBody))
|
||||
assert.Equal(t, "body", string(m.PlainBody))
|
||||
assert.Equal(t, fmt.Sprintf(`<html><body><p>body</p><img src="cid:%v"/></body></html>`, 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("<html><body><p>body</p><img src=\"cid:%v\"/></body></html><html><body><p>body2<br/>\n</p></body></html>", 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("<html><body><p>body</p><img src=\"cid:%v\"/></body></html><html><body><p>body2<br/>\n</p></body></html>", 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("<html><body><p>body</p></body></html><html><body><img src=\"cid:%v\"/></body></html><html><body><p>body2<br/>\n</p></body></html>", 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("<html><body><img src=\"cid:%v\"/></body></html><html><body><p>body</p></body></html>", 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" <sender@pm.me>`, m.Sender.String())
|
||||
assert.Equal(t, `"Receiver" <receiver@pm.me>`, m.ToList[0].String())
|
||||
|
||||
assert.Equal(t, "<html><head></head><body>This is body of <b>HTML mail</b> with attachment</body></html>", string(m.RichBody))
|
||||
require.Len(t, m.Attachments, 1)
|
||||
|
||||
assert.Equal(t, fmt.Sprintf(`<html><head></head><body>This is body of <b>HTML mail</b> with attachment</body></html><html><body><img src="cid:%v"/></body></html>`, 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("<br/>\nfoo<br/>\nbar<br/>\n<br/>\n<br/>\nzz<br/>\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("<br/>\r\nfoo<br/>\r\nbar<br/>\r\n<br/>\r\n<br/>\r\nzz<br/>\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 {
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
From: Sender <sender@pm.me>
|
||||
To: Receiver <receiver@pm.me>
|
||||
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--
|
|
@ -0,0 +1,35 @@
|
|||
From: Sender <sender@pm.me>
|
||||
To: Receiver <receiver@pm.me>
|
||||
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--
|
|
@ -0,0 +1,46 @@
|
|||
From: Sender <sender@pm.me>
|
||||
To: Receiver <receiver@pm.me>
|
||||
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--
|
Loading…
Reference in New Issue