forked from Silverfish/proton-bridge
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:
@ -336,6 +336,9 @@ func TestBridge_SendInvite(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBridge_SendAddTextBodyPartIfNotExists(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;
|
const messageMultipartWithoutText = `Content-Type: multipart/mixed;
|
||||||
boundary="Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84"
|
boundary="Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84"
|
||||||
Subject: A new message
|
Subject: A new message
|
||||||
@ -343,7 +346,7 @@ Date: Mon, 13 Mar 2023 16:06:16 +0100
|
|||||||
|
|
||||||
|
|
||||||
--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84
|
--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84
|
||||||
Content-Disposition: inline;
|
Content-Disposition: attachment;
|
||||||
filename=Cat_August_2010-4.jpeg
|
filename=Cat_August_2010-4.jpeg
|
||||||
Content-Type: image/jpeg;
|
Content-Type: image/jpeg;
|
||||||
name="Cat_August_2010-4.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
|
Date: Mon, 13 Mar 2023 16:06:16 +0100
|
||||||
|
|
||||||
--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84
|
--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84
|
||||||
Content-Disposition: inline;
|
Content-Disposition: attachment;
|
||||||
filename=Cat_August_2010-4.jpeg
|
filename=Cat_August_2010-4.jpeg
|
||||||
Content-Type: image/jpeg;
|
Content-Type: image/jpeg;
|
||||||
name="Cat_August_2010-4.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"
|
"github.com/ProtonMail/proton-bridge/v3/pkg/message/parser"
|
||||||
pmmime "github.com/ProtonMail/proton-bridge/v3/pkg/mime"
|
pmmime "github.com/ProtonMail/proton-bridge/v3/pkg/mime"
|
||||||
"github.com/emersion/go-message"
|
"github.com/emersion/go-message"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/jaytaylor/html2text"
|
"github.com/jaytaylor/html2text"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/sirupsen/logrus"
|
"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")
|
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)
|
m, err := parseMessageHeader(p.Root().Header, allowInvalidAddressLists)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, errors.Wrap(err, "failed to parse message header")
|
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
|
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/PuerkitoBio/goquery"
|
||||||
"github.com/emersion/go-message"
|
"github.com/emersion/go-message"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
"golang.org/x/net/html/charset"
|
"golang.org/x/net/html/charset"
|
||||||
"golang.org/x/text/encoding"
|
"golang.org/x/text/encoding"
|
||||||
@ -52,6 +53,14 @@ func (p *Part) ContentType() (string, map[string]string, error) {
|
|||||||
return t, params, err
|
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) {
|
func (p *Part) Child(n int) (part *Part, err error) {
|
||||||
if len(p.children) < n {
|
if len(p.children) < n {
|
||||||
return nil, errors.New("no such part")
|
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 {
|
func (p *Part) ConvertToUTF8() error {
|
||||||
logrus.Trace("Converting part to utf-8")
|
logrus.Trace("Converting part to utf-8")
|
||||||
|
|
||||||
@ -183,6 +200,15 @@ func (p *Part) isMultipartMixed() bool {
|
|||||||
return t == "multipart/mixed"
|
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 {
|
func getContentHeaders(header message.Header) message.Header {
|
||||||
var res message.Header
|
var res message.Header
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ package message
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@ -312,11 +313,13 @@ func TestParseTextPlainWithImageInline(t *testing.T) {
|
|||||||
m, err := Parse(f)
|
m, err := Parse(f)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NotEmpty(t, m.Attachments[0].ContentID)
|
||||||
|
|
||||||
assert.Equal(t, `"Sender" <sender@pm.me>`, m.Sender.String())
|
assert.Equal(t, `"Sender" <sender@pm.me>`, m.Sender.String())
|
||||||
assert.Equal(t, `"Receiver" <receiver@pm.me>`, m.ToList[0].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, "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.
|
// The inline image is an 8x8 mic-dropping gopher.
|
||||||
require.Len(t, m.Attachments, 1)
|
require.Len(t, m.Attachments, 1)
|
||||||
@ -326,6 +329,69 @@ func TestParseTextPlainWithImageInline(t *testing.T) {
|
|||||||
assert.Equal(t, 8, img.Height)
|
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) {
|
func TestParseTextPlainWithDuplicateCharset(t *testing.T) {
|
||||||
f := getFileReader("text_plain_duplicate_charset.eml")
|
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, `"Sender" <sender@pm.me>`, m.Sender.String())
|
||||||
assert.Equal(t, `"Receiver" <receiver@pm.me>`, m.ToList[0].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))
|
assert.Equal(t, "This is body of *HTML mail* with attachment", string(m.PlainBody))
|
||||||
|
|
||||||
// The inline image is an 8x8 mic-dropping gopher.
|
// 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))
|
img, err := png.DecodeConfig(bytes.NewReader(m.Attachments[0].Data))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 8, img.Width)
|
assert.Equal(t, 8, img.Width)
|
||||||
@ -719,6 +786,23 @@ func TestParseTextPlainWithDocxAttachmentCyrillic(t *testing.T) {
|
|||||||
assert.Equal(t, "АБВГДЃЕЖЗЅИЈКЛЉМНЊОПРСТЌУФХЧЏЗШ.docx", m.Attachments[0].Name)
|
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 {
|
func getFileReader(filename string) io.Reader {
|
||||||
f, err := os.Open(filepath.Join("testdata", filename))
|
f, err := os.Open(filepath.Join("testdata", filename))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
39
pkg/message/testdata/text_plain_image_inline2.eml
vendored
Normal file
39
pkg/message/testdata/text_plain_image_inline2.eml
vendored
Normal file
@ -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--
|
||||||
35
pkg/message/testdata/text_plain_image_inline_attachment_first.eml
vendored
Normal file
35
pkg/message/testdata/text_plain_image_inline_attachment_first.eml
vendored
Normal file
@ -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--
|
||||||
46
pkg/message/testdata/text_plain_image_inline_between_attachment.eml
vendored
Normal file
46
pkg/message/testdata/text_plain_image_inline_between_attachment.eml
vendored
Normal file
@ -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--
|
||||||
Reference in New Issue
Block a user