satellite/email: add delimiter to close the last part of Multipart emails

The existing implementation doesn't send proper rfc1341 compatible mails
according to https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html as
the closing `--boundary_id--` is missing from our mails. (see 7.2.1 from
the standard).

Mailservers which are not very flexible, deny the mails.

Example mail log: (see the
missing boundary delimiter at the end).

```
Subject: Activate your email
From: "Storj DCS - EU1" <noreply@eu1.storj.io>
To: "..." <...>
...

--26d7220f6f1c9f6fb47b535319eb15dce513bb6e1d941a0efddf25e96712
Content-Type: text/html; charset=UTF-8

....

</body>
</html>
.
smtp: DATA error	{"msg_id":"6d82c9e3","reason":"unexpected EOF"}
smtp: 554 5.0.0 Internal server error (msg ID = 6d82c9e3)
```

This patch moves all the Multipart writing to a function to make sure
that the `wr.Close()` (which writes out the last part) is executed
BEFORE `body.Bytes()` (defer added it AFTER `body.Bytes()` was calculated)

Change-Id: I8f18fc81b1857b646470eab32e73d6cbdc50d2ad
This commit is contained in:
Márton Elek 2022-01-31 15:51:19 +01:00 committed by Elek, Márton
parent 0d03473e00
commit 2ae78db660
2 changed files with 85 additions and 38 deletions

View File

@ -66,44 +66,9 @@ func (msg *Message) Bytes() (data []byte, err error) {
switch {
// multipart upload
case len(msg.Parts) > 0:
wr := multipart.NewWriter(&body)
defer func() { err = errs.Combine(err, wr.Close()) }()
fmt.Fprintf(&body, "Content-Type: multipart/alternative;")
fmt.Fprintf(&body, "\tboundary=\"%v\"\r\n", wr.Boundary())
fmt.Fprintf(&body, "\r\n")
var sub io.Writer
if len(msg.PlainText) > 0 {
sub, err := wr.CreatePart(textproto.MIMEHeader{
"Content-Type": []string{"text/plain; charset=UTF-8; format=flowed"},
"Content-Transfer-Encoding": []string{"quoted-printable"},
})
if err != nil {
return nil, Error.Wrap(err)
}
enc := quotedprintable.NewWriter(sub)
defer func() { err = errs.Combine(err, enc.Close()) }()
_, err = enc.Write([]byte(msg.PlainText))
if err != nil {
return nil, Error.Wrap(err)
}
}
for _, part := range msg.Parts {
header := textproto.MIMEHeader{"Content-Type": []string{mime.QEncoding.Encode("utf-8", part.Type)}}
if part.Encoding != "" {
header["Content-Transfer-Encoding"] = []string{mime.QEncoding.Encode("utf-8", part.Encoding)}
}
if part.Disposition != "" {
header["Content-Disposition"] = []string{mime.QEncoding.Encode("utf-8", part.Disposition)}
}
sub, _ = wr.CreatePart(header)
fmt.Fprint(sub, part.Content)
err = msg.writeMultipart(&body)
if err != nil {
return nil, err
}
// fallback if there are no parts, write PlainText with appropriate Content-Type
@ -122,6 +87,49 @@ func (msg *Message) Bytes() (data []byte, err error) {
return tocrlf(body.Bytes()), nil
}
func (msg *Message) writeMultipart(body *bytes.Buffer) (err error) {
wr := multipart.NewWriter(body)
defer func() { err = errs.Combine(err, wr.Close()) }()
fmt.Fprintf(body, "Content-Type: multipart/alternative;")
fmt.Fprintf(body, "\tboundary=\"%v\"\r\n", wr.Boundary())
fmt.Fprintf(body, "\r\n")
var sub io.Writer
if len(msg.PlainText) > 0 {
sub, err := wr.CreatePart(textproto.MIMEHeader{
"Content-Type": []string{"text/plain; charset=UTF-8; format=flowed"},
"Content-Transfer-Encoding": []string{"quoted-printable"},
})
if err != nil {
return Error.Wrap(err)
}
enc := quotedprintable.NewWriter(sub)
defer func() { err = errs.Combine(err, enc.Close()) }()
_, err = enc.Write([]byte(msg.PlainText))
if err != nil {
return Error.Wrap(err)
}
}
for _, part := range msg.Parts {
header := textproto.MIMEHeader{"Content-Type": []string{mime.QEncoding.Encode("utf-8", part.Type)}}
if part.Encoding != "" {
header["Content-Transfer-Encoding"] = []string{mime.QEncoding.Encode("utf-8", part.Encoding)}
}
if part.Disposition != "" {
header["Content-Disposition"] = []string{mime.QEncoding.Encode("utf-8", part.Disposition)}
}
sub, _ = wr.CreatePart(header)
fmt.Fprint(sub, part.Content)
}
return nil
}
func tocrlf(data []byte) []byte {
lf := bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n"))
crlf := bytes.ReplaceAll(lf, []byte("\n"), []byte("\r\n"))

View File

@ -0,0 +1,39 @@
// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information
package post
import (
"net/mail"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestMessage_ClosingLastPart(t *testing.T) {
from := mail.Address{Name: "No reply", Address: "noreply@eu1.storj.io"}
m := &Message{
From: from,
To: []mail.Address{{Name: "Foo Bar", Address: "foo@storj.io"}},
Subject: "This is a proper test mail",
PlainText: "",
Parts: []Part{
{
Type: "text/html; charset=UTF-8",
Content: string("<head><body><h1>ahoj</h1></body></head>"),
},
},
}
data, err := m.Bytes()
require.NoError(t, err)
lines := strings.Split(string(data), "\n")
// last part should be closed. see 7.2.1 of https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html
final := regexp.MustCompile("--.+--")
lastNonEmptyLine := lines[len(lines)-2]
require.True(t, final.MatchString(lastNonEmptyLine), "Last line '%s' doesn't include RFC1341 distinguished delimiter", lastNonEmptyLine)
}