diff --git a/Makefile b/Makefile
index 288c1068..76bb91e8 100644
--- a/Makefile
+++ b/Makefile
@@ -258,6 +258,7 @@ mocks:
mockgen --package mocks github.com/ProtonMail/proton-bridge/internal/transfer PanicHandler,ClientManager,IMAPClientProvider > internal/transfer/mocks/mocks.go
mockgen --package mocks github.com/ProtonMail/proton-bridge/internal/store PanicHandler,ClientManager,BridgeUser,ChangeNotifier > internal/store/mocks/mocks.go
mockgen --package mocks github.com/ProtonMail/proton-bridge/pkg/listener Listener > internal/store/mocks/utils_mocks.go
+ mockgen --package mocks github.com/ProtonMail/proton-bridge/pkg/message Fetcher > pkg/message/mocks/mocks.go
mockgen --package mocks github.com/ProtonMail/proton-bridge/pkg/pmapi Client > pkg/pmapi/mocks/mocks.go
lint: gofiles lint-golang lint-license lint-changelog
diff --git a/go.mod b/go.mod
index 9dabad08..621dfbe4 100644
--- a/go.mod
+++ b/go.mod
@@ -59,8 +59,6 @@ require (
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/stretchr/testify v1.6.1
github.com/therecipe/qt v0.0.0-20200701200531-7f61353ee73e
- github.com/therecipe/qt/internal/binding/files/docs/5.12.0 v0.0.0-20200904063919-c0c124a5770d // indirect
- github.com/therecipe/qt/internal/binding/files/docs/5.13.0 v0.0.0-20200904063919-c0c124a5770d // indirect
github.com/urfave/cli/v2 v2.2.0
github.com/vmihailenco/msgpack/v5 v5.1.3
go.etcd.io/bbolt v1.3.5
diff --git a/go.sum b/go.sum
index 93dea945..f96c74dd 100644
--- a/go.sum
+++ b/go.sum
@@ -262,12 +262,6 @@ github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/therecipe/qt v0.0.0-20200701200531-7f61353ee73e h1:G0DQ/TRQyrEZjtLlLwevFjaRiG8eeCMlq9WXQ2OO2bk=
github.com/therecipe/qt v0.0.0-20200701200531-7f61353ee73e/go.mod h1:SUUR2j3aE1z6/g76SdD6NwACEpvCxb3fvG82eKbD6us=
-github.com/therecipe/qt v0.0.0-20200904063919-c0c124a5770d h1:T+d8FnaLSvM/1BdlDXhW4d5dr2F07bAbB+LpgzMxx+o=
-github.com/therecipe/qt/internal/binding/files/docs v0.0.0-20191019224306-1097424d656c h1:/VhcwU7WuFEVgDHZ9V8PIYAyYqQ6KNxFUjBMOf2aFZM=
-github.com/therecipe/qt/internal/binding/files/docs/5.12.0 v0.0.0-20200904063919-c0c124a5770d h1:hAZyEG2swPRWjF0kqqdGERXUazYnRJdAk4a58f14z7Y=
-github.com/therecipe/qt/internal/binding/files/docs/5.12.0 v0.0.0-20200904063919-c0c124a5770d/go.mod h1:7m8PDYDEtEVqfjoUQc2UrFqhG0CDmoVJjRlQxexndFc=
-github.com/therecipe/qt/internal/binding/files/docs/5.13.0 v0.0.0-20200904063919-c0c124a5770d h1:AJRoBel/g9cDS+yE8BcN3E+TDD/xNAguG21aoR8DAIE=
-github.com/therecipe/qt/internal/binding/files/docs/5.13.0 v0.0.0-20200904063919-c0c124a5770d/go.mod h1:mH55Ek7AZcdns5KPp99O0bg+78el64YCYWHiQKrOdt4=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
diff --git a/internal/imap/backend.go b/internal/imap/backend.go
index 6e2d0a59..1cfcc4b6 100644
--- a/internal/imap/backend.go
+++ b/internal/imap/backend.go
@@ -26,10 +26,17 @@ import (
"github.com/ProtonMail/proton-bridge/internal/bridge"
"github.com/ProtonMail/proton-bridge/internal/events"
"github.com/ProtonMail/proton-bridge/pkg/listener"
+ "github.com/ProtonMail/proton-bridge/pkg/message"
"github.com/emersion/go-imap"
goIMAPBackend "github.com/emersion/go-imap/backend"
)
+const (
+ fetchWorkers = 20 // In how many workers to fetch message (group list on IMAP).
+ attachWorkers = 5 // In how many workers to fetch attachments (for one message).
+ buildWorkers = 20 // In how many workers to build messages.
+)
+
type panicHandler interface {
HandlePanic()
}
@@ -43,6 +50,8 @@ type imapBackend struct {
users map[string]*imapUser
usersLocker sync.Locker
+ builder *message.Builder
+
imapCache map[string]map[string]string
imapCachePath string
imapCacheLock *sync.RWMutex
@@ -78,6 +87,8 @@ func newIMAPBackend(
users: map[string]*imapUser{},
usersLocker: &sync.Mutex{},
+ builder: message.NewBuilder(fetchWorkers, attachWorkers, buildWorkers),
+
imapCachePath: cache.GetIMAPCachePath(),
imapCacheLock: &sync.RWMutex{},
}
diff --git a/internal/imap/imap.go b/internal/imap/imap.go
index 5845b71c..6d3e58c3 100644
--- a/internal/imap/imap.go
+++ b/internal/imap/imap.go
@@ -19,11 +19,4 @@ package imap
import "github.com/sirupsen/logrus"
-const (
- fetchMessagesWorkers = 5 // In how many workers to fetch message (group list on IMAP).
- fetchAttachmentsWorkers = 5 // In how many workers to fetch attachments (for one message).
-)
-
-var (
- log = logrus.WithField("pkg", "imap") //nolint[gochecknoglobals]
-)
+var log = logrus.WithField("pkg", "imap") //nolint[gochecknoglobals]
diff --git a/internal/imap/mailbox.go b/internal/imap/mailbox.go
index 8dc09ccb..e9df47f6 100644
--- a/internal/imap/mailbox.go
+++ b/internal/imap/mailbox.go
@@ -37,10 +37,12 @@ type imapMailbox struct {
storeUser storeUserProvider
storeAddress storeAddressProvider
storeMailbox storeMailboxProvider
+
+ builder *message.Builder
}
// newIMAPMailbox returns struct implementing go-imap/mailbox interface.
-func newIMAPMailbox(panicHandler panicHandler, user *imapUser, storeMailbox storeMailboxProvider) *imapMailbox {
+func newIMAPMailbox(panicHandler panicHandler, user *imapUser, storeMailbox storeMailboxProvider, builder *message.Builder) *imapMailbox {
return &imapMailbox{
panicHandler: panicHandler,
user: user,
@@ -54,6 +56,8 @@ func newIMAPMailbox(panicHandler panicHandler, user *imapUser, storeMailbox stor
storeUser: user.storeUser,
storeAddress: user.storeAddress,
storeMailbox: storeMailbox,
+
+ builder: builder,
}
}
diff --git a/internal/imap/mailbox_message.go b/internal/imap/mailbox_message.go
index 7564cf93..d52e8932 100644
--- a/internal/imap/mailbox_message.go
+++ b/internal/imap/mailbox_message.go
@@ -19,9 +19,9 @@ package imap
import (
"bytes"
+ "context"
"fmt"
"io"
- "mime/multipart"
"net/mail"
"net/textproto"
"sort"
@@ -32,12 +32,10 @@ import (
"github.com/ProtonMail/proton-bridge/internal/imap/cache"
"github.com/ProtonMail/proton-bridge/internal/imap/uidplus"
"github.com/ProtonMail/proton-bridge/pkg/message"
- "github.com/ProtonMail/proton-bridge/pkg/parallel"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/emersion/go-imap"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
- openpgperrors "golang.org/x/crypto/openpgp/errors"
)
var (
@@ -512,91 +510,6 @@ func (im *imapMailbox) fetchMessage(m *pmapi.Message) (err error) {
return
}
-func (im *imapMailbox) writeMessageBody(w io.Writer, m *pmapi.Message) (err error) {
- im.log.Trace("Writing message body")
-
- if m.Body == "" {
- im.log.Trace("While writing message body, noticed message body is null, need to fetch")
- if err = im.fetchMessage(m); err != nil {
- return
- }
- }
-
- kr, err := im.user.client().KeyRingForAddressID(m.AddressID)
- if err != nil {
- return errors.Wrap(err, "failed to get keyring for address ID")
- }
-
- err = message.WriteBody(w, kr, m)
- if err != nil {
- if customMessageErr := message.CustomMessage(m, err, true); customMessageErr != nil {
- im.log.WithError(customMessageErr).Warn("Failed to make custom message")
- }
- _, _ = io.WriteString(w, m.Body)
- err = nil
- }
-
- return
-}
-
-func (im *imapMailbox) writeAttachmentBody(w io.Writer, m *pmapi.Message, att *pmapi.Attachment) (err error) {
- // Retrieve encrypted attachment.
- r, err := im.user.client().GetAttachment(att.ID)
- if err != nil {
- return
- }
- defer r.Close() //nolint[errcheck]
-
- kr, err := im.user.client().KeyRingForAddressID(m.AddressID)
- if err != nil {
- return errors.Wrap(err, "failed to get keyring for address ID")
- }
-
- if err = message.WriteAttachmentBody(w, kr, m, att, r); err != nil {
- // Returning an error here makes certain mail clients behave badly,
- // trying to retrieve the message again and again.
- im.log.Warn("Cannot write attachment body: ", err)
- err = nil
- }
- return
-}
-
-func (im *imapMailbox) writeRelatedPart(p io.Writer, m *pmapi.Message, inlines []*pmapi.Attachment) (err error) {
- related := multipart.NewWriter(p)
-
- _ = related.SetBoundary(message.GetRelatedBoundary(m))
-
- buf := &bytes.Buffer{}
- if err = im.writeMessageBody(buf, m); err != nil {
- return
- }
-
- // Write the body part.
- h := message.GetBodyHeader(m)
-
- if p, err = related.CreatePart(h); err != nil {
- return
- }
-
- _, _ = buf.WriteTo(p)
-
- for _, inline := range inlines {
- buf = &bytes.Buffer{}
- if err = im.writeAttachmentBody(buf, m, inline); err != nil {
- return
- }
-
- h := message.GetAttachmentHeader(inline, true)
- if p, err = related.CreatePart(h); err != nil {
- return
- }
- _, _ = buf.WriteTo(p)
- }
-
- _ = related.Close()
- return nil
-}
-
const (
noMultipart = iota // only body
simpleMultipart // body + attachment or inline
@@ -634,179 +547,28 @@ func (im *imapMailbox) setMessageContentType(m *pmapi.Message) (multipartType in
}
// buildMessage from PM to IMAP.
-func (im *imapMailbox) buildMessage(m *pmapi.Message) (structure *message.BodyStructure, msgBody []byte, err error) {
- im.log.Trace("Building message")
-
- var errNoCache doNotCacheError
-
- // If fetch or decryption fails we need to change the MIMEType (in customMessage).
- err = im.fetchMessage(m)
+func (im *imapMailbox) buildMessage(m *pmapi.Message) (*message.BodyStructure, []byte, error) {
+ body, err := im.builder.NewJobWithOptions(
+ context.Background(),
+ im.user.client(),
+ m.ID,
+ message.JobOptions{
+ IgnoreDecryptionErrors: true, // Whether to ignore decryption errors and create a "custom message" instead.
+ SanitizeDate: true, // Whether to replace all dates before 1970 with RFC822's birthdate.
+ AddInternalID: true, // Whether to include MessageID as X-Pm-Internal-Id.
+ AddExternalID: true, // Whether to include ExternalID as X-Pm-External-Id.
+ AddMessageDate: true, // Whether to include message time as X-Pm-Date.
+ AddMessageIDReference: true, // Whether to include the MessageID in References.
+ },
+ ).GetResult()
if err != nil {
- return
- }
-
- kr, err := im.user.client().KeyRingForAddressID(m.AddressID)
- if err != nil {
- err = errors.Wrap(err, "failed to get keyring for address ID")
- return
- }
-
- errDecrypt := m.Decrypt(kr)
-
- if errDecrypt != nil && errDecrypt != openpgperrors.ErrSignatureExpired {
- errNoCache.add(errDecrypt)
- if customMessageErr := message.CustomMessage(m, errDecrypt, true); customMessageErr != nil {
- im.log.WithError(customMessageErr).Warn("Failed to make custom message")
- }
- }
-
- // Inner function can fail even when message is decrypted.
- // #1048 For example we have problem with double-encrypted messages
- // which seems as still encrypted and we try them to decrypt again
- // and that fails. For any building error is better to return custom
- // message than error because it will not be fixed and users would
- // get error message all the time and could not see some messages.
- structure, msgBody, err = im.buildMessageInner(m, kr)
- if err == pmapi.ErrAPINotReachable || err == pmapi.ErrInvalidToken || err == pmapi.ErrUpgradeApplication {
return nil, nil, err
- } else if err != nil {
- errNoCache.add(err)
- if customMessageErr := message.CustomMessage(m, err, true); customMessageErr != nil {
- im.log.WithError(customMessageErr).Warn("Failed to make custom message")
- }
- structure, msgBody, err = im.buildMessageInner(m, kr)
- if err != nil {
- return nil, nil, err
- }
}
- err = errNoCache.errorOrNil()
-
- return structure, msgBody, err
-}
-
-func (im *imapMailbox) buildMessageInner(m *pmapi.Message, kr *crypto.KeyRing) (structure *message.BodyStructure, msgBody []byte, err error) { // nolint[funlen]
- multipartType, err := im.setMessageContentType(m)
+ structure, err := message.NewBodyStructure(bytes.NewReader(body))
if err != nil {
- return
+ return nil, nil, err
}
- tmpBuf := &bytes.Buffer{}
- mainHeader := buildHeader(m)
- if err = writeHeader(tmpBuf, mainHeader); err != nil {
- return
- }
- _, _ = io.WriteString(tmpBuf, "\r\n")
-
- switch multipartType {
- case noMultipart:
- err = message.WriteBody(tmpBuf, kr, m)
- if err != nil {
- return
- }
- case complexMultipart:
- _, _ = io.WriteString(tmpBuf, "\r\n--"+message.GetBoundary(m)+"\r\n")
- err = message.WriteBody(tmpBuf, kr, m)
- if err != nil {
- return
- }
- _, _ = io.WriteString(tmpBuf, "\r\n--"+message.GetBoundary(m)+"--\r\n")
- case simpleMultipart:
- atts, inlines := message.SeparateInlineAttachments(m)
- mw := multipart.NewWriter(tmpBuf)
- _ = mw.SetBoundary(message.GetBoundary(m))
-
- var partWriter io.Writer
-
- if len(inlines) > 0 {
- relatedHeader := message.GetRelatedHeader(m)
- if partWriter, err = mw.CreatePart(relatedHeader); err != nil {
- return
- }
- _ = im.writeRelatedPart(partWriter, m, inlines)
- } else {
- buf := &bytes.Buffer{}
- if err = im.writeMessageBody(buf, m); err != nil {
- return
- }
-
- // Write the body part.
- bodyHeader := message.GetBodyHeader(m)
- if partWriter, err = mw.CreatePart(bodyHeader); err != nil {
- return
- }
-
- _, _ = buf.WriteTo(partWriter)
- }
-
- // Write the attachments parts.
- input := make([]interface{}, len(atts))
- for i, att := range atts {
- input[i] = att
- }
-
- processCallback := func(value interface{}) (interface{}, error) {
- att := value.(*pmapi.Attachment) //nolint[forcetypeassert] we want to panic here
-
- buf := &bytes.Buffer{}
- if err = im.writeAttachmentBody(buf, m, att); err != nil {
- return nil, err
- }
- return buf, nil
- }
-
- collectCallback := func(idx int, value interface{}) error {
- buf := value.(*bytes.Buffer) //nolint[forcetypeassert] we want to panic here
- defer buf.Reset()
- att := atts[idx]
-
- attachmentHeader := message.GetAttachmentHeader(att, true)
- if partWriter, err = mw.CreatePart(attachmentHeader); err != nil {
- return err
- }
-
- _, _ = buf.WriteTo(partWriter)
- return nil
- }
-
- err = parallel.RunParallel(fetchAttachmentsWorkers, input, processCallback, collectCallback)
- if err != nil {
- return
- }
-
- _ = mw.Close()
- default:
- fmt.Fprintf(tmpBuf, "\r\n\r\nUknown multipart type: %d\r\n\r\n", multipartType)
- }
-
- // We need to copy buffer before building body structure.
- msgBody = tmpBuf.Bytes()
- structure, err = message.NewBodyStructure(tmpBuf)
- if err != nil {
- // NOTE: We need to set structure if it fails and is empty.
- if structure == nil {
- structure = &message.BodyStructure{}
- }
- }
- return structure, msgBody, err
-}
-
-func buildHeader(msg *pmapi.Message) textproto.MIMEHeader {
- header := message.GetHeader(msg)
-
- msgTime := time.Unix(msg.Time, 0)
-
- // Apple Mail crashes fetching messages with date older than 1970.
- // There is no point having message older than RFC itself, it's not possible.
- d, err := msg.Header.Date()
- if err != nil || d.Before(rfc822Birthday) || msgTime.Before(rfc822Birthday) {
- if err != nil || d.IsZero() {
- header.Set("X-Original-Date", msgTime.Format(time.RFC1123Z))
- } else {
- header.Set("X-Original-Date", d.Format(time.RFC1123Z))
- }
- header.Set("Date", rfc822Birthday.Format(time.RFC1123Z))
- }
-
- return header
+ return structure, body, nil
}
diff --git a/internal/imap/mailbox_messages.go b/internal/imap/mailbox_messages.go
index 7680d730..1a26761a 100644
--- a/internal/imap/mailbox_messages.go
+++ b/internal/imap/mailbox_messages.go
@@ -575,7 +575,7 @@ func (im *imapMailbox) listMessages(isUID bool, seqSet *imap.SeqSet, items []ima
return nil
}
- err = parallel.RunParallel(fetchMessagesWorkers, input, processCallback, collectCallback)
+ err = parallel.RunParallel(fetchWorkers, input, processCallback, collectCallback)
if err != nil {
return err
}
diff --git a/internal/imap/user.go b/internal/imap/user.go
index 79d0d1fc..35c5518c 100644
--- a/internal/imap/user.go
+++ b/internal/imap/user.go
@@ -135,7 +135,7 @@ func (iu *imapUser) ListMailboxes(showOnlySubcribed bool) ([]goIMAPBackend.Mailb
if showOnlySubcribed && !iu.isSubscribed(storeMailbox.LabelID()) {
continue
}
- mailbox := newIMAPMailbox(iu.panicHandler, iu, storeMailbox)
+ mailbox := newIMAPMailbox(iu.panicHandler, iu, storeMailbox, iu.backend.builder)
mailboxes = append(mailboxes, mailbox)
}
@@ -167,7 +167,7 @@ func (iu *imapUser) GetMailbox(name string) (mb goIMAPBackend.Mailbox, err error
return
}
- return newIMAPMailbox(iu.panicHandler, iu, storeMailbox), nil
+ return newIMAPMailbox(iu.panicHandler, iu, storeMailbox, iu.backend.builder), nil
}
// CreateMailbox creates a new mailbox.
diff --git a/internal/transfer/provider_pmapi.go b/internal/transfer/provider_pmapi.go
index 202035ee..1d46129a 100644
--- a/internal/transfer/provider_pmapi.go
+++ b/internal/transfer/provider_pmapi.go
@@ -21,16 +21,24 @@ import (
"sort"
"github.com/ProtonMail/gopenpgp/v2/crypto"
+ "github.com/ProtonMail/proton-bridge/pkg/message"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/pkg/errors"
)
+const (
+ fetchWorkers = 20 // In how many workers to fetch message (group list on IMAP).
+ attachWorkers = 5 // In how many workers to fetch attachments (for one message).
+ buildWorkers = 20 // In how many workers to build messages.
+)
+
// PMAPIProvider implements import and export to/from ProtonMail server.
type PMAPIProvider struct {
clientManager ClientManager
userID string
addressID string
keyRing *crypto.KeyRing
+ builder *message.Builder
nextImportRequests map[string]*pmapi.ImportMsgReq // Key is msg transfer ID.
nextImportRequestsSize int
@@ -44,6 +52,7 @@ func NewPMAPIProvider(clientManager ClientManager, userID, addressID string) (*P
clientManager: clientManager,
userID: userID,
addressID: addressID,
+ builder: message.NewBuilder(fetchWorkers, attachWorkers, buildWorkers),
nextImportRequests: map[string]*pmapi.ImportMsgReq{},
nextImportRequestsSize: 0,
diff --git a/internal/transfer/provider_pmapi_source.go b/internal/transfer/provider_pmapi_source.go
index b1e65cd1..26cd7a5f 100644
--- a/internal/transfer/provider_pmapi_source.go
+++ b/internal/transfer/provider_pmapi_source.go
@@ -18,12 +18,13 @@
package transfer
import (
+ "context"
+ "errors"
"fmt"
"sync"
- pkgMsg "github.com/ProtonMail/proton-bridge/pkg/message"
+ "github.com/ProtonMail/proton-bridge/pkg/message"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
- "github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
@@ -144,6 +145,7 @@ func (p *PMAPIProvider) transferTo(rule *Rule, progress *Progress, ch chan<- Mes
func (p *PMAPIProvider) exportMessage(rule *Rule, progress *Progress, pmapiMsgID, msgID string, skipEncryptedMessages bool) (Message, error) {
var msg *pmapi.Message
+
progress.callWrap(func() error {
var err error
msg, err = p.getMessage(pmapiMsgID)
@@ -153,19 +155,18 @@ func (p *PMAPIProvider) exportMessage(rule *Rule, progress *Progress, pmapiMsgID
p.timeIt.start("build", msgID)
defer p.timeIt.stop("build", msgID)
- msgBuilder := pkgMsg.NewBuilder(p.client(), msg)
- msgBuilder.EncryptedToHTML = false
- _, body, err := msgBuilder.BuildMessage()
+ body, err := p.builder.NewJobWithOptions(
+ context.Background(),
+ p.client(),
+ msg.ID,
+ message.JobOptions{IgnoreDecryptionErrors: !skipEncryptedMessages},
+ ).GetResult()
if err != nil {
- return Message{
- Body: body, // Keep body to show details about the message to user.
- }, errors.Wrap(err, "failed to build message")
- }
+ if errors.Is(err, message.ErrDecryptionFailed) && skipEncryptedMessages {
+ err = errors.New("skipping encrypted message")
+ }
- if !msgBuilder.SuccessfullyDecrypted() && skipEncryptedMessages {
- return Message{
- Body: body, // Keep body to show details about the message to user.
- }, errors.New("skipping encrypted message")
+ return Message{Body: []byte(msg.Body)}, err
}
unread := false
diff --git a/internal/versioner/version_test.go b/internal/versioner/version_test.go
index 17cb8fb1..3e8cb768 100644
--- a/internal/versioner/version_test.go
+++ b/internal/versioner/version_test.go
@@ -27,6 +27,7 @@ import (
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/pkg/sum"
+ tests "github.com/ProtonMail/proton-bridge/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -68,7 +69,7 @@ func TestVerifyWithBadFile(t *testing.T) {
filepath.Join("sub", "f5.tgz"),
)
- badKeyRing := makeKeyRing(t)
+ badKeyRing := tests.MakeKeyRing(t)
signFile(t, filepath.Join(tempDir, "f3.bad"), badKeyRing)
assert.Error(t, version.VerifyFiles(kr))
@@ -91,14 +92,14 @@ func TestVerifyWithBadSubFile(t *testing.T) {
filepath.Join("sub", "f5.bad"),
)
- badKeyRing := makeKeyRing(t)
+ badKeyRing := tests.MakeKeyRing(t)
signFile(t, filepath.Join(tempDir, "sub", "f5.bad"), badKeyRing)
assert.Error(t, version.VerifyFiles(kr))
}
func createSignedFiles(t *testing.T, root string, paths ...string) *crypto.KeyRing {
- kr := makeKeyRing(t)
+ kr := tests.MakeKeyRing(t)
for _, path := range paths {
makeFile(t, filepath.Join(root, path))
@@ -118,16 +119,6 @@ func createSignedFiles(t *testing.T, root string, paths ...string) *crypto.KeyRi
return kr
}
-func makeKeyRing(t *testing.T) *crypto.KeyRing {
- key, err := crypto.GenerateKey("name", "email", "rsa", 2048)
- require.NoError(t, err)
-
- kr, err := crypto.NewKeyRing(key)
- require.NoError(t, err)
-
- return kr
-}
-
func makeFile(t *testing.T, path string) {
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700))
diff --git a/pkg/message/address.go b/pkg/message/address.go
index b34c018c..f18c856f 100644
--- a/pkg/message/address.go
+++ b/pkg/message/address.go
@@ -44,13 +44,3 @@ func getAddresses(addrs []*mail.Address) (imapAddrs []*imap.Address) {
return
}
-
-func formatAddressList(addrs []*mail.Address) (s string) {
- for i, addr := range addrs {
- if i > 0 {
- s += ", "
- }
- s += addr.String()
- }
- return
-}
diff --git a/pkg/message/body.go b/pkg/message/body.go
deleted file mode 100644
index bb40b87b..00000000
--- a/pkg/message/body.go
+++ /dev/null
@@ -1,78 +0,0 @@
-// Copyright (c) 2021 Proton Technologies AG
-//
-// This file is part of ProtonMail Bridge.
-//
-// ProtonMail Bridge is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// ProtonMail Bridge is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with ProtonMail Bridge. If not, see .
-
-package message
-
-import (
- "encoding/base64"
- "fmt"
- "io"
- "mime/quotedprintable"
-
- "github.com/ProtonMail/gopenpgp/v2/crypto"
- "github.com/ProtonMail/proton-bridge/pkg/pmapi"
- "github.com/emersion/go-textwrapper"
- openpgperrors "golang.org/x/crypto/openpgp/errors"
-)
-
-func WriteBody(w io.Writer, kr *crypto.KeyRing, m *pmapi.Message) error {
- // Decrypt body.
- if err := m.Decrypt(kr); err != nil && err != openpgperrors.ErrSignatureExpired {
- return err
- }
- if m.MIMEType != pmapi.ContentTypeMultipartMixed {
- // Encode it.
- qp := quotedprintable.NewWriter(w)
- if _, err := io.WriteString(qp, m.Body); err != nil {
- return err
- }
- return qp.Close()
- }
- _, err := io.WriteString(w, m.Body)
- return err
-}
-
-func WriteAttachmentBody(w io.Writer, kr *crypto.KeyRing, m *pmapi.Message, att *pmapi.Attachment, r io.Reader) (err error) {
- // Decrypt it
- var dr io.Reader
- dr, err = att.Decrypt(r, kr)
- if err == openpgperrors.ErrKeyIncorrect {
- err = nil //nolint[wastedassing] Do not fail if attachment is encrypted with a different key.
- dr = r
- att.Name += ".gpg"
- att.MIMEType = "application/pgp-encrypted" //nolint
- } else if err != nil && err != openpgperrors.ErrSignatureExpired {
- return fmt.Errorf("cannot decrypt attachment: %v", err)
- }
-
- // Don't encode message/rfc822 attachments; they should be embedded and preserved.
- if att.MIMEType == rfc822Message {
- if n, err := io.Copy(w, dr); err != nil {
- return fmt.Errorf("cannot write attached message: %v (wrote %v bytes)", err, n)
- }
- return nil
- }
-
- // Encode it.
- ww := textwrapper.NewRFC822(w)
- bw := base64.NewEncoder(base64.StdEncoding, ww)
-
- if n, err := io.Copy(bw, dr); err != nil {
- return fmt.Errorf("cannot write attachment: %v (wrote %v bytes)", err, n)
- }
- return bw.Close()
-}
diff --git a/pkg/message/build.go b/pkg/message/build.go
index 0740a238..c141cd00 100644
--- a/pkg/message/build.go
+++ b/pkg/message/build.go
@@ -18,328 +18,126 @@
package message
import (
- "bytes"
- "encoding/base64"
- "fmt"
+ "context"
"io"
- "io/ioutil"
- "mime/multipart"
- "mime/quotedprintable"
- "net/textproto"
+ "sync"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
- "github.com/emersion/go-textwrapper"
- openpgperrors "golang.org/x/crypto/openpgp/errors"
+ "github.com/pkg/errors"
+)
+
+var (
+ ErrDecryptionFailed = errors.New("message could not be decrypted")
+ ErrNoSuchKeyRing = errors.New("the keyring to decrypt this message could not be found")
)
-// Builder for converting PM message to RFC822. Builder will directly write
-// changes to message when fetching or building message.
type Builder struct {
- cl pmapi.Client
- msg *pmapi.Message
-
- EncryptedToHTML bool
- successfullyDecrypted bool
+ reqs chan fetchReq
+ done chan struct{}
+ jobs map[string]*BuildJob
+ locker sync.Mutex
}
-// NewBuilder initiated with client and message meta info.
-func NewBuilder(client pmapi.Client, message *pmapi.Message) *Builder {
- return &Builder{cl: client, msg: message, EncryptedToHTML: true, successfullyDecrypted: false}
+type Fetcher interface {
+ GetMessage(string) (*pmapi.Message, error)
+ GetAttachment(string) (io.ReadCloser, error)
+ KeyRingForAddressID(string) (*crypto.KeyRing, error)
}
-// fetchMessage will update original PM message if successful.
-func (bld *Builder) fetchMessage() (err error) {
- if bld.msg.Body != "" {
- return nil
- }
+func NewBuilder(fetchWorkers, attachWorkers, buildWorkers int) *Builder {
+ b := newBuilder()
- complete, err := bld.cl.GetMessage(bld.msg.ID)
- if err != nil {
- return
- }
+ fetchReqCh, fetchResCh := startFetchWorkers(fetchWorkers, attachWorkers)
+ buildReqCh, buildResCh := startBuildWorkers(buildWorkers)
- *bld.msg = *complete
+ go func() {
+ defer close(fetchReqCh)
- return
-}
+ for {
+ select {
+ case req := <-b.reqs:
+ fetchReqCh <- req
-func (bld *Builder) writeMessageBody(w io.Writer) error {
- if err := bld.fetchMessage(); err != nil {
- return err
- }
-
- err := bld.WriteBody(w)
- if err != nil {
- _, _ = io.WriteString(w, "\r\n")
- if bld.EncryptedToHTML {
- _ = CustomMessage(bld.msg, err, true)
- }
- _, err = io.WriteString(w, bld.msg.Body)
- _, _ = io.WriteString(w, "\r\n")
- }
-
- return err
-}
-
-func (bld *Builder) writeAttachmentBody(w io.Writer, att *pmapi.Attachment) error {
- // Retrieve encrypted attachment
- r, err := bld.cl.GetAttachment(att.ID)
- if err != nil {
- return err
- }
- defer r.Close() //nolint[errcheck]
-
- if err := bld.WriteAttachmentBody(w, att, r); err != nil {
- // Returning an error here makes e-mail clients like Thunderbird behave
- // badly, trying to retrieve the message again and again
- log.Warnln("Cannot write attachment body:", err)
- }
- return nil
-}
-
-func (bld *Builder) writeRelatedPart(p io.Writer, inlines []*pmapi.Attachment) error {
- related := multipart.NewWriter(p)
-
- _ = related.SetBoundary(GetRelatedBoundary(bld.msg))
-
- buf := &bytes.Buffer{}
- if err := bld.writeMessageBody(buf); err != nil {
- return err
- }
-
- // Write the body part
- h := GetBodyHeader(bld.msg)
-
- var err error
- if p, err = related.CreatePart(h); err != nil {
- return err
- }
-
- _, _ = buf.WriteTo(p)
-
- for _, inline := range inlines {
- buf = &bytes.Buffer{}
- if err = bld.writeAttachmentBody(buf, inline); err != nil {
- return err
- }
-
- h := GetAttachmentHeader(inline, false)
- if p, err = related.CreatePart(h); err != nil {
- return err
- }
- _, _ = buf.WriteTo(p)
- }
-
- _ = related.Close()
- return nil
-}
-
-// BuildMessage converts PM message to body structure (not RFC3501) and bytes
-// of RC822 message. If successful the original PM message will contain decrypted body.
-func (bld *Builder) BuildMessage() (structure *BodyStructure, message []byte, err error) { //nolint[funlen]
- if err = bld.fetchMessage(); err != nil {
- return nil, nil, err
- }
-
- bodyBuf := &bytes.Buffer{}
-
- mainHeader := GetHeader(bld.msg)
- mainHeader.Set("Content-Type", "multipart/mixed; boundary="+GetBoundary(bld.msg))
- if err = WriteHeader(bodyBuf, mainHeader); err != nil {
- return nil, nil, err
- }
- _, _ = io.WriteString(bodyBuf, "\r\n")
-
- // NOTE: Do we really need extra encapsulation? i.e. Bridge-IMAP message is always multipart/mixed
-
- if bld.msg.MIMEType == pmapi.ContentTypeMultipartMixed {
- _, _ = io.WriteString(bodyBuf, "\r\n--"+GetBoundary(bld.msg)+"\r\n")
- if err = bld.writeMessageBody(bodyBuf); err != nil {
- return nil, nil, err
- }
- _, _ = io.WriteString(bodyBuf, "\r\n--"+GetBoundary(bld.msg)+"--\r\n")
- } else {
- mw := multipart.NewWriter(bodyBuf)
- _ = mw.SetBoundary(GetBoundary(bld.msg))
-
- var partWriter io.Writer
- atts, inlines := SeparateInlineAttachments(bld.msg)
-
- if len(inlines) > 0 {
- relatedHeader := GetRelatedHeader(bld.msg)
- if partWriter, err = mw.CreatePart(relatedHeader); err != nil {
- return nil, nil, err
+ case <-b.done:
+ return
}
- _ = bld.writeRelatedPart(partWriter, inlines)
- } else {
- buf := &bytes.Buffer{}
- if err = bld.writeMessageBody(buf); err != nil {
- return nil, nil, err
- }
-
- // Write the body part
- bodyHeader := GetBodyHeader(bld.msg)
- if partWriter, err = mw.CreatePart(bodyHeader); err != nil {
- return nil, nil, err
- }
-
- _, _ = buf.WriteTo(partWriter)
}
+ }()
- // Write the attachments parts
- for _, att := range atts {
- buf := &bytes.Buffer{}
- if err = bld.writeAttachmentBody(buf, att); err != nil {
- return nil, nil, err
+ go func() {
+ defer close(buildReqCh)
+
+ for res := range fetchResCh {
+ if res.err != nil {
+ b.jobFailure(res.messageID, res.err)
+ } else {
+ buildReqCh <- res
}
-
- attachmentHeader := GetAttachmentHeader(att, false)
- if partWriter, err = mw.CreatePart(attachmentHeader); err != nil {
- return nil, nil, err
- }
-
- _, _ = buf.WriteTo(partWriter)
}
+ }()
- _ = mw.Close()
- }
+ go func() {
+ for res := range buildResCh {
+ if res.err != nil {
+ b.jobFailure(res.messageID, res.err)
+ } else {
+ b.jobSuccess(res.messageID, res.literal)
+ }
+ }
+ }()
- // wee need to copy buffer before building body structure
- message = bodyBuf.Bytes()
- structure, err = NewBodyStructure(bodyBuf)
- return structure, message, err
+ return b
}
-// SuccessfullyDecrypted is true when message was fetched and decrypted successfully.
-func (bld *Builder) SuccessfullyDecrypted() bool { return bld.successfullyDecrypted }
-
-// WriteBody decrypts PM message and writes main body section. The external PGP
-// message is written as is (including attachments).
-func (bld *Builder) WriteBody(w io.Writer) error {
- kr, err := bld.cl.KeyRingForAddressID(bld.msg.AddressID)
- if err != nil {
- return err
+func newBuilder() *Builder {
+ return &Builder{
+ reqs: make(chan fetchReq),
+ done: make(chan struct{}),
+ jobs: make(map[string]*BuildJob),
}
- // decrypt body
- if err := bld.msg.Decrypt(kr); err != nil && err != openpgperrors.ErrSignatureExpired {
- return err
- }
- bld.successfullyDecrypted = true
- if bld.msg.MIMEType != pmapi.ContentTypeMultipartMixed {
- // transfer encoding
- qp := quotedprintable.NewWriter(w)
- if _, err := io.WriteString(qp, bld.msg.Body); err != nil {
- return err
- }
- return qp.Close()
- }
- _, err = io.WriteString(w, bld.msg.Body)
- return err
}
-// WriteAttachmentBody decrypts and writes the attachments.
-func (bld *Builder) WriteAttachmentBody(w io.Writer, att *pmapi.Attachment, attReader io.Reader) (err error) {
- kr, err := bld.cl.KeyRingForAddressID(bld.msg.AddressID)
- if err != nil {
- return err
- }
- // Decrypt it
- var dr io.Reader
- dr, err = att.Decrypt(attReader, kr)
- if err == openpgperrors.ErrKeyIncorrect {
- err = nil //nolint[wastedasign] Do not fail if attachment is encrypted with a different key
- dr = attReader
- att.Name += ".gpg"
- att.MIMEType = "application/pgp-encrypted"
- } else if err != nil && err != openpgperrors.ErrSignatureExpired {
- err = fmt.Errorf("cannot decrypt attachment: %v", err)
- return err
- }
-
- // transfer encoding
- ww := textwrapper.NewRFC822(w)
- bw := base64.NewEncoder(base64.StdEncoding, ww)
-
- var n int64
- if n, err = io.Copy(bw, dr); err != nil {
- err = fmt.Errorf("cannot write attachment: %v (wrote %v bytes)", err, n)
- }
-
- _ = bw.Close()
- return err
+func (b *Builder) NewJob(ctx context.Context, api Fetcher, messageID string) *BuildJob {
+ return b.NewJobWithOptions(ctx, api, messageID, JobOptions{})
}
-func BuildEncrypted(m *pmapi.Message, readers []io.Reader, kr *crypto.KeyRing) ([]byte, error) { //nolint[funlen]
- b := &bytes.Buffer{}
+func (b *Builder) NewJobWithOptions(ctx context.Context, api Fetcher, messageID string, opts JobOptions) *BuildJob {
+ b.locker.Lock()
+ defer b.locker.Unlock()
- // Overwrite content for main header for import.
- // Even if message has just simple body we should upload as multipart/mixed.
- // Each part has encrypted body and header reflects the original header.
- mainHeader := GetHeader(m)
- mainHeader.Set("Content-Type", "multipart/mixed; boundary="+GetBoundary(m))
- mainHeader.Del("Content-Disposition")
- mainHeader.Del("Content-Transfer-Encoding")
- if err := WriteHeader(b, mainHeader); err != nil {
- return nil, err
- }
- mw := multipart.NewWriter(b)
- if err := mw.SetBoundary(GetBoundary(m)); err != nil {
- return nil, err
+ if job, ok := b.jobs[messageID]; ok {
+ return job
}
- // Write the body part.
- bodyHeader := make(textproto.MIMEHeader)
- bodyHeader.Set("Content-Type", m.MIMEType+"; charset=utf-8")
- bodyHeader.Set("Content-Disposition", "inline")
- bodyHeader.Set("Content-Transfer-Encoding", "7bit")
+ b.jobs[messageID] = newBuildJob(messageID)
- p, err := mw.CreatePart(bodyHeader)
- if err != nil {
- return nil, err
- }
- // First, encrypt the message body.
- if err := m.Encrypt(kr, kr); err != nil {
- return nil, err
- }
- if _, err := io.WriteString(p, m.Body); err != nil {
- return nil, err
- }
+ go func() { b.reqs <- fetchReq{ctx: ctx, api: api, messageID: messageID, opts: opts} }()
- // Write the attachments parts.
- for i := 0; i < len(m.Attachments); i++ {
- att := m.Attachments[i]
- r := readers[i]
- h := GetAttachmentHeader(att, false)
- p, err := mw.CreatePart(h)
- if err != nil {
- return nil, err
- }
-
- data, err := ioutil.ReadAll(r)
- if err != nil {
- return nil, err
- }
-
- // Create encrypted writer.
- pgpMessage, err := kr.Encrypt(crypto.NewPlainMessage(data), nil)
- if err != nil {
- return nil, err
- }
-
- ww := textwrapper.NewRFC822(p)
- bw := base64.NewEncoder(base64.StdEncoding, ww)
- if _, err := bw.Write(pgpMessage.GetBinary()); err != nil {
- return nil, err
- }
- if err := bw.Close(); err != nil {
- return nil, err
- }
- }
-
- if err := mw.Close(); err != nil {
- return nil, err
- }
-
- return b.Bytes(), nil
+ return b.jobs[messageID]
+}
+
+func (b *Builder) Done() {
+ b.locker.Lock()
+ defer b.locker.Unlock()
+
+ close(b.done)
+}
+
+func (b *Builder) jobSuccess(messageID string, literal []byte) {
+ b.locker.Lock()
+ defer b.locker.Unlock()
+
+ b.jobs[messageID].postSuccess(literal)
+
+ delete(b.jobs, messageID)
+}
+
+func (b *Builder) jobFailure(messageID string, err error) {
+ b.locker.Lock()
+ defer b.locker.Unlock()
+
+ b.jobs[messageID].postFailure(err)
+
+ delete(b.jobs, messageID)
}
diff --git a/pkg/message/build_boundary.go b/pkg/message/build_boundary.go
new file mode 100644
index 00000000..3844dae6
--- /dev/null
+++ b/pkg/message/build_boundary.go
@@ -0,0 +1,43 @@
+// Copyright (c) 2021 Proton Technologies AG
+//
+// This file is part of ProtonMail Bridge.
+//
+// ProtonMail Bridge is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// ProtonMail Bridge is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with ProtonMail Bridge. If not, see .
+
+package message
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+)
+
+type boundary struct {
+ val string
+}
+
+func newBoundary(seed string) *boundary {
+ return &boundary{val: seed}
+}
+
+func (bw *boundary) gen() string {
+ hash := sha256.New()
+
+ if _, err := hash.Write([]byte(bw.val)); err != nil {
+ panic(err)
+ }
+
+ bw.val = hex.EncodeToString(hash.Sum(nil))
+
+ return bw.val
+}
diff --git a/pkg/message/build_build.go b/pkg/message/build_build.go
new file mode 100644
index 00000000..9639c7cb
--- /dev/null
+++ b/pkg/message/build_build.go
@@ -0,0 +1,79 @@
+// Copyright (c) 2021 Proton Technologies AG
+//
+// This file is part of ProtonMail Bridge.
+//
+// ProtonMail Bridge is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// ProtonMail Bridge is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with ProtonMail Bridge. If not, see .
+
+package message
+
+import (
+ "sync"
+
+ "github.com/pkg/errors"
+)
+
+type buildRes struct {
+ messageID string
+ literal []byte
+ err error
+}
+
+func newBuildResSuccess(messageID string, literal []byte) buildRes {
+ return buildRes{
+ messageID: messageID,
+ literal: literal,
+ }
+}
+
+func newBuildResFailure(messageID string, err error) buildRes {
+ return buildRes{
+ messageID: messageID,
+ err: err,
+ }
+}
+
+func startBuildWorkers(buildWorkers int) (chan fetchRes, chan buildRes) {
+ buildReqCh := make(chan fetchRes)
+ buildResCh := make(chan buildRes)
+
+ go func() {
+ defer close(buildResCh)
+
+ var wg sync.WaitGroup
+
+ wg.Add(buildWorkers)
+
+ for workerID := 0; workerID < buildWorkers; workerID++ {
+ go buildWorker(buildReqCh, buildResCh, &wg)
+ }
+
+ wg.Wait()
+ }()
+
+ return buildReqCh, buildResCh
+}
+
+func buildWorker(buildReqCh <-chan fetchRes, buildResCh chan<- buildRes, wg *sync.WaitGroup) {
+ defer wg.Done()
+
+ for req := range buildReqCh {
+ if kr, err := req.api.KeyRingForAddressID(req.msg.AddressID); err != nil {
+ buildResCh <- newBuildResFailure(req.msg.ID, errors.Wrap(ErrNoSuchKeyRing, err.Error()))
+ } else if literal, err := buildRFC822(kr, req.msg, req.atts, req.opts); err != nil {
+ buildResCh <- newBuildResFailure(req.msg.ID, err)
+ } else {
+ buildResCh <- newBuildResSuccess(req.msg.ID, literal)
+ }
+ }
+}
diff --git a/pkg/message/build_encrypted.go b/pkg/message/build_encrypted.go
new file mode 100644
index 00000000..8adfedee
--- /dev/null
+++ b/pkg/message/build_encrypted.go
@@ -0,0 +1,114 @@
+// Copyright (c) 2021 Proton Technologies AG
+//
+// This file is part of ProtonMail Bridge.
+//
+// ProtonMail Bridge is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// ProtonMail Bridge is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with ProtonMail Bridge. If not, see .
+
+package message
+
+import (
+ "bytes"
+ "encoding/base64"
+ "io"
+ "io/ioutil"
+ "mime/multipart"
+ "net/http"
+ "net/textproto"
+
+ "github.com/ProtonMail/gopenpgp/v2/crypto"
+ "github.com/ProtonMail/proton-bridge/pkg/pmapi"
+ "github.com/emersion/go-textwrapper"
+)
+
+func BuildEncrypted(m *pmapi.Message, readers []io.Reader, kr *crypto.KeyRing) ([]byte, error) { //nolint[funlen]
+ b := &bytes.Buffer{}
+
+ // Overwrite content for main header for import.
+ // Even if message has just simple body we should upload as multipart/mixed.
+ // Each part has encrypted body and header reflects the original header.
+ mainHeader := GetHeader(m)
+ mainHeader.Set("Content-Type", "multipart/mixed; boundary="+GetBoundary(m))
+ mainHeader.Del("Content-Disposition")
+ mainHeader.Del("Content-Transfer-Encoding")
+ if err := WriteHeader(b, mainHeader); err != nil {
+ return nil, err
+ }
+ mw := multipart.NewWriter(b)
+ if err := mw.SetBoundary(GetBoundary(m)); err != nil {
+ return nil, err
+ }
+
+ // Write the body part.
+ bodyHeader := make(textproto.MIMEHeader)
+ bodyHeader.Set("Content-Type", m.MIMEType+"; charset=utf-8")
+ bodyHeader.Set("Content-Disposition", pmapi.DispositionInline)
+ bodyHeader.Set("Content-Transfer-Encoding", "7bit")
+
+ p, err := mw.CreatePart(bodyHeader)
+ if err != nil {
+ return nil, err
+ }
+ // First, encrypt the message body.
+ if err := m.Encrypt(kr, kr); err != nil {
+ return nil, err
+ }
+ if _, err := io.WriteString(p, m.Body); err != nil {
+ return nil, err
+ }
+
+ // Write the attachments parts.
+ for i := 0; i < len(m.Attachments); i++ {
+ att := m.Attachments[i]
+ r := readers[i]
+ h := GetAttachmentHeader(att, false)
+ p, err := mw.CreatePart(h)
+ if err != nil {
+ return nil, err
+ }
+
+ data, err := ioutil.ReadAll(r)
+ if err != nil {
+ return nil, err
+ }
+
+ // Create encrypted writer.
+ pgpMessage, err := kr.Encrypt(crypto.NewPlainMessage(data), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ ww := textwrapper.NewRFC822(p)
+ bw := base64.NewEncoder(base64.StdEncoding, ww)
+ if _, err := bw.Write(pgpMessage.GetBinary()); err != nil {
+ return nil, err
+ }
+ if err := bw.Close(); err != nil {
+ return nil, err
+ }
+ }
+
+ if err := mw.Close(); err != nil {
+ return nil, err
+ }
+
+ return b.Bytes(), nil
+}
+
+func WriteHeader(w io.Writer, h textproto.MIMEHeader) (err error) {
+ if err = http.Header(h).Write(w); err != nil {
+ return
+ }
+ _, err = io.WriteString(w, "\r\n")
+ return
+}
diff --git a/pkg/message/build_fetch.go b/pkg/message/build_fetch.go
new file mode 100644
index 00000000..7807be92
--- /dev/null
+++ b/pkg/message/build_fetch.go
@@ -0,0 +1,135 @@
+// Copyright (c) 2021 Proton Technologies AG
+//
+// This file is part of ProtonMail Bridge.
+//
+// ProtonMail Bridge is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// ProtonMail Bridge is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with ProtonMail Bridge. If not, see .
+
+package message
+
+import (
+ "context"
+ "io/ioutil"
+ "sync"
+
+ "github.com/ProtonMail/proton-bridge/pkg/parallel"
+ "github.com/ProtonMail/proton-bridge/pkg/pmapi"
+)
+
+type fetchReq struct {
+ ctx context.Context
+ api Fetcher
+ messageID string
+ opts JobOptions
+}
+
+type fetchRes struct {
+ fetchReq
+
+ msg *pmapi.Message
+ atts [][]byte
+ err error
+}
+
+func newFetchResSuccess(req fetchReq, msg *pmapi.Message, atts [][]byte) fetchRes {
+ return fetchRes{
+ fetchReq: req,
+ msg: msg,
+ atts: atts,
+ }
+}
+
+func newFetchResFailure(req fetchReq, err error) fetchRes {
+ return fetchRes{
+ fetchReq: req,
+ err: err,
+ }
+}
+
+func startFetchWorkers(fetchWorkers, attachWorkers int) (chan fetchReq, chan fetchRes) {
+ fetchReqCh := make(chan fetchReq)
+ fetchResCh := make(chan fetchRes)
+
+ go func() {
+ defer close(fetchResCh)
+
+ var wg sync.WaitGroup
+
+ wg.Add(fetchWorkers)
+
+ for workerID := 0; workerID < fetchWorkers; workerID++ {
+ go fetchWorker(fetchReqCh, fetchResCh, attachWorkers, &wg)
+ }
+
+ wg.Wait()
+ }()
+
+ return fetchReqCh, fetchResCh
+}
+
+func fetchWorker(fetchReqCh <-chan fetchReq, fetchResCh chan<- fetchRes, attachWorkers int, wg *sync.WaitGroup) {
+ defer wg.Done()
+
+ for req := range fetchReqCh {
+ msg, atts, err := fetchMessage(req, attachWorkers)
+ if err != nil {
+ fetchResCh <- newFetchResFailure(req, err)
+ } else {
+ fetchResCh <- newFetchResSuccess(req, msg, atts)
+ }
+ }
+}
+
+func fetchMessage(req fetchReq, attachWorkers int) (*pmapi.Message, [][]byte, error) {
+ msg, err := req.api.GetMessage(req.messageID)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ attList := make([]interface{}, len(msg.Attachments))
+
+ for i, att := range msg.Attachments {
+ attList[i] = att.ID
+ }
+
+ process := func(value interface{}) (interface{}, error) {
+ rc, err := req.api.GetAttachment(value.(string))
+ if err != nil {
+ return nil, err
+ }
+
+ b, err := ioutil.ReadAll(rc)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := rc.Close(); err != nil {
+ return nil, err
+ }
+
+ return b, nil
+ }
+
+ attData := make([][]byte, len(msg.Attachments))
+
+ collect := func(idx int, value interface{}) error {
+ attData[idx] = value.([]byte)
+ return nil
+ }
+
+ if err := parallel.RunParallel(attachWorkers, attList, process, collect); err != nil {
+ return nil, nil, err
+ }
+
+ return msg, attData, nil
+}
diff --git a/pkg/message/build_framework_test.go b/pkg/message/build_framework_test.go
new file mode 100644
index 00000000..bcb43fc3
--- /dev/null
+++ b/pkg/message/build_framework_test.go
@@ -0,0 +1,331 @@
+// Copyright (c) 2021 Proton Technologies AG
+//
+// This file is part of ProtonMail Bridge.
+//
+// ProtonMail Bridge is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// ProtonMail Bridge is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with ProtonMail Bridge. If not, see .
+
+package message
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/base64"
+ "io"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/ProtonMail/gopenpgp/v2/crypto"
+ "github.com/ProtonMail/proton-bridge/pkg/message/mocks"
+ "github.com/ProtonMail/proton-bridge/pkg/message/parser"
+ "github.com/ProtonMail/proton-bridge/pkg/pmapi"
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestFetcher(
+ m *gomock.Controller,
+ kr *crypto.KeyRing,
+ msg *pmapi.Message,
+ attData ...[]byte,
+) Fetcher {
+ f := mocks.NewMockFetcher(m)
+
+ f.EXPECT().GetMessage(msg.ID).Return(msg, nil)
+
+ for i, att := range msg.Attachments {
+ f.EXPECT().GetAttachment(att.ID).Return(newTestReadCloser(attData[i]), nil)
+ }
+
+ f.EXPECT().KeyRingForAddressID(msg.AddressID).Return(kr, nil)
+
+ return f
+}
+
+func newTestMessage(
+ t *testing.T,
+ kr *crypto.KeyRing,
+ messageID, addressID, mimeType, body string, // nolint[unparam]
+ date time.Time,
+) *pmapi.Message {
+ enc, err := kr.Encrypt(crypto.NewPlainMessageFromString(body), kr)
+ require.NoError(t, err)
+
+ arm, err := enc.GetArmored()
+ require.NoError(t, err)
+
+ return &pmapi.Message{
+ ID: messageID,
+ AddressID: addressID,
+ MIMEType: mimeType,
+ Header: map[string][]string{
+ "Content-Type": {mimeType},
+ "Date": {date.In(time.UTC).Format(time.RFC1123Z)},
+ },
+ Body: arm,
+ Time: date.Unix(),
+ }
+}
+
+func addTestAttachment(
+ t *testing.T,
+ kr *crypto.KeyRing,
+ msg *pmapi.Message,
+ attachmentID, name, mimeType, disposition, data string,
+) []byte {
+ enc, err := kr.EncryptAttachment(crypto.NewPlainMessageFromString(data), attachmentID+".bin")
+ require.NoError(t, err)
+
+ msg.Attachments = append(msg.Attachments, &pmapi.Attachment{
+ ID: attachmentID,
+ Name: name,
+ MIMEType: mimeType,
+ Header: map[string][]string{
+ "Content-Type": {mimeType},
+ "Content-Disposition": {disposition},
+ },
+ Disposition: disposition,
+ KeyPackets: base64.StdEncoding.EncodeToString(enc.GetBinaryKeyPacket()),
+ })
+
+ return enc.GetBinaryDataPacket()
+}
+
+type testReadCloser struct {
+ io.Reader
+}
+
+func newTestReadCloser(b []byte) *testReadCloser {
+ return &testReadCloser{Reader: bytes.NewReader(b)}
+}
+
+func (testReadCloser) Close() error {
+ return nil
+}
+
+type testSection struct {
+ t *testing.T
+ part *parser.Part
+ raw []byte
+}
+
+// NOTE: Each section is parsed individually --> cleaner test code but slower... improve this one day?
+func section(t *testing.T, b []byte, section ...int) *testSection {
+ p, err := parser.New(bytes.NewReader(b))
+ assert.NoError(t, err)
+
+ part, err := p.Section(section)
+ require.NoError(t, err)
+
+ bs, err := NewBodyStructure(bytes.NewReader(b))
+ require.NoError(t, err)
+
+ raw, err := bs.GetSection(bytes.NewReader(b), append([]int{}, section...))
+ require.NoError(t, err)
+
+ return &testSection{
+ t: t,
+ part: part,
+ raw: raw,
+ }
+}
+
+func (s *testSection) expectBody(wantBody matcher) *testSection {
+ wantBody.match(s.t, string(s.part.Body))
+
+ return s
+}
+
+func (s *testSection) expectSection(wantSection matcher) *testSection { // nolint[unparam]
+ wantSection.match(s.t, string(s.raw))
+
+ return s
+}
+
+func (s *testSection) expectContentType(wantContentType matcher) *testSection {
+ mimeType, _, err := s.part.Header.ContentType()
+ require.NoError(s.t, err)
+
+ wantContentType.match(s.t, mimeType)
+
+ return s
+}
+
+func (s *testSection) expectContentTypeParam(key string, wantParam matcher) *testSection { // nolint[unparam]
+ _, params, err := s.part.Header.ContentType()
+ require.NoError(s.t, err)
+
+ wantParam.match(s.t, params[key])
+
+ return s
+}
+
+func (s *testSection) expectContentDisposition(wantDisposition matcher) *testSection {
+ disposition, _, err := s.part.Header.ContentDisposition()
+ require.NoError(s.t, err)
+
+ wantDisposition.match(s.t, disposition)
+
+ return s
+}
+
+func (s *testSection) expectContentDispositionParam(key string, wantParam matcher) *testSection { // nolint[unparam]
+ _, params, err := s.part.Header.ContentDisposition()
+ require.NoError(s.t, err)
+
+ wantParam.match(s.t, params[key])
+
+ return s
+}
+
+func (s *testSection) expectTransferEncoding(wantTransferEncoding matcher) *testSection {
+ wantTransferEncoding.match(s.t, s.part.Header.Get("Content-Transfer-Encoding"))
+
+ return s
+}
+
+func (s *testSection) expectDate(wantDate matcher) *testSection {
+ wantDate.match(s.t, s.part.Header.Get("Date"))
+
+ return s
+}
+
+func (s *testSection) expectHeader(key string, wantValue matcher) *testSection {
+ wantValue.match(s.t, s.part.Header.Get(key))
+
+ return s
+}
+
+func (s *testSection) expectDecodedHeader(key string, wantValue matcher) *testSection { // nolint[unparam]
+ dec, err := s.part.Header.Text(key)
+ require.NoError(s.t, err)
+
+ wantValue.match(s.t, dec)
+
+ return s
+}
+
+func (s *testSection) pubKey() *crypto.KeyRing {
+ key, err := crypto.NewKeyFromArmored(string(s.part.Body))
+ require.NoError(s.t, err)
+
+ kr, err := crypto.NewKeyRing(key)
+ require.NoError(s.t, err)
+
+ return kr
+}
+
+func (s *testSection) signature() *crypto.PGPSignature {
+ sig, err := crypto.NewPGPSignatureFromArmored(string(s.part.Body))
+ require.NoError(s.t, err)
+
+ return sig
+}
+
+type matcher interface {
+ match(*testing.T, string)
+}
+
+type isMatcher struct {
+ want string
+}
+
+func (matcher isMatcher) match(t *testing.T, have string) {
+ assert.Equal(t, matcher.want, have)
+}
+
+func is(want string) isMatcher {
+ return isMatcher{want: want}
+}
+
+func isMissing() isMatcher {
+ return isMatcher{}
+}
+
+type isNotMatcher struct {
+ notWant string
+}
+
+func (matcher isNotMatcher) match(t *testing.T, have string) {
+ assert.NotEqual(t, matcher.notWant, have)
+}
+
+func isNot(notWant string) isNotMatcher {
+ return isNotMatcher{notWant: notWant}
+}
+
+type containsMatcher struct {
+ contains string
+}
+
+func (matcher containsMatcher) match(t *testing.T, have string) {
+ assert.Contains(t, have, matcher.contains)
+}
+
+func contains(contains string) containsMatcher {
+ return containsMatcher{contains: contains}
+}
+
+type decryptsToMatcher struct {
+ kr *crypto.KeyRing
+ want string
+}
+
+func (matcher decryptsToMatcher) match(t *testing.T, have string) {
+ haveMsg, err := crypto.NewPGPMessageFromArmored(have)
+ require.NoError(t, err)
+
+ dec, err := matcher.kr.Decrypt(haveMsg, nil, crypto.GetUnixTime())
+ require.NoError(t, err)
+
+ assert.Equal(t, matcher.want, dec.GetString())
+}
+
+func decryptsTo(kr *crypto.KeyRing, want string) decryptsToMatcher {
+ return decryptsToMatcher{kr: kr, want: want}
+}
+
+type verifiesAgainstMatcher struct {
+ kr *crypto.KeyRing
+ sig *crypto.PGPSignature
+}
+
+func (matcher verifiesAgainstMatcher) match(t *testing.T, have string) {
+ assert.NoError(t, matcher.kr.VerifyDetached(
+ crypto.NewPlainMessage(bytes.TrimSuffix([]byte(have), []byte("\r\n"))),
+ matcher.sig,
+ crypto.GetUnixTime()),
+ )
+}
+
+func verifiesAgainst(kr *crypto.KeyRing, sig *crypto.PGPSignature) verifiesAgainstMatcher {
+ return verifiesAgainstMatcher{kr: kr, sig: sig}
+}
+
+type maxLineLengthMatcher struct {
+ wantMax int
+}
+
+func (matcher maxLineLengthMatcher) match(t *testing.T, have string) {
+ scanner := bufio.NewScanner(strings.NewReader(have))
+
+ for scanner.Scan() {
+ assert.Less(t, len(scanner.Text()), matcher.wantMax)
+ }
+}
+
+func hasMaxLineLength(wantMax int) maxLineLengthMatcher {
+ return maxLineLengthMatcher{wantMax: wantMax}
+}
diff --git a/pkg/message/build_job.go b/pkg/message/build_job.go
new file mode 100644
index 00000000..15cd74f2
--- /dev/null
+++ b/pkg/message/build_job.go
@@ -0,0 +1,57 @@
+// Copyright (c) 2021 Proton Technologies AG
+//
+// This file is part of ProtonMail Bridge.
+//
+// ProtonMail Bridge is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// ProtonMail Bridge is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with ProtonMail Bridge. If not, see .
+
+package message
+
+type JobOptions struct {
+ IgnoreDecryptionErrors bool // Whether to ignore decryption errors and create a "custom message" instead.
+ SanitizeDate bool // Whether to replace all dates before 1970 with RFC822's birthdate.
+ AddInternalID bool // Whether to include MessageID as X-Pm-Internal-Id.
+ AddExternalID bool // Whether to include ExternalID as X-Pm-External-Id.
+ AddMessageDate bool // Whether to include message time as X-Pm-Date.
+ AddMessageIDReference bool // Whether to include the MessageID in References.
+}
+
+type BuildJob struct {
+ messageID string
+ literal []byte
+ err error
+
+ done chan struct{}
+}
+
+func newBuildJob(messageID string) *BuildJob {
+ return &BuildJob{
+ messageID: messageID,
+ done: make(chan struct{}),
+ }
+}
+
+func (job *BuildJob) GetResult() ([]byte, error) {
+ <-job.done
+ return job.literal, job.err
+}
+
+func (job *BuildJob) postSuccess(literal []byte) {
+ job.literal = literal
+ close(job.done)
+}
+
+func (job *BuildJob) postFailure(err error) {
+ job.err = err
+ close(job.done)
+}
diff --git a/pkg/message/build_rfc822.go b/pkg/message/build_rfc822.go
new file mode 100644
index 00000000..47b2694b
--- /dev/null
+++ b/pkg/message/build_rfc822.go
@@ -0,0 +1,410 @@
+// Copyright (c) 2021 Proton Technologies AG
+//
+// This file is part of ProtonMail Bridge.
+//
+// ProtonMail Bridge is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// ProtonMail Bridge is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with ProtonMail Bridge. If not, see .
+
+package message
+
+import (
+ "bytes"
+ "encoding/base64"
+ "io/ioutil"
+ "mime"
+ "net/mail"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/ProtonMail/go-rfc5322"
+ "github.com/ProtonMail/gopenpgp/v2/crypto"
+ "github.com/ProtonMail/proton-bridge/pkg/pmapi"
+ "github.com/emersion/go-message"
+ "github.com/pkg/errors"
+)
+
+func buildRFC822(kr *crypto.KeyRing, msg *pmapi.Message, attData [][]byte, opts JobOptions) ([]byte, error) {
+ switch {
+ case len(msg.Attachments) > 0:
+ return buildMultipartRFC822(kr, msg, attData, opts)
+
+ case msg.MIMEType == "multipart/mixed":
+ return buildEncryptedRFC822(kr, msg, opts)
+
+ default:
+ return buildSimpleRFC822(kr, msg, opts)
+ }
+}
+
+func buildSimpleRFC822(kr *crypto.KeyRing, msg *pmapi.Message, opts JobOptions) ([]byte, error) {
+ dec, err := msg.Decrypt(kr)
+ if err != nil {
+ if !opts.IgnoreDecryptionErrors {
+ return nil, errors.Wrap(ErrDecryptionFailed, err.Error())
+ }
+
+ return buildMultipartRFC822(kr, msg, nil, opts)
+ }
+
+ hdr := getTextPartHeader(getMessageHeader(msg, opts), dec, msg.MIMEType)
+
+ buf := new(bytes.Buffer)
+
+ w, err := message.CreateWriter(buf, hdr)
+ if err != nil {
+ return nil, err
+ }
+
+ if _, err := w.Write(dec); err != nil {
+ return nil, err
+ }
+
+ if err := w.Close(); err != nil {
+ return nil, err
+ }
+
+ return buf.Bytes(), nil
+}
+
+func buildMultipartRFC822(
+ kr *crypto.KeyRing,
+ msg *pmapi.Message,
+ attData [][]byte,
+ opts JobOptions,
+) ([]byte, error) {
+ boundary := newBoundary(msg.ID)
+
+ hdr := getMessageHeader(msg, opts)
+
+ hdr.SetContentType("multipart/mixed", map[string]string{"boundary": boundary.gen()})
+
+ buf := new(bytes.Buffer)
+
+ w, err := message.CreateWriter(buf, hdr)
+ if err != nil {
+ return nil, err
+ }
+
+ var (
+ inlineAtts []*pmapi.Attachment
+ inlineData [][]byte
+ attachAtts []*pmapi.Attachment
+ attachData [][]byte
+ )
+
+ for i, att := range msg.Attachments {
+ if att.Disposition == pmapi.DispositionInline {
+ inlineAtts = append(inlineAtts, att)
+ inlineData = append(inlineData, attData[i])
+ } else {
+ attachAtts = append(attachAtts, att)
+ attachData = append(attachData, attData[i])
+ }
+ }
+
+ if len(inlineAtts) > 0 {
+ if err := writeRelatedParts(w, kr, boundary, msg, inlineAtts, inlineData, opts); err != nil {
+ return nil, err
+ }
+ } else if err := writeTextPart(w, kr, msg, opts); err != nil {
+ return nil, err
+ }
+
+ for i, att := range attachAtts {
+ if err := writeAttachmentPart(w, kr, att, attachData[i], opts); err != nil {
+ return nil, err
+ }
+ }
+
+ if err := w.Close(); err != nil {
+ return nil, err
+ }
+
+ return buf.Bytes(), nil
+}
+
+func writeTextPart(
+ w *message.Writer,
+ kr *crypto.KeyRing,
+ msg *pmapi.Message,
+ opts JobOptions,
+) error {
+ dec, err := msg.Decrypt(kr)
+ if err != nil {
+ if !opts.IgnoreDecryptionErrors {
+ return errors.Wrap(ErrDecryptionFailed, err.Error())
+ }
+
+ return writeCustomTextPart(w, msg, err)
+ }
+
+ part, err := w.CreatePart(getTextPartHeader(message.Header{}, dec, msg.MIMEType))
+ if err != nil {
+ return err
+ }
+
+ if _, err := part.Write(dec); err != nil {
+ return err
+ }
+
+ return part.Close()
+}
+
+func writeAttachmentPart(
+ w *message.Writer,
+ kr *crypto.KeyRing,
+ att *pmapi.Attachment,
+ attData []byte,
+ opts JobOptions,
+) error {
+ kps, err := base64.StdEncoding.DecodeString(att.KeyPackets)
+ if err != nil {
+ return err
+ }
+
+ msg := crypto.NewPGPSplitMessage(kps, attData).GetPGPMessage()
+
+ dec, err := kr.Decrypt(msg, nil, crypto.GetUnixTime())
+ if err != nil {
+ if !opts.IgnoreDecryptionErrors {
+ return errors.Wrap(ErrDecryptionFailed, err.Error())
+ }
+
+ return writeCustomAttachmentPart(w, att, msg, err)
+ }
+
+ part, err := w.CreatePart(getAttachmentPartHeader(att))
+ if err != nil {
+ return err
+ }
+
+ if _, err := part.Write(dec.GetBinary()); err != nil {
+ return err
+ }
+
+ return part.Close()
+}
+
+func writeRelatedParts(
+ w *message.Writer,
+ kr *crypto.KeyRing,
+ boundary *boundary,
+ msg *pmapi.Message,
+ atts []*pmapi.Attachment,
+ attData [][]byte,
+ opts JobOptions,
+) error {
+ hdr := message.Header{}
+
+ hdr.SetContentType("multipart/related", map[string]string{"boundary": boundary.gen()})
+
+ rel, err := w.CreatePart(hdr)
+ if err != nil {
+ return err
+ }
+
+ if err := writeTextPart(rel, kr, msg, opts); err != nil {
+ return err
+ }
+
+ for i, att := range atts {
+ if err := writeAttachmentPart(rel, kr, att, attData[i], opts); err != nil {
+ return err
+ }
+ }
+
+ return rel.Close()
+}
+
+func buildEncryptedRFC822(kr *crypto.KeyRing, msg *pmapi.Message, opts JobOptions) ([]byte, error) {
+ hdr := getMessageHeader(msg, opts)
+
+ hdr.SetContentType("multipart/mixed", map[string]string{"boundary": newBoundary(msg.ID).gen()})
+
+ buf := new(bytes.Buffer)
+
+ w, err := message.CreateWriter(buf, hdr)
+ if err != nil {
+ return nil, err
+ }
+
+ dec, err := msg.Decrypt(kr)
+ if err != nil {
+ return nil, errors.Wrap(ErrDecryptionFailed, err.Error())
+ }
+
+ ent, err := message.Read(bytes.NewReader(dec))
+ if err != nil {
+ return nil, err
+ }
+
+ part, err := w.CreatePart(ent.Header)
+ if err != nil {
+ return nil, err
+ }
+
+ body, err := ioutil.ReadAll(ent.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ if _, err := part.Write(body); err != nil {
+ return nil, err
+ }
+
+ if err := part.Close(); err != nil {
+ return nil, err
+ }
+
+ if err := w.Close(); err != nil {
+ return nil, err
+ }
+
+ return buf.Bytes(), nil
+}
+
+func getMessageHeader(msg *pmapi.Message, opts JobOptions) message.Header { // nolint[funlen]
+ hdr := toMessageHeader(msg.Header)
+
+ // SetText will RFC2047-encode.
+ if msg.Subject != "" {
+ hdr.SetText("Subject", msg.Subject)
+ }
+
+ // mail.Address.String() will RFC2047-encode if necessary.
+ if msg.Sender != nil {
+ hdr.Set("From", msg.Sender.String())
+ }
+
+ if len(msg.ReplyTos) > 0 {
+ hdr.Set("Reply-To", toAddressList(msg.ReplyTos))
+ }
+
+ if len(msg.ToList) > 0 {
+ hdr.Set("To", toAddressList(msg.ToList))
+ }
+
+ if len(msg.CCList) > 0 {
+ hdr.Set("Cc", toAddressList(msg.CCList))
+ }
+
+ if len(msg.BCCList) > 0 {
+ hdr.Set("Bcc", toAddressList(msg.BCCList))
+ }
+
+ // Set Message-Id from ExternalID or ID if it's not already set.
+ if hdr.Get("Message-Id") == "" {
+ if msg.ExternalID != "" {
+ hdr.Set("Message-Id", "<"+msg.ExternalID+">")
+ } else {
+ hdr.Set("Message-Id", "<"+msg.ID+"@"+pmapi.InternalIDDomain+">")
+ }
+ }
+
+ // Sanitize the date; it needs to have a valid unix timestamp.
+ if opts.SanitizeDate {
+ if date, err := rfc5322.ParseDateTime(hdr.Get("Date")); err != nil || date.Before(time.Unix(0, 0)) {
+ if msgTime := time.Unix(msg.Time, 0); msgTime.After(time.Unix(0, 0)) {
+ hdr.Set("Date", msgTime.In(time.UTC).Format(time.RFC1123Z))
+ } else {
+ // No message should realistically be older than RFC822 itself.
+ hdr.Set("Date", time.Date(1982, 8, 13, 0, 0, 0, 0, time.UTC).Format(time.RFC1123Z))
+ }
+
+ // We clobbered the date so we save it under X-Original-Date.
+ hdr.Set("X-Original-Date", date.In(time.UTC).Format(time.RFC1123Z))
+ }
+ }
+
+ // Set our internal ID if requested.
+ // This is important for us to detect whether APPENDed things are actually "move like outlook".
+ if opts.AddInternalID {
+ hdr.Set("X-Pm-Internal-Id", msg.ID)
+ }
+
+ // Set our external ID if requested.
+ // This was useful during debugging of applemail recovered messages; doesn't help with any behaviour.
+ if opts.AddExternalID {
+ hdr.Set("X-Pm-External-Id", "<"+msg.ExternalID+">")
+ }
+
+ // Set our server date if requested.
+ // Can be useful to see how long it took for a message to arrive.
+ if opts.AddMessageDate {
+ hdr.Set("X-Pm-Date", time.Unix(msg.Time, 0).In(time.UTC).Format(time.RFC1123Z))
+ }
+
+ // Include the message ID in the references (supposedly this somehow improves outlook support...).
+ if opts.AddMessageIDReference {
+ if references := hdr.Get("References"); !strings.Contains(references, msg.ID) {
+ hdr.Set("References", references+" <"+msg.ID+"@"+pmapi.InternalIDDomain+">")
+ }
+ }
+
+ return hdr
+}
+
+func getTextPartHeader(hdr message.Header, body []byte, mimeType string) message.Header {
+ params := make(map[string]string)
+
+ if utf8.Valid(body) {
+ params["charset"] = "utf-8"
+ }
+
+ hdr.SetContentType(mimeType, params)
+
+ // Use quoted-printable for all text/... parts
+ hdr.Set("Content-Transfer-Encoding", "quoted-printable")
+
+ return hdr
+}
+
+func getAttachmentPartHeader(att *pmapi.Attachment) message.Header {
+ hdr := toMessageHeader(mail.Header(att.Header))
+
+ // All attachments have a content type.
+ hdr.SetContentType(att.MIMEType, map[string]string{"name": mime.QEncoding.Encode("utf-8", att.Name)})
+
+ // All attachments have a content disposition.
+ hdr.SetContentDisposition(att.Disposition, map[string]string{"filename": mime.QEncoding.Encode("utf-8", att.Name)})
+
+ // Use base64 for all attachments except embedded RFC822 messages.
+ if att.MIMEType != "message/rfc822" {
+ hdr.Set("Content-Transfer-Encoding", "base64")
+ }
+
+ return hdr
+}
+
+func toMessageHeader(hdr mail.Header) message.Header {
+ var res message.Header
+
+ for key, val := range hdr {
+ for _, val := range val {
+ res.Add(key, val)
+ }
+ }
+
+ return res
+}
+
+func toAddressList(addrs []*mail.Address) string {
+ res := make([]string, len(addrs))
+
+ for i, addr := range addrs {
+ res[i] = addr.String()
+ }
+
+ return strings.Join(res, ", ")
+}
diff --git a/pkg/message/build_rfc822_custom.go b/pkg/message/build_rfc822_custom.go
new file mode 100644
index 00000000..bcedf5e8
--- /dev/null
+++ b/pkg/message/build_rfc822_custom.go
@@ -0,0 +1,94 @@
+// Copyright (c) 2021 Proton Technologies AG
+//
+// This file is part of ProtonMail Bridge.
+//
+// ProtonMail Bridge is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// ProtonMail Bridge is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with ProtonMail Bridge. If not, see .
+
+package message
+
+import (
+ "fmt"
+ "mime"
+
+ "github.com/ProtonMail/gopenpgp/v2/constants"
+ "github.com/ProtonMail/gopenpgp/v2/crypto"
+ "github.com/ProtonMail/proton-bridge/pkg/pmapi"
+ "github.com/emersion/go-message"
+)
+
+func writeCustomTextPart(
+ w *message.Writer,
+ msg *pmapi.Message,
+ decError error,
+) error {
+ enc, err := crypto.NewPGPMessageFromArmored(msg.Body)
+ if err != nil {
+ return err
+ }
+
+ arm, err := enc.GetArmoredWithCustomHeaders(
+ fmt.Sprintf("This message could not be decrypted: %v", decError),
+ constants.ArmorHeaderVersion,
+ )
+ if err != nil {
+ return err
+ }
+
+ var hdr message.Header
+
+ hdr.SetContentType(msg.MIMEType, nil)
+
+ part, err := w.CreatePart(hdr)
+ if err != nil {
+ return err
+ }
+
+ if _, err := part.Write([]byte(arm)); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func writeCustomAttachmentPart(
+ w *message.Writer,
+ att *pmapi.Attachment,
+ msg *crypto.PGPMessage,
+ decError error,
+) error {
+ arm, err := msg.GetArmoredWithCustomHeaders(
+ fmt.Sprintf("This attachment could not be decrypted: %v", decError),
+ constants.ArmorHeaderVersion,
+ )
+ if err != nil {
+ return err
+ }
+
+ var hdr message.Header
+
+ hdr.SetContentType("application/pgp-encrypted", map[string]string{"name": mime.QEncoding.Encode("utf-8", att.Name+".pgp")})
+
+ hdr.SetContentDisposition(att.Disposition, map[string]string{"filename": mime.QEncoding.Encode("utf-8", att.Name+".pgp")})
+
+ part, err := w.CreatePart(hdr)
+ if err != nil {
+ return err
+ }
+
+ if _, err := part.Write([]byte(arm)); err != nil {
+ return err
+ }
+
+ return part.Close()
+}
diff --git a/pkg/message/build_test.go b/pkg/message/build_test.go
new file mode 100644
index 00000000..82e909b3
--- /dev/null
+++ b/pkg/message/build_test.go
@@ -0,0 +1,1113 @@
+// Copyright (c) 2021 Proton Technologies AG
+//
+// This file is part of ProtonMail Bridge.
+//
+// ProtonMail Bridge is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// ProtonMail Bridge is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with ProtonMail Bridge. If not, see .
+
+package message
+
+import (
+ "context"
+ "errors"
+ "net/mail"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/ProtonMail/proton-bridge/pkg/message/mocks"
+ tests "github.com/ProtonMail/proton-bridge/test"
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBuildPlainMessage(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/plain", "body", time.Now())
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).
+ expectContentType(is(`text/plain`)).
+ expectBody(is(`body`)).
+ expectTransferEncoding(is(`quoted-printable`))
+}
+
+func TestBuildHTMLMessage(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/html", "
body", time.Now())
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).
+ expectContentType(is(`text/html`)).
+ expectBody(is(`body`)).
+ expectTransferEncoding(is(`quoted-printable`))
+}
+
+func TestBuildPlainEncryptedMessage(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ body := readerToString(getFileReader("pgp-mime-body-plaintext.eml"))
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "multipart/mixed", body, time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC))
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).
+ expectContentType(is(`multipart/mixed`)).
+ expectDate(is(`Wed, 01 Jan 2020 00:00:00 +0000`))
+
+ section(t, res, 1).
+ expectContentType(is(`multipart/mixed`)).
+ expectContentTypeParam(`protected-headers`, is(`v1`)).
+ expectHeader(`Subject`, is(`plain no pubkey no sign`)).
+ expectHeader(`From`, is(`"pm.bridge.qa" `)).
+ expectHeader(`To`, is(`schizofrenic@pm.me`))
+
+ section(t, res, 1, 1).
+ expectContentType(is(`text/plain`)).
+ expectBody(contains(`Where do fruits go on vacation? Pear-is!`))
+}
+
+func TestBuildHTMLEncryptedMessage(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ body := readerToString(getFileReader("pgp-mime-body-html.eml"))
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "multipart/mixed", body, time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC))
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).
+ expectContentType(is(`multipart/mixed`)).
+ expectDate(is(`Wed, 01 Jan 2020 00:00:00 +0000`))
+
+ section(t, res, 1).
+ expectContentType(is(`multipart/mixed`)).
+ expectContentTypeParam(`protected-headers`, is(`v1`)).
+ expectHeader(`Subject`, is(`html no pubkey no sign`)).
+ expectHeader(`From`, is(`"pm.bridge.qa" `)).
+ expectHeader(`To`, is(`schizofrenic@pm.me`))
+
+ section(t, res, 1, 1).
+ expectContentType(is(`text/html`)).
+ expectBody(contains(`What do you call a poor Santa Claus`)).
+ expectBody(contains(`Where do boats go when they're sick`))
+}
+
+func TestBuildSignedPlainEncryptedMessage(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ body := readerToString(getFileReader("pgp-mime-body-signed-plaintext.eml"))
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "multipart/mixed", body, time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC))
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).
+ expectContentType(is(`multipart/mixed`)).
+ expectDate(is(`Wed, 01 Jan 2020 00:00:00 +0000`))
+
+ section(t, res, 1).
+ expectContentType(is(`multipart/signed`)).
+ expectContentTypeParam(`micalg`, is(`pgp-sha256`)).
+ expectContentTypeParam(`protocol`, is(`application/pgp-signature`))
+
+ section(t, res, 1, 1).
+ expectContentType(is(`multipart/mixed`)).
+ expectContentTypeParam(`protected-headers`, is(`v1`)).
+ expectHeader(`Subject`, is(`plain body no pubkey`)).
+ expectHeader(`From`, is(`"pm.bridge.qa" `)).
+ expectHeader(`To`, is(`schizofrenic@pm.me`))
+
+ section(t, res, 1, 1, 1).
+ expectContentType(is(`text/plain`)).
+ expectBody(contains(`Why do seagulls fly over the ocean`)).
+ expectBody(contains(`Because if they flew over the bay, we'd call them bagels`))
+
+ section(t, res, 1, 2).
+ expectContentType(is(`application/pgp-signature`)).
+ expectContentTypeParam(`name`, is(`OpenPGP_signature.asc`)).
+ expectContentDisposition(is(`attachment`)).
+ expectContentDispositionParam(`filename`, is(`OpenPGP_signature`))
+}
+
+func TestBuildSignedHTMLEncryptedMessage(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ body := readerToString(getFileReader("pgp-mime-body-signed-html.eml"))
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "multipart/mixed", body, time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC))
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).
+ expectContentType(is(`multipart/mixed`)).
+ expectDate(is(`Wed, 01 Jan 2020 00:00:00 +0000`))
+
+ section(t, res, 1).
+ expectContentType(is(`multipart/signed`)).
+ expectContentTypeParam(`micalg`, is(`pgp-sha256`)).
+ expectContentTypeParam(`protocol`, is(`application/pgp-signature`))
+
+ section(t, res, 1, 1).
+ expectContentType(is(`multipart/mixed`)).
+ expectContentTypeParam(`protected-headers`, is(`v1`)).
+ expectHeader(`Subject`, is(`html body no pubkey`)).
+ expectHeader(`From`, is(`"pm.bridge.qa" `)).
+ expectHeader(`To`, is(`schizofrenic@pm.me`))
+
+ section(t, res, 1, 1, 1).
+ expectContentType(is(`text/html`)).
+ expectBody(contains(`Behold another HTML`)).
+ expectBody(contains(`I only know 25 letters of the alphabet`)).
+ expectBody(contains(`What did one wall say to the other`)).
+ expectBody(contains(`What did the zero say to the eight`))
+
+ section(t, res, 1, 2).
+ expectContentType(is(`application/pgp-signature`)).
+ expectContentTypeParam(`name`, is(`OpenPGP_signature.asc`)).
+ expectContentDisposition(is(`attachment`)).
+ expectContentDispositionParam(`filename`, is(`OpenPGP_signature`))
+}
+
+func TestBuildSignedPlainEncryptedMessageWithPubKey(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ body := readerToString(getFileReader("pgp-mime-body-signed-plaintext-with-pubkey.eml"))
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "multipart/mixed", body, time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC))
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).
+ expectContentType(is(`multipart/mixed`)).
+ expectDate(is(`Wed, 01 Jan 2020 00:00:00 +0000`))
+
+ section(t, res, 1).
+ expectContentType(is(`multipart/signed`)).
+ expectContentTypeParam(`micalg`, is(`pgp-sha256`)).
+ expectContentTypeParam(`protocol`, is(`application/pgp-signature`))
+
+ section(t, res, 1, 1).
+ expectContentType(is(`multipart/mixed`)).
+ expectContentTypeParam(`protected-headers`, is(`v1`)).
+ expectHeader(`Subject`, is(`simple plaintext body`)).
+ expectHeader(`From`, is(`"pm.bridge.qa" `)).
+ expectHeader(`To`, is(`schizofrenic@pm.me`)).
+ expectSection(verifiesAgainst(section(t, res, 1, 1, 1, 2).pubKey(), section(t, res, 1, 2).signature()))
+
+ section(t, res, 1, 1, 1).
+ expectContentType(is(`multipart/mixed`))
+
+ section(t, res, 1, 1, 1, 1).
+ expectContentType(is(`text/plain`)).
+ expectBody(contains(`Why don't crabs give to charity? Because they're shellfish.`))
+
+ section(t, res, 1, 1, 1, 2).
+ expectContentType(is(`application/pgp-keys`)).
+ expectContentTypeParam(`name`, is(`OpenPGP_0x161C0875822359F7.asc`)).
+ expectContentDisposition(is(`attachment`)).
+ expectContentDispositionParam(`filename`, is(`OpenPGP_0x161C0875822359F7.asc`))
+
+ section(t, res, 1, 2).
+ expectContentType(is(`application/pgp-signature`)).
+ expectContentTypeParam(`name`, is(`OpenPGP_signature.asc`)).
+ expectContentDisposition(is(`attachment`)).
+ expectContentDispositionParam(`filename`, is(`OpenPGP_signature`))
+}
+
+func TestBuildSignedHTMLEncryptedMessageWithPubKey(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ body := readerToString(getFileReader("pgp-mime-body-signed-html-with-pubkey.eml"))
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "multipart/mixed", body, time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC))
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).
+ expectContentType(is(`multipart/mixed`)).
+ expectDate(is(`Wed, 01 Jan 2020 00:00:00 +0000`))
+
+ section(t, res, 1).
+ expectContentType(is(`multipart/signed`)).
+ expectContentTypeParam(`micalg`, is(`pgp-sha256`)).
+ expectContentTypeParam(`protocol`, is(`application/pgp-signature`))
+
+ section(t, res, 1, 1).
+ expectContentType(is(`multipart/mixed`)).
+ expectContentTypeParam(`protected-headers`, is(`v1`)).
+ expectHeader(`Subject`, is(`simple html body`)).
+ expectHeader(`From`, is(`"pm.bridge.qa" `)).
+ expectHeader(`To`, is(`schizofrenic@pm.me`)).
+ expectSection(verifiesAgainst(section(t, res, 1, 1, 1, 2).pubKey(), section(t, res, 1, 2).signature()))
+
+ section(t, res, 1, 1, 1).
+ expectContentType(is(`multipart/mixed`))
+
+ section(t, res, 1, 1, 1, 1).
+ expectContentType(is(`text/html`)).
+ expectBody(contains(`Do I enjoy making courthouse puns`)).
+ expectBody(contains(`Can February March`))
+
+ section(t, res, 1, 1, 1, 2).
+ expectContentType(is(`application/pgp-keys`)).
+ expectContentTypeParam(`name`, is(`OpenPGP_0x161C0875822359F7.asc`)).
+ expectContentDisposition(is(`attachment`)).
+ expectContentDispositionParam(`filename`, is(`OpenPGP_0x161C0875822359F7.asc`))
+
+ section(t, res, 1, 2).
+ expectContentType(is(`application/pgp-signature`)).
+ expectContentTypeParam(`name`, is(`OpenPGP_signature.asc`)).
+ expectContentDisposition(is(`attachment`)).
+ expectContentDispositionParam(`filename`, is(`OpenPGP_signature`))
+}
+
+func TestBuildHTMLMessageWithAttachment(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/html", "body", time.Now())
+ att := addTestAttachment(t, kr, msg, "attachID", "file.png", "image/png", "attachment", "attachment")
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg, att), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res, 1).
+ expectBody(is(`body`)).
+ expectContentType(is(`text/html`)).
+ expectTransferEncoding(is(`quoted-printable`))
+
+ section(t, res, 2).
+ expectBody(is(`attachment`)).
+ expectContentType(is(`image/png`)).
+ expectTransferEncoding(is(`base64`)).
+ expectContentTypeParam(`name`, is(`file.png`)).
+ expectContentDispositionParam(`filename`, is(`file.png`))
+}
+
+func TestBuildHTMLMessageWithRFC822Attachment(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/html", "body", time.Now())
+ att := addTestAttachment(t, kr, msg, "attachID", "file.eml", "message/rfc822", "attachment", "... message/rfc822 ...")
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg, att), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res, 1).
+ expectBody(is(`body`)).
+ expectContentType(is(`text/html`)).
+ expectTransferEncoding(is(`quoted-printable`))
+
+ section(t, res, 2).
+ expectBody(is(`... message/rfc822 ...`)).
+ expectContentType(is(`message/rfc822`)).
+ expectTransferEncoding(isNot(`base64`)).
+ expectContentTypeParam(`name`, is(`file.eml`)).
+ expectContentDispositionParam(`filename`, is(`file.eml`))
+}
+
+func TestBuildHTMLMessageWithInlineAttachment(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/html", "body", time.Now())
+ inl := addTestAttachment(t, kr, msg, "inlineID", "file.png", "image/png", "inline", "inline")
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg, inl), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res, 1).
+ expectContentType(is(`multipart/related`))
+
+ section(t, res, 1, 1).
+ expectBody(is(`body`)).
+ expectContentType(is(`text/html`)).
+ expectTransferEncoding(is(`quoted-printable`))
+
+ section(t, res, 1, 2).
+ expectBody(is(`inline`)).
+ expectContentType(is(`image/png`)).
+ expectTransferEncoding(is(`base64`)).
+ expectContentTypeParam(`name`, is(`file.png`)).
+ expectContentDispositionParam(`filename`, is(`file.png`))
+}
+
+func TestBuildHTMLMessageWithComplexAttachments(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/html", "body", time.Now())
+ inl0 := addTestAttachment(t, kr, msg, "inlineID0", "inline0.png", "image/png", "inline", "inline0")
+ inl1 := addTestAttachment(t, kr, msg, "inlineID1", "inline1.png", "image/png", "inline", "inline1")
+ att0 := addTestAttachment(t, kr, msg, "attachID0", "attach0.png", "image/png", "attachment", "attach0")
+ att1 := addTestAttachment(t, kr, msg, "attachID1", "attach1.png", "image/png", "attachment", "attach1")
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg, inl0, inl1, att0, att1), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res, 1).
+ expectContentType(is(`multipart/related`))
+
+ section(t, res, 1, 1).
+ expectBody(is(`body`)).
+ expectContentType(is(`text/html`)).
+ expectTransferEncoding(is(`quoted-printable`))
+
+ section(t, res, 1, 2).
+ expectBody(is(`inline0`)).
+ expectContentType(is(`image/png`)).
+ expectTransferEncoding(is(`base64`)).
+ expectContentTypeParam(`name`, is(`inline0.png`)).
+ expectContentDispositionParam(`filename`, is(`inline0.png`))
+
+ section(t, res, 1, 3).
+ expectBody(is(`inline1`)).
+ expectContentType(is(`image/png`)).
+ expectTransferEncoding(is(`base64`)).
+ expectContentTypeParam(`name`, is(`inline1.png`)).
+ expectContentDispositionParam(`filename`, is(`inline1.png`))
+
+ section(t, res, 2).
+ expectBody(is(`attach0`)).
+ expectContentType(is(`image/png`)).
+ expectTransferEncoding(is(`base64`)).
+ expectContentTypeParam(`name`, is(`attach0.png`)).
+ expectContentDispositionParam(`filename`, is(`attach0.png`))
+
+ section(t, res, 3).
+ expectBody(is(`attach1`)).
+ expectContentType(is(`image/png`)).
+ expectTransferEncoding(is(`base64`)).
+ expectContentTypeParam(`name`, is(`attach1.png`)).
+ expectContentDispositionParam(`filename`, is(`attach1.png`))
+}
+
+func TestBuildAttachmentWithExoticFilename(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/html", "body", time.Now())
+ att := addTestAttachment(t, kr, msg, "attachID", `I řeally šhould leařn czech.png`, "image/png", "attachment", "attachment")
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg, att), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ // The "name" and "filename" params should actually be RFC2047-encoded because they aren't 7-bit clean.
+ // We expect them to be readable as UTF-8 but we check that the raw header value contains the encoded data.
+ section(t, res, 2).
+ expectContentTypeParam(`name`, is(`I řeally šhould leařn czech.png`)).
+ expectHeader(`Content-Type`, contains(`=?utf-8?q?I_=C5=99eally_=C5=A1hould_lea=C5=99n_czech.png?=`)).
+ expectContentDispositionParam(`filename`, is(`I řeally šhould leařn czech.png`)).
+ expectHeader(`Content-Disposition`, contains(`=?utf-8?q?I_=C5=99eally_=C5=A1hould_lea=C5=99n_czech.png?=`))
+}
+
+func TestBuildAttachmentWithLongFilename(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ veryLongName := strings.Repeat("a", 200) + ".png"
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/html", "body", time.Now())
+ att := addTestAttachment(t, kr, msg, "attachID", veryLongName, "image/png", "attachment", "attachment")
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg, att), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ // NOTE: hasMaxLineLength is too high! Long filenames should be linewrapped using multipart filenames.
+ section(t, res, 2).
+ expectContentTypeParam(`name`, is(veryLongName)).
+ expectHeader(`Content-Type`, contains(veryLongName)).
+ expectContentDispositionParam(`filename`, is(veryLongName)).
+ expectHeader(`Content-Disposition`, contains(veryLongName)).
+ expectSection(hasMaxLineLength(215))
+}
+
+func TestBuildMessageDate(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/plain", "body", time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC))
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).expectDate(is(`Wed, 01 Jan 2020 00:00:00 +0000`))
+}
+
+func TestBuildMessageWithInvalidDate(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+
+ // Create a message with "invalid" (according to applemail) date (before unix time 0).
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/html", "body", time.Unix(-1, 0))
+
+ // Build the message as usual; the date will be before 1970.
+ resRaw, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, resRaw).
+ expectDate(is(`Wed, 31 Dec 1969 23:59:59 +0000`)).
+ expectHeader(`X-Original-Date`, isMissing())
+
+ // Build the message with date sanitization enabled; the date will be RFC822's birthdate.
+ resFix, err := b.NewJobWithOptions(
+ context.Background(),
+ newTestFetcher(m, kr, msg),
+ msg.ID,
+ JobOptions{SanitizeDate: true},
+ ).GetResult()
+ require.NoError(t, err)
+
+ section(t, resFix).
+ expectDate(is(`Fri, 13 Aug 1982 00:00:00 +0000`)).
+ expectHeader(`X-Original-Date`, is(`Wed, 31 Dec 1969 23:59:59 +0000`))
+}
+
+func TestBuildMessageInternalID(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/plain", "body", time.Now())
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).expectHeader(`Message-Id`, is(``))
+}
+
+func TestBuildMessageExternalID(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/plain", "body", time.Now())
+
+ // Set the message's external ID; this should be used preferentially to set the Message-Id header field.
+ msg.ExternalID = "externalID"
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).expectHeader(`Message-Id`, is(``))
+}
+
+func TestBuild8BitBody(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+
+ // Set an 8-bit body; the charset should be set to UTF-8.
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/plain", "I řeally šhould leařn czech", time.Now())
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).expectContentTypeParam(`charset`, is(`utf-8`))
+}
+
+func TestBuild8BitSubject(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/plain", "body", time.Now())
+
+ // Set an 8-bit subject; it should be RFC2047-encoded.
+ msg.Subject = `I řeally šhould leařn czech`
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).
+ expectHeader(`Subject`, is(`=?utf-8?q?I_=C5=99eally_=C5=A1hould_lea=C5=99n_czech?=`)).
+ expectDecodedHeader(`Subject`, is(`I řeally šhould leařn czech`))
+}
+
+func TestBuild8BitSender(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/plain", "body", time.Now())
+
+ // Set an 8-bit sender; it should be RFC2047-encoded.
+ msg.Sender = &mail.Address{
+ Name: `I řeally šhould leařn czech`,
+ Address: `mail@example.com`,
+ }
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).
+ expectHeader(`From`, is(`=?utf-8?q?I_=C5=99eally_=C5=A1hould_lea=C5=99n_czech?= `)).
+ expectDecodedHeader(`From`, is(`I řeally šhould leařn czech `))
+}
+
+func TestBuild8BitRecipients(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/plain", "body", time.Now())
+
+ // Set an 8-bit sender; it should be RFC2047-encoded.
+ msg.ToList = []*mail.Address{
+ {Name: `I řeally šhould`, Address: `mail1@example.com`},
+ {Name: `leařn czech`, Address: `mail2@example.com`},
+ }
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).
+ expectHeader(`To`, is(`=?utf-8?q?I_=C5=99eally_=C5=A1hould?= , =?utf-8?q?lea=C5=99n_czech?= `)).
+ expectDecodedHeader(`To`, is(`I řeally šhould , leařn czech `))
+}
+
+func TestBuildIncludeMessageIDReference(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/plain", "body", time.Now())
+
+ // Add references.
+ msg.Header["References"] = []string{""}
+
+ res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).expectHeader(`References`, is(``))
+
+ resRef, err := b.NewJobWithOptions(
+ context.Background(),
+ newTestFetcher(m, kr, msg),
+ msg.ID,
+ JobOptions{AddMessageIDReference: true},
+ ).GetResult()
+ require.NoError(t, err)
+
+ section(t, resRef).expectHeader(`References`, is(` `))
+}
+
+func TestBuildMessageIsDeterministic(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/plain", "body", time.Now())
+ inl := addTestAttachment(t, kr, msg, "inlineID", "file.png", "image/png", "inline", "inline")
+ att := addTestAttachment(t, kr, msg, "attachID", "attach.png", "image/png", "attachment", "attachment")
+
+ res1, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg, inl, att), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ res2, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg, inl, att), msg.ID).GetResult()
+ require.NoError(t, err)
+
+ assert.Equal(t, res1, res2)
+}
+
+func TestBuildParallel(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(2, 2, 2)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg1 := newTestMessage(t, kr, "messageID1", "addressID", "text/plain", "body1", time.Now())
+ msg2 := newTestMessage(t, kr, "messageID2", "addressID", "text/plain", "body2", time.Now())
+
+ job1 := b.NewJob(context.Background(), newTestFetcher(m, kr, msg1), msg1.ID)
+ job2 := b.NewJob(context.Background(), newTestFetcher(m, kr, msg2), msg2.ID)
+
+ res1, err := job1.GetResult()
+ require.NoError(t, err)
+
+ section(t, res1).expectBody(is(`body1`))
+
+ res2, err := job2.GetResult()
+ require.NoError(t, err)
+
+ section(t, res2).expectBody(is(`body2`))
+}
+
+func TestBuildParallelSameMessage(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(2, 2, 2)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/plain", "body", time.Now())
+
+ // Jobs for the same messageID are shared so fetcher is only called once.
+ fetcher := newTestFetcher(m, kr, msg)
+ job1 := b.NewJob(context.Background(), fetcher, msg.ID)
+ job2 := b.NewJob(context.Background(), fetcher, msg.ID)
+
+ res1, err := job1.GetResult()
+ require.NoError(t, err)
+
+ section(t, res1).expectBody(is(`body`))
+
+ res2, err := job2.GetResult()
+ require.NoError(t, err)
+
+ section(t, res2).expectBody(is(`body`))
+}
+
+func TestBuildUndecryptableMessage(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+
+ // Use a different keyring for encrypting the message; it won't be decryptable.
+ msg := newTestMessage(t, tests.MakeKeyRing(t), "messageID", "addressID", "text/plain", "body", time.Now())
+
+ _, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
+ assert.True(t, errors.Is(err, ErrDecryptionFailed))
+}
+
+func TestBuildUndecryptableAttachment(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/plain", "body", time.Now())
+
+ // Use a different keyring for encrypting the attachment; it won't be decryptable.
+ att := addTestAttachment(t, tests.MakeKeyRing(t), msg, "attachID", "file.png", "image/png", "attachment", "attachment")
+
+ _, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg, att), msg.ID).GetResult()
+ assert.True(t, errors.Is(err, ErrDecryptionFailed))
+}
+
+func TestBuildCustomMessagePlain(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+
+ // Use a different keyring for encrypting the message; it won't be decryptable.
+ foreignKR := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, foreignKR, "messageID", "addressID", "text/plain", "body", time.Now())
+
+ // Tell the job to ignore decryption errors; a custom message will be returned instead of an error.
+ res, err := b.NewJobWithOptions(
+ context.Background(),
+ newTestFetcher(m, kr, msg),
+ msg.ID,
+ JobOptions{IgnoreDecryptionErrors: true},
+ ).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).
+ expectContentType(is(`multipart/mixed`))
+
+ section(t, res, 1).
+ expectContentType(is(`text/plain`)).
+ expectBody(contains(`This message could not be decrypted`)).
+ expectBody(decryptsTo(foreignKR, `body`)).
+ expectTransferEncoding(isMissing())
+}
+
+func TestBuildCustomMessageHTML(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+
+ // Use a different keyring for encrypting the message; it won't be decryptable.
+ foreignKR := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, foreignKR, "messageID", "addressID", "text/html", "body", time.Now())
+
+ // Tell the job to ignore decryption errors; a custom message will be returned instead of an error.
+ res, err := b.NewJobWithOptions(
+ context.Background(),
+ newTestFetcher(m, kr, msg),
+ msg.ID,
+ JobOptions{IgnoreDecryptionErrors: true},
+ ).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).
+ expectContentType(is(`multipart/mixed`))
+
+ section(t, res, 1).
+ expectContentType(is(`text/html`)).
+ expectBody(contains(`This message could not be decrypted`)).
+ expectBody(decryptsTo(foreignKR, `body`)).
+ expectTransferEncoding(isMissing())
+}
+
+func TestBuildCustomMessagePlainWithAttachment(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+
+ // Use a different keyring for encrypting the message; it won't be decryptable.
+ foreignKR := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, foreignKR, "messageID", "addressID", "text/plain", "body", time.Now())
+ att := addTestAttachment(t, foreignKR, msg, "attachID", "file.png", "image/png", "attachment", "attachment")
+
+ // Tell the job to ignore decryption errors; a custom message will be returned instead of an error.
+ res, err := b.NewJobWithOptions(
+ context.Background(),
+ newTestFetcher(m, kr, msg, att),
+ msg.ID,
+ JobOptions{IgnoreDecryptionErrors: true},
+ ).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).
+ expectContentType(is(`multipart/mixed`))
+
+ section(t, res, 1).
+ expectContentType(is(`text/plain`)).
+ expectBody(contains(`This message could not be decrypted`)).
+ expectBody(decryptsTo(foreignKR, `body`)).
+ expectTransferEncoding(isMissing())
+
+ section(t, res, 2).
+ expectContentType(is(`application/pgp-encrypted`)).
+ expectBody(contains(`This attachment could not be decrypted`)).
+ expectBody(decryptsTo(foreignKR, `attachment`)).
+ expectContentTypeParam(`name`, is(`file.png.pgp`)).
+ expectContentDispositionParam(`filename`, is(`file.png.pgp`)).
+ expectTransferEncoding(isMissing())
+}
+
+func TestBuildCustomMessageHTMLWithAttachment(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+
+ // Use a different keyring for encrypting the message; it won't be decryptable.
+ foreignKR := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, foreignKR, "messageID", "addressID", "text/html", "body", time.Now())
+ att := addTestAttachment(t, foreignKR, msg, "attachID", "file.png", "image/png", "attachment", "attachment")
+
+ // Tell the job to ignore decryption errors; a custom message will be returned instead of an error.
+ res, err := b.NewJobWithOptions(
+ context.Background(),
+ newTestFetcher(m, kr, msg, att),
+ msg.ID,
+ JobOptions{IgnoreDecryptionErrors: true},
+ ).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).
+ expectContentType(is(`multipart/mixed`))
+
+ section(t, res, 1).
+ expectContentType(is(`text/html`)).
+ expectBody(contains(`This message could not be decrypted`)).
+ expectBody(decryptsTo(foreignKR, `body`)).
+ expectTransferEncoding(isMissing())
+
+ section(t, res, 2).
+ expectContentType(is(`application/pgp-encrypted`)).
+ expectBody(contains(`This attachment could not be decrypted`)).
+ expectBody(decryptsTo(foreignKR, `attachment`)).
+ expectContentTypeParam(`name`, is(`file.png.pgp`)).
+ expectContentDispositionParam(`filename`, is(`file.png.pgp`)).
+ expectTransferEncoding(isMissing())
+}
+
+func TestBuildCustomMessageOnlyBodyIsUndecryptable(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+
+ // Use a different keyring for encrypting the message; it won't be decryptable.
+ foreignKR := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, foreignKR, "messageID", "addressID", "text/html", "body", time.Now())
+
+ // Use the original keyring for encrypting the attachment; it should decrypt fine.
+ att := addTestAttachment(t, kr, msg, "attachID", "file.png", "image/png", "attachment", "attachment")
+
+ // Tell the job to ignore decryption errors; a custom message will be returned instead of an error.
+ res, err := b.NewJobWithOptions(
+ context.Background(),
+ newTestFetcher(m, kr, msg, att),
+ msg.ID,
+ JobOptions{IgnoreDecryptionErrors: true},
+ ).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).
+ expectContentType(is(`multipart/mixed`))
+
+ section(t, res, 1).
+ expectContentType(is(`text/html`)).
+ expectBody(contains(`This message could not be decrypted`)).
+ expectBody(decryptsTo(foreignKR, `body`)).
+ expectTransferEncoding(isMissing())
+
+ section(t, res, 2).
+ expectBody(is(`attachment`)).
+ expectContentType(is(`image/png`)).
+ expectTransferEncoding(is(`base64`)).
+ expectContentTypeParam(`name`, is(`file.png`)).
+ expectContentDispositionParam(`filename`, is(`file.png`))
+}
+
+func TestBuildCustomMessageOnlyAttachmentIsUndecryptable(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ // Use the original keyring for encrypting the message; it should decrypt fine.
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/html", "body", time.Now())
+
+ // Use a different keyring for encrypting the attachment; it won't be decryptable.
+ foreignKR := tests.MakeKeyRing(t)
+ att := addTestAttachment(t, foreignKR, msg, "attachID", "file.png", "image/png", "attachment", "attachment")
+
+ // Tell the job to ignore decryption errors; a custom message will be returned instead of an error.
+ res, err := b.NewJobWithOptions(
+ context.Background(),
+ newTestFetcher(m, kr, msg, att),
+ msg.ID,
+ JobOptions{IgnoreDecryptionErrors: true},
+ ).GetResult()
+ require.NoError(t, err)
+
+ section(t, res).
+ expectContentType(is(`multipart/mixed`))
+
+ section(t, res, 1).
+ expectBody(is(`body`)).
+ expectContentType(is(`text/html`)).
+ expectTransferEncoding(is(`quoted-printable`))
+
+ section(t, res, 2).
+ expectContentType(is(`application/pgp-encrypted`)).
+ expectBody(contains(`This attachment could not be decrypted`)).
+ expectBody(decryptsTo(foreignKR, `attachment`)).
+ expectContentTypeParam(`name`, is(`file.png.pgp`)).
+ expectContentDispositionParam(`filename`, is(`file.png.pgp`)).
+ expectTransferEncoding(isMissing())
+}
+
+func TestBuildFetchMessageFail(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/plain", "body", time.Now())
+
+ // Pretend the message cannot be fetched.
+ f := mocks.NewMockFetcher(m)
+ f.EXPECT().GetMessage(msg.ID).Return(nil, errors.New("oops"))
+
+ // The job should fail, returning an error and a nil result.
+ res, err := b.NewJob(context.Background(), f, msg.ID).GetResult()
+ assert.Error(t, err)
+ assert.Nil(t, res)
+}
+
+func TestBuildFetchAttachmentFail(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/plain", "body", time.Now())
+ _ = addTestAttachment(t, kr, msg, "attachID", "file.png", "image/png", "attachment", "attachment")
+
+ // Pretend the attachment cannot be fetched.
+ f := mocks.NewMockFetcher(m)
+ f.EXPECT().GetMessage(msg.ID).Return(msg, nil)
+ f.EXPECT().GetAttachment(msg.Attachments[0].ID).Return(nil, errors.New("oops"))
+
+ // The job should fail, returning an error and a nil result.
+ res, err := b.NewJob(context.Background(), f, msg.ID).GetResult()
+ assert.Error(t, err)
+ assert.Nil(t, res)
+}
+
+func TestBuildNoSuchKeyRing(t *testing.T) {
+ m := gomock.NewController(t)
+ defer m.Finish()
+
+ b := NewBuilder(1, 1, 1)
+ defer b.Done()
+
+ kr := tests.MakeKeyRing(t)
+ msg := newTestMessage(t, kr, "messageID", "addressID", "text/plain", "body", time.Now())
+
+ // Pretend there is no available keyring.
+ f := mocks.NewMockFetcher(m)
+ f.EXPECT().GetMessage(msg.ID).Return(msg, nil)
+ f.EXPECT().KeyRingForAddressID(msg.AddressID).Return(nil, errors.New("oops"))
+
+ res, err := b.NewJob(context.Background(), f, msg.ID).GetResult()
+ assert.Error(t, err)
+ assert.Nil(t, res)
+
+ // The returned error should be of this specific type.
+ assert.True(t, errors.Is(err, ErrNoSuchKeyRing))
+}
diff --git a/pkg/message/header.go b/pkg/message/header.go
index 36ecff70..fe08f1d2 100644
--- a/pkg/message/header.go
+++ b/pkg/message/header.go
@@ -42,16 +42,16 @@ func GetHeader(msg *pmapi.Message) textproto.MIMEHeader { //nolint[funlen]
h.Set("From", pmmime.EncodeHeader(msg.Sender.String()))
}
if len(msg.ReplyTos) > 0 {
- h.Set("Reply-To", pmmime.EncodeHeader(formatAddressList(msg.ReplyTos)))
+ h.Set("Reply-To", pmmime.EncodeHeader(toAddressList(msg.ReplyTos)))
}
if len(msg.ToList) > 0 {
- h.Set("To", pmmime.EncodeHeader(formatAddressList(msg.ToList)))
+ h.Set("To", pmmime.EncodeHeader(toAddressList(msg.ToList)))
}
if len(msg.CCList) > 0 {
- h.Set("Cc", pmmime.EncodeHeader(formatAddressList(msg.CCList)))
+ h.Set("Cc", pmmime.EncodeHeader(toAddressList(msg.CCList)))
}
if len(msg.BCCList) > 0 {
- h.Set("Bcc", pmmime.EncodeHeader(formatAddressList(msg.BCCList)))
+ h.Set("Bcc", pmmime.EncodeHeader(toAddressList(msg.BCCList)))
}
// Add or rewrite date related fields.
@@ -91,7 +91,7 @@ func GetHeader(msg *pmapi.Message) textproto.MIMEHeader { //nolint[funlen]
func SetBodyContentFields(h *textproto.MIMEHeader, m *pmapi.Message) {
h.Set("Content-Type", m.MIMEType+"; charset=utf-8")
- h.Set("Content-Disposition", "inline")
+ h.Set("Content-Disposition", pmapi.DispositionInline)
h.Set("Content-Transfer-Encoding", "quoted-printable")
}
@@ -120,8 +120,8 @@ func GetAttachmentHeader(att *pmapi.Attachment, buildForIMAP bool) textproto.MIM
encodedName := pmmime.EncodeHeader(att.Name)
disposition := "attachment" //nolint[goconst]
- if strings.Contains(att.Header.Get("Content-Disposition"), "inline") {
- disposition = "inline"
+ if strings.Contains(att.Header.Get("Content-Disposition"), pmapi.DispositionInline) {
+ disposition = pmapi.DispositionInline
}
h := make(textproto.MIMEHeader)
diff --git a/pkg/message/message.go b/pkg/message/message.go
index f8dc8963..90ab4930 100644
--- a/pkg/message/message.go
+++ b/pkg/message/message.go
@@ -46,7 +46,7 @@ func GetRelatedBoundary(m *pmapi.Message) string {
func SeparateInlineAttachments(m *pmapi.Message) (atts, inlines []*pmapi.Attachment) {
for _, att := range m.Attachments {
- if strings.Contains(att.Header.Get("Content-Disposition"), "inline") {
+ if strings.Contains(att.Header.Get("Content-Disposition"), pmapi.DispositionInline) {
inlines = append(inlines, att)
} else {
atts = append(atts, att)
diff --git a/pkg/message/mocks/mocks.go b/pkg/message/mocks/mocks.go
new file mode 100644
index 00000000..d55de7f2
--- /dev/null
+++ b/pkg/message/mocks/mocks.go
@@ -0,0 +1,82 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/ProtonMail/proton-bridge/pkg/message (interfaces: Fetcher)
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+ io "io"
+ reflect "reflect"
+
+ crypto "github.com/ProtonMail/gopenpgp/v2/crypto"
+ pmapi "github.com/ProtonMail/proton-bridge/pkg/pmapi"
+ gomock "github.com/golang/mock/gomock"
+)
+
+// MockFetcher is a mock of Fetcher interface
+type MockFetcher struct {
+ ctrl *gomock.Controller
+ recorder *MockFetcherMockRecorder
+}
+
+// MockFetcherMockRecorder is the mock recorder for MockFetcher
+type MockFetcherMockRecorder struct {
+ mock *MockFetcher
+}
+
+// NewMockFetcher creates a new mock instance
+func NewMockFetcher(ctrl *gomock.Controller) *MockFetcher {
+ mock := &MockFetcher{ctrl: ctrl}
+ mock.recorder = &MockFetcherMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use
+func (m *MockFetcher) EXPECT() *MockFetcherMockRecorder {
+ return m.recorder
+}
+
+// GetAttachment mocks base method
+func (m *MockFetcher) GetAttachment(arg0 string) (io.ReadCloser, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetAttachment", arg0)
+ ret0, _ := ret[0].(io.ReadCloser)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetAttachment indicates an expected call of GetAttachment
+func (mr *MockFetcherMockRecorder) GetAttachment(arg0 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAttachment", reflect.TypeOf((*MockFetcher)(nil).GetAttachment), arg0)
+}
+
+// GetMessage mocks base method
+func (m *MockFetcher) GetMessage(arg0 string) (*pmapi.Message, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetMessage", arg0)
+ ret0, _ := ret[0].(*pmapi.Message)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetMessage indicates an expected call of GetMessage
+func (mr *MockFetcherMockRecorder) GetMessage(arg0 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMessage", reflect.TypeOf((*MockFetcher)(nil).GetMessage), arg0)
+}
+
+// KeyRingForAddressID mocks base method
+func (m *MockFetcher) KeyRingForAddressID(arg0 string) (*crypto.KeyRing, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "KeyRingForAddressID", arg0)
+ ret0, _ := ret[0].(*crypto.KeyRing)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// KeyRingForAddressID indicates an expected call of KeyRingForAddressID
+func (mr *MockFetcherMockRecorder) KeyRingForAddressID(arg0 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KeyRingForAddressID", reflect.TypeOf((*MockFetcher)(nil).KeyRingForAddressID), arg0)
+}
diff --git a/pkg/message/parser.go b/pkg/message/parser.go
index a62eb8eb..f93d1a85 100644
--- a/pkg/message/parser.go
+++ b/pkg/message/parser.go
@@ -536,7 +536,7 @@ func parseAttachment(h message.Header) (*pmapi.Attachment, error) {
if h.Has("Content-Disposition") {
if disp, _, err := h.ContentDisposition(); err != nil {
return nil, err
- } else if disp == "inline" {
+ } else if disp == pmapi.DispositionInline {
att.ContentID = strings.Trim(h.Get("Content-Id"), " <>")
}
} else if h.Has("Content-Id") {
diff --git a/pkg/message/section.go b/pkg/message/section.go
index e4865ede..87333c31 100644
--- a/pkg/message/section.go
+++ b/pkg/message/section.go
@@ -201,7 +201,7 @@ func (bs *BodyStructure) parseAllChildSections(r io.Reader, currentPath []int, s
mediaType, params, _ := pmmime.ParseMediaType(info.Header.Get("Content-Type"))
// If multipart, call getAllParts, else read to count lines.
- if (strings.HasPrefix(mediaType, "multipart/") || mediaType == rfc822Message) && params["boundary"] != "" {
+ if (strings.HasPrefix(mediaType, "multipart/") || mediaType == "message/rfc822") && params["boundary"] != "" {
newPath := append(currentPath, 1)
var br *boundaryReader
diff --git a/pkg/message/testdata/pgp-mime-body-html.eml b/pkg/message/testdata/pgp-mime-body-html.eml
new file mode 100644
index 00000000..3941ae97
--- /dev/null
+++ b/pkg/message/testdata/pgp-mime-body-html.eml
@@ -0,0 +1,33 @@
+Content-Type: multipart/mixed; boundary="u5NoTcx3NkhqapFjjYFKJZdxCaEWvrsGw";
+ protected-headers="v1"
+Subject: html no pubkey no sign
+From: "pm.bridge.qa"
+To: schizofrenic@pm.me
+Message-ID:
+
+--u5NoTcx3NkhqapFjjYFKJZdxCaEWvrsGw
+Content-Type: text/html; charset=utf-8
+Content-Language: en-US
+Content-Transfer-Encoding: quoted-printable
+
+
+
+
+
+
+
+
+ - What do you call a poor Santa Claus? St.
+ Nickel-less.
+ - Where do boats go when they're sick? To the boat
+ doc.
+
+
+
+
+
+
+
+
+--u5NoTcx3NkhqapFjjYFKJZdxCaEWvrsGw--
diff --git a/pkg/message/testdata/pgp-mime-body-plaintext.eml b/pkg/message/testdata/pgp-mime-body-plaintext.eml
new file mode 100644
index 00000000..e3491e1d
--- /dev/null
+++ b/pkg/message/testdata/pgp-mime-body-plaintext.eml
@@ -0,0 +1,17 @@
+Content-Type: multipart/mixed; boundary="unlHEst6hn6dMAzATXJvy5dCLgUfF9Vvs";
+ protected-headers="v1"
+Subject: plain no pubkey no sign
+From: "pm.bridge.qa"
+To: schizofrenic@pm.me
+Message-ID: <564b9c7c-91eb-6508-107a-35108f383a44@gmail.com>
+
+--unlHEst6hn6dMAzATXJvy5dCLgUfF9Vvs
+Content-Type: text/plain; charset=utf-8; format=flowed
+Content-Transfer-Encoding: quoted-printable
+Content-Language: en-US
+
+Where do fruits go on vacation? Pear-is!
+
+
+
+--unlHEst6hn6dMAzATXJvy5dCLgUfF9Vvs--
diff --git a/pkg/message/testdata/pgp-mime-body-signed-html-with-pubkey.eml b/pkg/message/testdata/pgp-mime-body-signed-html-with-pubkey.eml
new file mode 100644
index 00000000..9f530e7f
--- /dev/null
+++ b/pkg/message/testdata/pgp-mime-body-signed-html-with-pubkey.eml
@@ -0,0 +1,116 @@
+Content-Type: multipart/signed; micalg=pgp-sha256;
+ protocol="application/pgp-signature";
+ boundary="pavrbLYh8Q4RWBboYnVxY3mNBBzan1Zz4"
+
+This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
+--pavrbLYh8Q4RWBboYnVxY3mNBBzan1Zz4
+Content-Type: multipart/mixed; boundary="avFoFILZo8SdHM1Pc1OUviN4UKQh16HyR";
+ protected-headers="v1"
+Subject: simple html body
+From: "pm.bridge.qa"
+To: schizofrenic@pm.me
+Message-ID:
+
+--avFoFILZo8SdHM1Pc1OUviN4UKQh16HyR
+Content-Type: multipart/mixed;
+ boundary="------------9EAE2E1A715ACB9849E5C4E3"
+Content-Language: en-US
+
+This is a multi-part message in MIME format.
+--------------9EAE2E1A715ACB9849E5C4E3
+Content-Type: text/html; charset=utf-8
+Content-Transfer-Encoding: quoted-printable
+
+
+
+
+
+
+
+ And this is HTML
+
+ - Do I enjoy making courthouse puns? Guilty.=E2=80=94 @=
+baddadjokes
+ - Can February March? No, but April May. =E2=80=94@Bear=
+dedMOGuy
+
+
+
+
+--------------9EAE2E1A715ACB9849E5C4E3
+Content-Type: application/pgp-keys;
+ name="OpenPGP_0x161C0875822359F7.asc"
+Content-Transfer-Encoding: quoted-printable
+Content-Disposition: attachment;
+ filename="OpenPGP_0x161C0875822359F7.asc"
+
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+xsBNBFxlUPwBCACx954Ey4SD88f8DSKFw9BaZNXrNwYxNYSgqaqOGHQ0WllF3mstEhTfuxxCZ=
+pDh
+I5IhWCXUNxanzsFkn88mRDwFRVl2sf2aAG4/P/p1381oh2kd0UElMRQaQGzoCadQMaQOL9WYT=
+f4S
+PWSCzjrPyKgjq5FbqjbF/ndu376na9L+tnsEXyL6RrI6aZhjWG73xlqxS65dzTIYzsyM/P97x=
+Snd
+NvlvWtGvLlpFkzxfAEGpVzfOYVYFKoc8rGmUDwrDWYfk5JczRDDogJnY+BNMZf9pjSqk6rTyB=
+OfN
+H5fpU8r7A5Q7l+HVakvMUQ9DzDWJtg2ru1Y8hexnJOF68avO4+a1ABEBAAHNKEJyaWRnZSBLe=
+XUt
+RWh5aiA8cG0uYnJpZGdlLnFhQGdtYWlsLmNvbT7CwJQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCB=
+BYC
+AwECHgECF4AWIQRc5gl5cC8oW/Mo+bEWHAh1giNZ9wUCYC32ygUJB4sMzgAKCRAWHAh1giNZ9=
+/K8
+B/4qs84Ii/zKH+q+C8vwO4jUJkOM73qD0pgB7zBs651zWbpgopyol1YUKNpFaHlx/Qch7RDI7=
+Vcz
+1+60/KZJSJR19/N2EDVbCUdh8ueioUp9X/218YWV2TRJNxTnljd4FAn7smZnXuP1TsLjQ6sKO=
+V0U
+u6JoiG6LZFXqDgxYpA++58Rkl6xaY6R71VkmVQlbEKtubX9AjHydq97Y+Jvn11XzWZaKhv4L7=
+6Pa
+4tMKXvvrKh1oywMmh6mZJo+5ZA/ABTkr45cwlTPYqGTS9+uvOHt+PH/oYwwJB4ls2cIAUldSj=
+TVQ
+IsseYz3LlbcCfKJiiCFxeHOQXA5J6zNLKOT58TsczsBNBFxlUPwBCADh2HsX23yVnJt9fxFz3=
+D07
+kCBNvu4HQfps6h1rgNxGhE32VmpESHebvIB5xjL6xKbIqqRa3x/7KDVBNJvca0gUsqEt5kzYF=
+88F
+yf2NBcejpIbcP7BS/g+C6KOowYj+Et26T6GdwFXExUcl80JvoX8yHQOfvJpdiBRbjyB8UqfCa=
+knm
+3c7dNuXmhflz/w3aBj32q9ZyGqA1NpHCpLyVAlvSNQ/pat/rGUCPZ9duw4KhUUqEmatQPVFPk=
+utT
+ouEZQbMK+i+chOH3AsKCuNDfvCDwirnsSqIJmAgl1lC4de+bsWYCMqN9ei99hOCRUyhZ3g3sr=
+8RB
+owVAdcvjZxeIDKALABEBAAHCwHwEGAEIACYCGwwWIQRc5gl5cC8oW/Mo+bEWHAh1giNZ9wUCY=
+C32
+lAUJB4sMmAAKCRAWHAh1giNZ9+Y2B/9rTKZaKviae+ummXNumXcrKvbkAAvfuLpKUn53FlQLm=
+L6H
+jB++lJnPWvVSzdZxdv8FiPP3d632XHKUrkQRQM/9byRDXDommi7Qttx7YCkhd4JLVYqJqpnAQ=
+xI5
+RMkXiZNWyr1lz8JOM1XvDk1M7sJwPMWews8VOIE03E1nt7AsQGnvHtadgEnQaufrYNX3hFA8S=
+osO
+HSnedcys6yrzCSIGCqCD9VHbnMtS4DOv0XJGh2hwc8omzH0KZA517dyKBorJRwadcVauGXDKx=
+Etv
+Im4rl94PR/3An1Mj6HeeVVpLqDQ5Jb9J90BahWeQ53FzRa4EQzYCw0nLnxcsT1ZEEP5u
+=3Dv/1p
+-----END PGP PUBLIC KEY BLOCK-----
+
+--------------9EAE2E1A715ACB9849E5C4E3--
+
+--avFoFILZo8SdHM1Pc1OUviN4UKQh16HyR--
+
+--pavrbLYh8Q4RWBboYnVxY3mNBBzan1Zz4
+Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename="OpenPGP_signature"
+
+-----BEGIN PGP SIGNATURE-----
+
+wsB5BAABCAAjFiEEXOYJeXAvKFvzKPmxFhwIdYIjWfcFAmBa9hAFAwAAAAAACgkQFhwIdYIjWffL
+1AgApF18AVOPEm9y5R+d0NQmxqhSwAtvaqCwqQpG3mArIYK3Y0zrDkPQZZl/3emW8LWht7ZyYCAb
+NZo7HoYxjLy3yxAOPUl/Pc0nJpEqk/wAZT58yOnzv8DU5Q9o+444FfTMJpcrcH/M5cXYyqRtVhas
+k5wu5u2DEgSO3Kj/5l7lThb+CUgRC6wSiOuUkqGEWLiAguCdd88XDkLMbwrDnOu3PbhcA8o1msns
+PfkBdq3mFjp4M8M4ha+D2MxmV6tBv1E7snWf/spBVb9fHIa7zI4ZS6shpzGHCnJarO0Jco0Qh3IZ
+ZVfwhtJeFsmdqSm6DLvCmQWAYk2fDOZDMVKqe9IbUA==
+=pkS0
+-----END PGP SIGNATURE-----
+
+--pavrbLYh8Q4RWBboYnVxY3mNBBzan1Zz4--
diff --git a/pkg/message/testdata/pgp-mime-body-signed-html.eml b/pkg/message/testdata/pgp-mime-body-signed-html.eml
new file mode 100644
index 00000000..05634a0a
--- /dev/null
+++ b/pkg/message/testdata/pgp-mime-body-signed-html.eml
@@ -0,0 +1,58 @@
+Content-Type: multipart/signed; micalg=pgp-sha256;
+ protocol="application/pgp-signature";
+ boundary="YxKjBoCQD3a9PdAWo8ztsilFlSghrT8M5"
+
+This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
+--YxKjBoCQD3a9PdAWo8ztsilFlSghrT8M5
+Content-Type: multipart/mixed; boundary="6GLjuOzexqUw1CoA6CFjmA6r51g9FOPK7";
+ protected-headers="v1"
+Subject: html body no pubkey
+From: "pm.bridge.qa"
+To: schizofrenic@pm.me
+Message-ID: <5e22f83a-c4f0-d61a-55c8-8230854dc052@gmail.com>
+
+--6GLjuOzexqUw1CoA6CFjmA6r51g9FOPK7
+Content-Type: text/html; charset=utf-8
+Content-Language: en-US
+Content-Transfer-Encoding: quoted-printable
+
+
+
+
+
+
+
+ Behold another HTML
+
+ - I only know 25 letters of the alphabet. I don't
+ know y.
+ - What did one wall say to the other? I'll meet you at
+ the corner.
+ - What did the zero say to the eight? Damn, that belt
+ looks good on you.
+
+
+
+
+
+
+--6GLjuOzexqUw1CoA6CFjmA6r51g9FOPK7--
+
+--YxKjBoCQD3a9PdAWo8ztsilFlSghrT8M5
+Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename="OpenPGP_signature"
+
+-----BEGIN PGP SIGNATURE-----
+
+wsB5BAABCAAjFiEEXOYJeXAvKFvzKPmxFhwIdYIjWfcFAmBa+RsFAwAAAAAACgkQFhwIdYIjWfcK
+aQf/a9w4OwdyFerAW5Y45SdjAOA7WKUbm0gnrifbM2zk03bMEsdgfJQawC1p0hVyUCeqFYNJ9JQ4
+JF5/+7iWEe6oRFp3nW3LbBNr8wu3iN/dp5AWjTqnzx9VXLcvEryV/FJXwMUngO6z0eNVlxjdDFH/
+ucomItcmXFmfDx68ghLkumyWwX4SDfd/W70Wqi1f35wLBjfVIeFik4AS0bmpGFfMt1MKHrgirn2S
++9sKPBiTQ+EFGK9V1wFrrDFleLDDE6oTMl75OUmY1Rr0y9q9jmws3cciEFYT3hTV9LNSwV9hMhZZ
+IEKAzLTy6nYnVltYkFC1ggwAVouq4o6Bcw/5bUt2fA==
+=lk/3
+-----END PGP SIGNATURE-----
+
+--YxKjBoCQD3a9PdAWo8ztsilFlSghrT8M5--
diff --git a/pkg/message/testdata/pgp-mime-body-signed-plaintext-with-pubkey.eml b/pkg/message/testdata/pgp-mime-body-signed-plaintext-with-pubkey.eml
new file mode 100644
index 00000000..99a7d84e
--- /dev/null
+++ b/pkg/message/testdata/pgp-mime-body-signed-plaintext-with-pubkey.eml
@@ -0,0 +1,103 @@
+Content-Type: multipart/signed; micalg=pgp-sha256;
+ protocol="application/pgp-signature";
+ boundary="x4FrOFG2PnNJvlbzxe80NPwxzh2yUHABp"
+
+This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
+--x4FrOFG2PnNJvlbzxe80NPwxzh2yUHABp
+Content-Type: multipart/mixed; boundary="bBln6dwDJTLkin5LPHkHBQudqRLwIzTUH";
+ protected-headers="v1"
+Subject: simple plaintext body
+From: "pm.bridge.qa"
+To: schizofrenic@pm.me
+Message-ID:
+
+--bBln6dwDJTLkin5LPHkHBQudqRLwIzTUH
+Content-Type: multipart/mixed;
+ boundary="------------1B34C666A4C2FB03E0324F1A"
+Content-Language: en-US
+
+This is a multi-part message in MIME format.
+--------------1B34C666A4C2FB03E0324F1A
+Content-Type: text/plain; charset=utf-8; format=flowed
+Content-Transfer-Encoding: quoted-printable
+
+Why don't crabs give to charity? Because they're shellfish.
+
+
+
+--------------1B34C666A4C2FB03E0324F1A
+Content-Type: application/pgp-keys;
+ name="OpenPGP_0x161C0875822359F7.asc"
+Content-Transfer-Encoding: quoted-printable
+Content-Disposition: attachment;
+ filename="OpenPGP_0x161C0875822359F7.asc"
+
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+xsBNBFxlUPwBCACx954Ey4SD88f8DSKFw9BaZNXrNwYxNYSgqaqOGHQ0WllF3mstEhTfuxxCZ=
+pDh
+I5IhWCXUNxanzsFkn88mRDwFRVl2sf2aAG4/P/p1381oh2kd0UElMRQaQGzoCadQMaQOL9WYT=
+f4S
+PWSCzjrPyKgjq5FbqjbF/ndu376na9L+tnsEXyL6RrI6aZhjWG73xlqxS65dzTIYzsyM/P97x=
+Snd
+NvlvWtGvLlpFkzxfAEGpVzfOYVYFKoc8rGmUDwrDWYfk5JczRDDogJnY+BNMZf9pjSqk6rTyB=
+OfN
+H5fpU8r7A5Q7l+HVakvMUQ9DzDWJtg2ru1Y8hexnJOF68avO4+a1ABEBAAHNKEJyaWRnZSBLe=
+XUt
+RWh5aiA8cG0uYnJpZGdlLnFhQGdtYWlsLmNvbT7CwJQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCB=
+BYC
+AwECHgECF4AWIQRc5gl5cC8oW/Mo+bEWHAh1giNZ9wUCYC32ygUJB4sMzgAKCRAWHAh1giNZ9=
+/K8
+B/4qs84Ii/zKH+q+C8vwO4jUJkOM73qD0pgB7zBs651zWbpgopyol1YUKNpFaHlx/Qch7RDI7=
+Vcz
+1+60/KZJSJR19/N2EDVbCUdh8ueioUp9X/218YWV2TRJNxTnljd4FAn7smZnXuP1TsLjQ6sKO=
+V0U
+u6JoiG6LZFXqDgxYpA++58Rkl6xaY6R71VkmVQlbEKtubX9AjHydq97Y+Jvn11XzWZaKhv4L7=
+6Pa
+4tMKXvvrKh1oywMmh6mZJo+5ZA/ABTkr45cwlTPYqGTS9+uvOHt+PH/oYwwJB4ls2cIAUldSj=
+TVQ
+IsseYz3LlbcCfKJiiCFxeHOQXA5J6zNLKOT58TsczsBNBFxlUPwBCADh2HsX23yVnJt9fxFz3=
+D07
+kCBNvu4HQfps6h1rgNxGhE32VmpESHebvIB5xjL6xKbIqqRa3x/7KDVBNJvca0gUsqEt5kzYF=
+88F
+yf2NBcejpIbcP7BS/g+C6KOowYj+Et26T6GdwFXExUcl80JvoX8yHQOfvJpdiBRbjyB8UqfCa=
+knm
+3c7dNuXmhflz/w3aBj32q9ZyGqA1NpHCpLyVAlvSNQ/pat/rGUCPZ9duw4KhUUqEmatQPVFPk=
+utT
+ouEZQbMK+i+chOH3AsKCuNDfvCDwirnsSqIJmAgl1lC4de+bsWYCMqN9ei99hOCRUyhZ3g3sr=
+8RB
+owVAdcvjZxeIDKALABEBAAHCwHwEGAEIACYCGwwWIQRc5gl5cC8oW/Mo+bEWHAh1giNZ9wUCY=
+C32
+lAUJB4sMmAAKCRAWHAh1giNZ9+Y2B/9rTKZaKviae+ummXNumXcrKvbkAAvfuLpKUn53FlQLm=
+L6H
+jB++lJnPWvVSzdZxdv8FiPP3d632XHKUrkQRQM/9byRDXDommi7Qttx7YCkhd4JLVYqJqpnAQ=
+xI5
+RMkXiZNWyr1lz8JOM1XvDk1M7sJwPMWews8VOIE03E1nt7AsQGnvHtadgEnQaufrYNX3hFA8S=
+osO
+HSnedcys6yrzCSIGCqCD9VHbnMtS4DOv0XJGh2hwc8omzH0KZA517dyKBorJRwadcVauGXDKx=
+Etv
+Im4rl94PR/3An1Mj6HeeVVpLqDQ5Jb9J90BahWeQ53FzRa4EQzYCw0nLnxcsT1ZEEP5u
+=3Dv/1p
+-----END PGP PUBLIC KEY BLOCK-----
+
+--------------1B34C666A4C2FB03E0324F1A--
+
+--bBln6dwDJTLkin5LPHkHBQudqRLwIzTUH--
+
+--x4FrOFG2PnNJvlbzxe80NPwxzh2yUHABp
+Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename="OpenPGP_signature"
+
+-----BEGIN PGP SIGNATURE-----
+
+wsB5BAABCAAjFiEEXOYJeXAvKFvzKPmxFhwIdYIjWfcFAmBa9YIFAwAAAAAACgkQFhwIdYIjWfem
+vQgAjUMAaxL7D6fRtFBqLjdQGr7PkDBigeQD9ax17CJFld7Zfo2dAYUzYJRi0HP0Kn1YCSBppF0w
+5/P8458H2sqfPC32ptbDCZ/seL0Rpt/gRx6yufbz7wQC0iUZxqxBq2Ox9PGZYSCrTO837lAVYxUo
+aMnDL/K9ohAGIyTZVv31z+r3LLWQsFpfpB5hJFqsjQXA9IGKSQIkWbaeE+0wveJSwqxdTwYvsHs2
+xjBw+s8tRHO/whP4pvzL185fGsHAb8x9a9oyoDVcszhw5xBpiWW37mI58qkQ6g+4wTarreuXGTp3
+RKgPupoYOMJja90yh3TWovcmuZz6QOgne5Rbn3s+Vg==
+=hUb8
+-----END PGP SIGNATURE-----
+
+--x4FrOFG2PnNJvlbzxe80NPwxzh2yUHABp--
diff --git a/pkg/message/testdata/pgp-mime-body-signed-plaintext.eml b/pkg/message/testdata/pgp-mime-body-signed-plaintext.eml
new file mode 100644
index 00000000..853fcb2f
--- /dev/null
+++ b/pkg/message/testdata/pgp-mime-body-signed-plaintext.eml
@@ -0,0 +1,43 @@
+Content-Type: multipart/signed; micalg=pgp-sha256;
+ protocol="application/pgp-signature";
+ boundary="M2HYr2fNKsmidMKeWqsSlKJaGCe2l1guZ"
+
+This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
+--M2HYr2fNKsmidMKeWqsSlKJaGCe2l1guZ
+Content-Type: multipart/mixed; boundary="ijQgYCMAVOgOyTMqn30h68dd5lQKbMzCn";
+ protected-headers="v1"
+Subject: plain body no pubkey
+From: "pm.bridge.qa"
+To: schizofrenic@pm.me
+Message-ID: <7414d726-2f14-54bf-3abe-75805aa6cc7f@gmail.com>
+
+--ijQgYCMAVOgOyTMqn30h68dd5lQKbMzCn
+Content-Type: text/plain; charset=utf-8; format=flowed
+Content-Transfer-Encoding: quoted-printable
+Content-Language: en-US
+
+Why do seagulls fly over the ocean?
+
+Because if they flew over the bay, we'd call them bagels.
+
+
+
+--ijQgYCMAVOgOyTMqn30h68dd5lQKbMzCn--
+
+--M2HYr2fNKsmidMKeWqsSlKJaGCe2l1guZ
+Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename="OpenPGP_signature"
+
+-----BEGIN PGP SIGNATURE-----
+
+wsB5BAABCAAjFiEEXOYJeXAvKFvzKPmxFhwIdYIjWfcFAmBa+F4FAwAAAAAACgkQFhwIdYIjWfew
+6wf/Ts05KX3py8C2L3FPKkdNf+Ci1hd5aE7ARM8Zp5l0cFuuf6M3+Lud94VKYonoayNu5XfSGoyA
+OO1HtpW+8hf5A+KSnyh8jp2dA/aLnU1RPZsfEN2cmgamMd6NyTL5cpYuAfxcSmWT79xeCcxPcjor
+GtrVAojN1tkP2bynYzNI09uygWXzfzgB5f25povN2pAj7DFMAqRKf9bt3nZxO1wIh/aKHoEyjU3w
+tO2AEKnn7dUnPS37wKomZr/LI1ZbNSLBJ+Gaan4w5c92gfEixttEuHXq2GwkJzJq6SInrxmyZQdl
+dGR/kiAy9wFwQlErhyjI5lTtd12y3XNTyhaO5cS0bQ==
+=Th/B
+-----END PGP SIGNATURE-----
+
+--M2HYr2fNKsmidMKeWqsSlKJaGCe2l1guZ--
diff --git a/pkg/message/utils.go b/pkg/message/utils.go
deleted file mode 100644
index 556e41a7..00000000
--- a/pkg/message/utils.go
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (c) 2021 Proton Technologies AG
-//
-// This file is part of ProtonMail Bridge.
-//
-// ProtonMail Bridge is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// ProtonMail Bridge is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with ProtonMail Bridge. If not, see .
-
-package message
-
-import (
- "bytes"
- "html/template"
- "io"
- "net/http"
- "net/mail"
- "net/textproto"
-
- "github.com/ProtonMail/proton-bridge/pkg/pmapi"
-)
-
-func WriteHeader(w io.Writer, h textproto.MIMEHeader) (err error) {
- if err = http.Header(h).Write(w); err != nil {
- return
- }
- _, err = io.WriteString(w, "\r\n")
- return
-}
-
-const customMessageTemplate = `
-
-
-
-
-
Decryption error
- Decryption of this message's encrypted content failed.
-
{{.Error}}
-
-
- {{if .AttachBody}}
-
- {{- end}}
-
-
-`
-
-type customMessageData struct {
- Error string
- AttachBody bool
- Body string
-}
-
-func CustomMessage(m *pmapi.Message, decodeError error, attachBody bool) error {
- t := template.Must(template.New("customMessage").Parse(customMessageTemplate))
-
- b := new(bytes.Buffer)
-
- if err := t.Execute(b, customMessageData{
- Error: decodeError.Error(),
- AttachBody: attachBody,
- Body: m.Body,
- }); err != nil {
- return err
- }
-
- m.MIMEType = pmapi.ContentTypeHTML
- m.Body = b.String()
-
- // NOTE: we need to set header in custom message header, so we check that is non-nil.
- if m.Header == nil {
- m.Header = make(mail.Header)
- }
- return nil
-}
diff --git a/pkg/pmapi/attachments.go b/pkg/pmapi/attachments.go
index 00ca6e9a..2e88f651 100644
--- a/pkg/pmapi/attachments.go
+++ b/pkg/pmapi/attachments.go
@@ -65,16 +65,22 @@ func (h *header) UnmarshalJSON(b []byte) error {
return nil
}
+const (
+ DispositionInline = "inline"
+ DispositionAttachment = "attachment"
+)
+
// Attachment represents a message attachment.
type Attachment struct {
- ID string `json:",omitempty"`
- MessageID string `json:",omitempty"` // msg v3 ???
- Name string `json:",omitempty"`
- Size int64 `json:",omitempty"`
- MIMEType string `json:",omitempty"`
- ContentID string `json:",omitempty"`
- KeyPackets string `json:",omitempty"`
- Signature string `json:",omitempty"`
+ ID string `json:",omitempty"`
+ MessageID string `json:",omitempty"` // msg v3 ???
+ Name string `json:",omitempty"`
+ Size int64 `json:",omitempty"`
+ MIMEType string `json:",omitempty"`
+ ContentID string `json:",omitempty"`
+ Disposition string
+ KeyPackets string `json:",omitempty"`
+ Signature string `json:",omitempty"`
Header textproto.MIMEHeader `json:"-"`
}
diff --git a/pkg/pmapi/contacts.go b/pkg/pmapi/contacts.go
index 89950812..d3758fd8 100644
--- a/pkg/pmapi/contacts.go
+++ b/pkg/pmapi/contacts.go
@@ -93,7 +93,7 @@ func (c *client) DecryptAndVerifyCards(cards []Card) ([]Card, error) {
if err != nil {
return nil, err
}
- card.Data = signedCard
+ card.Data = string(signedCard)
}
if isSignedCardType(card.Type) {
err := c.verify(card.Data, card.Signature)
diff --git a/pkg/pmapi/keyring.go b/pkg/pmapi/keyring.go
index bdd18378..61552b9a 100644
--- a/pkg/pmapi/keyring.go
+++ b/pkg/pmapi/keyring.go
@@ -190,13 +190,13 @@ func encrypt(encrypter *crypto.KeyRing, plain string, signer *crypto.KeyRing) (a
return pgpMessage.GetArmored()
}
-func (c *client) decrypt(armored string) (plain string, err error) {
+func (c *client) decrypt(armored string) (plain []byte, err error) {
return decrypt(c.userKeyRing, armored)
}
-func decrypt(decrypter *crypto.KeyRing, armored string) (plainBody string, err error) {
+func decrypt(decrypter *crypto.KeyRing, armored string) (plainBody []byte, err error) {
if decrypter == nil {
- return "", ErrNoKeyringAvailable
+ return nil, ErrNoKeyringAvailable
}
pgpMessage, err := crypto.NewPGPMessageFromArmored(armored)
if err != nil {
@@ -206,7 +206,7 @@ func decrypt(decrypter *crypto.KeyRing, armored string) (plainBody string, err e
if err != nil {
return
}
- return plainMessage.GetString(), nil
+ return plainMessage.GetBinary(), nil
}
func (c *client) sign(plain string) (armoredSignature string, err error) {
diff --git a/pkg/pmapi/messages.go b/pkg/pmapi/messages.go
index 5cce9d1f..fe28b3e6 100644
--- a/pkg/pmapi/messages.go
+++ b/pkg/pmapi/messages.go
@@ -272,26 +272,26 @@ func (m *Message) IsLegacyMessage() bool {
strings.Contains(m.Body, MessageTail)
}
-func (m *Message) Decrypt(kr *crypto.KeyRing) (err error) {
+func (m *Message) Decrypt(kr *crypto.KeyRing) ([]byte, error) {
if m.IsLegacyMessage() {
- return m.DecryptLegacy(kr)
+ return m.decryptLegacy(kr)
}
if !m.IsBodyEncrypted() {
- return
+ return []byte(m.Body), nil
}
armored := strings.TrimSpace(m.Body)
+
body, err := decrypt(kr, armored)
if err != nil {
- return
+ return nil, err
}
- m.Body = body
- return
+ return body, nil
}
-func (m *Message) DecryptLegacy(kr *crypto.KeyRing) (err error) {
+func (m *Message) decryptLegacy(kr *crypto.KeyRing) (dec []byte, err error) {
randomKeyStart := strings.Index(m.Body, RandomKeyHeader) + len(RandomKeyHeader)
randomKeyEnd := strings.Index(m.Body, RandomKeyTail)
randomKey := m.Body[randomKeyStart:randomKeyEnd]
@@ -300,7 +300,7 @@ func (m *Message) DecryptLegacy(kr *crypto.KeyRing) (err error) {
if err != nil {
return
}
- bytesKey, err := decodeBase64UTF8(signedKey)
+ bytesKey, err := decodeBase64UTF8(string(signedKey))
if err != nil {
return
}
@@ -345,8 +345,7 @@ func (m *Message) DecryptLegacy(kr *crypto.KeyRing) (err error) {
return
}
- m.Body = string(bytesPlaintext)
- return err
+ return bytesPlaintext, nil
}
func decodeBase64UTF8(input string) (output []byte, err error) {
diff --git a/pkg/pmapi/messages_test.go b/pkg/pmapi/messages_test.go
index 067b1401..d4cfd587 100644
--- a/pkg/pmapi/messages_test.go
+++ b/pkg/pmapi/messages_test.go
@@ -134,9 +134,9 @@ func TestMessage_IsBodyEncrypted(t *testing.T) {
func TestMessage_Decrypt(t *testing.T) {
msg := &Message{Body: testMessageEncrypted}
- err := msg.Decrypt(testPrivateKeyRing)
+ dec, err := msg.Decrypt(testPrivateKeyRing)
Ok(t, err)
- Equals(t, testMessageCleartext, msg.Body)
+ Equals(t, testMessageCleartext, string(dec))
}
func TestMessage_Decrypt_Legacy(t *testing.T) {
@@ -153,17 +153,17 @@ func TestMessage_Decrypt_Legacy(t *testing.T) {
msg := &Message{Body: testMessageEncryptedLegacy}
- err = msg.Decrypt(testPrivateKeyRingLegacy)
+ dec, err := msg.Decrypt(testPrivateKeyRingLegacy)
Ok(t, err)
- Equals(t, testMessageCleartextLegacy, msg.Body)
+ Equals(t, testMessageCleartextLegacy, string(dec))
}
func TestMessage_Decrypt_signed(t *testing.T) {
msg := &Message{Body: testMessageSigned}
- err := msg.Decrypt(testPrivateKeyRing)
+ dec, err := msg.Decrypt(testPrivateKeyRing)
Ok(t, err)
- Equals(t, testMessageCleartext, msg.Body)
+ Equals(t, testMessageCleartext, string(dec))
}
func TestMessage_Encrypt(t *testing.T) {
@@ -176,10 +176,10 @@ func TestMessage_Encrypt(t *testing.T) {
msg := &Message{Body: testMessageCleartext}
Ok(t, msg.Encrypt(testPrivateKeyRing, testPrivateKeyRing))
- err = msg.Decrypt(testPrivateKeyRing)
+ dec, err := msg.Decrypt(testPrivateKeyRing)
Ok(t, err)
- Equals(t, testMessageCleartext, msg.Body)
+ Equals(t, testMessageCleartext, string(dec))
Equals(t, testIdentity, signer.GetIdentities()[0])
}
diff --git a/internal/imap/utils.go b/test/keyring.go
similarity index 70%
rename from internal/imap/utils.go
rename to test/keyring.go
index 167f999b..bdcd6ac1 100644
--- a/internal/imap/utils.go
+++ b/test/keyring.go
@@ -15,18 +15,21 @@
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see .
-package imap
+package tests
import (
- "io"
- "net/http"
- "net/textproto"
+ "testing"
+
+ "github.com/ProtonMail/gopenpgp/v2/crypto"
+ "github.com/stretchr/testify/require"
)
-func writeHeader(w io.Writer, h textproto.MIMEHeader) (err error) {
- if err = http.Header(h).Write(w); err != nil {
- return
- }
- _, err = io.WriteString(w, "\r\n")
- return
+func MakeKeyRing(t *testing.T) *crypto.KeyRing {
+ key, err := crypto.GenerateKey("name", "email", "rsa", 2048)
+ require.NoError(t, err)
+
+ kr, err := crypto.NewKeyRing(key)
+ require.NoError(t, err)
+
+ return kr
}