diff --git a/internal/user/events.go b/internal/user/events.go index adcda965..494f4143 100644 --- a/internal/user/events.go +++ b/internal/user/events.go @@ -438,12 +438,13 @@ func (user *User) handleCreateMessageEvent(ctx context.Context, event proton.Mes }).Info("Handling message created event") return withAddrKR(user.apiUser, user.apiAddrs[event.Message.AddressID], user.vault.KeyPass(), func(_, addrKR *crypto.KeyRing) error { - update, err := buildRFC822(user.apiLabels, full, addrKR).update.unpack() - if err != nil { + buildRes := buildRFC822(user.apiLabels, full, addrKR) + + if buildRes.err != nil { return fmt.Errorf("failed to build RFC822 message: %w", err) } - user.updateCh[full.AddressID].Enqueue(imap.NewMessagesCreated(update)) + user.updateCh[full.AddressID].Enqueue(imap.NewMessagesCreated(buildRes.update)) return nil }) @@ -493,16 +494,17 @@ func (user *User) handleUpdateDraftEvent(ctx context.Context, event proton.Messa } return withAddrKR(user.apiUser, user.apiAddrs[event.Message.AddressID], user.vault.KeyPass(), func(_, addrKR *crypto.KeyRing) error { - update, err := buildRFC822(user.apiLabels, full, addrKR).update.unpack() - if err != nil { + buildRes := buildRFC822(user.apiLabels, full, addrKR) + + if buildRes.err != nil { return fmt.Errorf("failed to build RFC822 draft: %w", err) } user.updateCh[full.AddressID].Enqueue(imap.NewMessageUpdated( - update.Message, - update.Literal, - update.MailboxIDs, - update.ParsedMessage, + buildRes.update.Message, + buildRes.update.Literal, + buildRes.update.MailboxIDs, + buildRes.update.ParsedMessage, )) return nil diff --git a/internal/user/sync.go b/internal/user/sync.go index 173bbbf0..d64a494e 100644 --- a/internal/user/sync.go +++ b/internal/user/sync.go @@ -294,32 +294,32 @@ func syncMessages( } }() - // Goroutine in charge of converting the messages into updates and building a waitable structure for progress - // tracking. + // Goroutine which converts the messages into updates and builds a waitable structure for progress tracking. go func() { defer close(flushUpdateCh) for batch := range flushCh { for _, res := range batch { - if err := res.update.err(); err != nil { + if res.err != nil { if err := vault.AddFailedMessageID(res.messageID); err != nil { logrus.WithError(err).Error("Failed to add failed message ID") } if err := sentry.ReportMessageWithContext("Failed to sync message", reporter.Context{ "messageID": res.messageID, - "error": err, + "error": res.err, }); err != nil { logrus.WithError(err).Error("Failed to report message sync error") } + // We could sync a placeholder message here, but for now we skip it entirely. continue } else { if err := vault.RemFailedMessageID(res.messageID); err != nil { logrus.WithError(err).Error("Failed to remove failed message ID") } - - flushers[res.addressID].push(res.update.unwrap()) } + + flushers[res.addressID].push(res.update) } for _, flusher := range flushers { diff --git a/internal/user/sync_build.go b/internal/user/sync_build.go index b5cfbd58..6e14f36a 100644 --- a/internal/user/sync_build.go +++ b/internal/user/sync_build.go @@ -18,47 +18,22 @@ package user import ( - "fmt" + "bytes" + "html/template" + "time" "github.com/ProtonMail/gluon/imap" "github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/gopenpgp/v2/crypto" "github.com/ProtonMail/proton-bridge/v3/pkg/message" + "github.com/bradenaw/juniper/xslices" ) -type result[T any] struct { - v T - e error -} - -func resOk[T any](v T) result[T] { - return result[T]{v: v} -} - -func resErr[T any](e error) result[T] { - return result[T]{e: e} -} - -func (r *result[T]) unwrap() T { - if r.e != nil { - panic(r.err) - } - - return r.v -} - -func (r *result[T]) unpack() (T, error) { - return r.v, r.e -} - -func (r *result[T]) err() error { - return r.e -} - type buildRes struct { messageID string addressID string - update result[*imap.MessageCreated] + update *imap.MessageCreated + err error } func defaultJobOpts() message.JobOptions { @@ -73,20 +48,26 @@ func defaultJobOpts() message.JobOptions { } func buildRFC822(apiLabels map[string]proton.Label, full proton.FullMessage, addrKR *crypto.KeyRing) *buildRes { - var update result[*imap.MessageCreated] + var ( + update *imap.MessageCreated + err error + ) - if literal, err := message.BuildRFC822(addrKR, full.Message, full.AttData, defaultJobOpts()); err != nil { - update = resErr[*imap.MessageCreated](fmt.Errorf("failed to build RFC822 for message %s: %w", full.ID, err)) - } else if created, err := newMessageCreatedUpdate(apiLabels, full.MessageMetadata, literal); err != nil { - update = resErr[*imap.MessageCreated](fmt.Errorf("failed to create IMAP update for message %s: %w", full.ID, err)) + if literal, buildErr := message.BuildRFC822(addrKR, full.Message, full.AttData, defaultJobOpts()); buildErr != nil { + update = newMessageCreatedFailedUpdate(apiLabels, full.MessageMetadata, buildErr) + err = buildErr + } else if created, parseErr := newMessageCreatedUpdate(apiLabels, full.MessageMetadata, literal); parseErr != nil { + update = newMessageCreatedFailedUpdate(apiLabels, full.MessageMetadata, parseErr) + err = parseErr } else { - update = resOk(created) + update = created } return &buildRes{ messageID: full.ID, addressID: full.AddressID, update: update, + err: err, } } @@ -107,3 +88,83 @@ func newMessageCreatedUpdate( ParsedMessage: parsedMessage, }, nil } + +func newMessageCreatedFailedUpdate( + apiLabels map[string]proton.Label, + message proton.MessageMetadata, + err error, +) *imap.MessageCreated { + literal := newFailedMessageLiteral(message.ID, time.Unix(message.Time, 0), message.Subject, err) + + parsedMessage, err := imap.NewParsedMessage(literal) + if err != nil { + panic(err) + } + + return &imap.MessageCreated{ + Message: toIMAPMessage(message), + MailboxIDs: mapTo[string, imap.MailboxID](wantLabels(apiLabels, message.LabelIDs)), + Literal: literal, + ParsedMessage: parsedMessage, + } +} + +func newFailedMessageLiteral( + messageID string, + date time.Time, + subject string, + syncErr error, +) []byte { + var buf bytes.Buffer + + if tmpl, err := template.New("header").Parse(failedMessageHeaderTemplate); err != nil { + panic(err) + } else if b, err := tmplExec(tmpl, map[string]any{ + "Date": date.Format(time.RFC822), + }); err != nil { + panic(err) + } else if _, err := buf.Write(b); err != nil { + panic(err) + } + + if tmpl, err := template.New("body").Parse(failedMessageBodyTemplate); err != nil { + panic(err) + } else if b, err := tmplExec(tmpl, map[string]any{ + "MessageID": messageID, + "Subject": subject, + "Error": syncErr.Error(), + }); err != nil { + panic(err) + } else if _, err := buf.Write(lineWrap(b64Encode(b))); err != nil { + panic(err) + } + + return buf.Bytes() +} + +func tmplExec(template *template.Template, data any) ([]byte, error) { + var buf bytes.Buffer + + if err := template.Execute(&buf, data); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func lineWrap(b []byte) []byte { + return bytes.Join(xslices.Chunk(b, 76), []byte{'\r', '\n'}) +} + +const failedMessageHeaderTemplate = `Date: {{.Date}} +Subject: Message failed to build +Content-Type: text/plain +Content-Transfer-Encoding: base64 + +` + +const failedMessageBodyTemplate = `Failed to build message: +Subject: {{.Subject}} +Error: {{.Error}} +MessageID: {{.MessageID}} +` diff --git a/internal/user/sync_build_test.go b/internal/user/sync_build_test.go new file mode 100644 index 00000000..fdbf7ddf --- /dev/null +++ b/internal/user/sync_build_test.go @@ -0,0 +1,49 @@ +// Copyright (c) 2022 Proton AG +// +// This file is part of Proton Mail Bridge. +// +// Proton Mail 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. +// +// Proton Mail 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 Proton Mail Bridge. If not, see . + +package user + +import ( + "errors" + "testing" + "time" + + "github.com/ProtonMail/gluon/imap" + "github.com/ProtonMail/gluon/rfc822" + "github.com/stretchr/testify/require" +) + +func TestNewFailedMessageLiteral(t *testing.T) { + literal := newFailedMessageLiteral("abcd-efgh", time.Unix(123456789, 0), "subject", errors.New("oops")) + + header, err := rfc822.Parse(literal).ParseHeader() + require.NoError(t, err) + require.Equal(t, "Message failed to build", header.Get("Subject")) + require.Equal(t, "29 Nov 73 22:33 CET", header.Get("Date")) + require.Equal(t, "text/plain", header.Get("Content-Type")) + require.Equal(t, "base64", header.Get("Content-Transfer-Encoding")) + + b, err := rfc822.Parse(literal).DecodedBody() + require.NoError(t, err) + require.Equal(t, string(b), "Failed to build message: \nSubject: subject\nError: oops\nMessageID: abcd-efgh\n") + + parsed, err := imap.NewParsedMessage(literal) + require.NoError(t, err) + require.Equal(t, `("29 Nov 73 22:33 CET" "Message failed to build" NIL NIL NIL NIL NIL NIL NIL NIL)`, parsed.Envelope) + require.Equal(t, `("text" "plain" () NIL NIL "base64" 114 2)`, parsed.Body) + require.Equal(t, `("text" "plain" () NIL NIL "base64" 114 2 NIL NIL NIL NIL)`, parsed.Structure) +} diff --git a/internal/user/types.go b/internal/user/types.go index 16a8cc36..acd3fb3c 100644 --- a/internal/user/types.go +++ b/internal/user/types.go @@ -60,6 +60,15 @@ func groupBy[Key comparable, Value any](items []Value, key func(Value) Key) map[ // b64Encode returns the base64 encoding of the given byte slice. func b64Encode(b []byte) []byte { + enc := make([]byte, base64.StdEncoding.EncodedLen(len(b))) + + base64.StdEncoding.Encode(enc, b) + + return enc +} + +// b64RawEncode returns the base64 encoding of the given byte slice. +func b64RawEncode(b []byte) []byte { enc := make([]byte, base64.RawURLEncoding.EncodedLen(len(b))) base64.RawURLEncoding.Encode(enc, b) @@ -67,8 +76,8 @@ func b64Encode(b []byte) []byte { return enc } -// b64Decode returns the bytes represented by the base64 encoding of the given byte slice. -func b64Decode(b []byte) ([]byte, error) { +// b64RawDecode returns the bytes represented by the base64 encoding of the given byte slice. +func b64RawDecode(b []byte) ([]byte, error) { dec := make([]byte, base64.RawURLEncoding.DecodedLen(len(b))) n, err := base64.RawURLEncoding.Decode(dec, b) diff --git a/internal/user/user.go b/internal/user/user.go index ccee8d65..42755c66 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -355,7 +355,7 @@ func (user *User) GluonKey() []byte { // BridgePass returns the user's bridge password, used for authentication over SMTP and IMAP. func (user *User) BridgePass() []byte { - return b64Encode(user.vault.BridgePass()) + return b64RawEncode(user.vault.BridgePass()) } // UsedSpace returns the total space used by the user on the API. @@ -431,7 +431,7 @@ func (user *User) CheckAuth(email string, password []byte) (string, error) { panic("your wish is my command.. I crash") } - dec, err := b64Decode(password) + dec, err := b64RawDecode(password) if err != nil { return "", fmt.Errorf("failed to decode password: %w", err) }