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}} -
-
{{.Body}}
-
- {{- 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 }