Compare commits

..

49 Commits

Author SHA1 Message Date
c6f1f159f3 chore: Bridge Perth Narrows 3.0.18 2023-02-28 06:53:16 +01:00
82af4e01bc feat(GODT-2364): wait and retry once if the gRPC service config file exists but cannot be opened. 2023-02-28 06:21:36 +01:00
9ad5f74409 feat(GODT-2364): added optional details to C++ exceptions. 2023-02-28 06:21:25 +01:00
10cf153678 fix(GODT-2413): use qEnvironmentVariable() instead of qgetenv(). 2023-02-27 15:41:26 +01:00
5ba07db7e3 chore: Bump Gluon for GODT-2399, GODT-2400 and GODT-2414
fix(GODT-2399): Defer updated message deletion
fix(GODT-2400): Allow state updates to be applied if command fails
fix(GODT-2414): Multiple deletion bug in WriteControlledStore
2023-02-27 14:53:37 +01:00
ad0d4ebd36 fix(GODT-2412): Don't treat context cancellation as BadEvent 2023-02-27 14:34:35 +01:00
9f3c14ab1e fix(GODT-2404): Handle unexpected EOF
When fetching too many attachment bodies at once, the read can fail with
io.ErrUnexpectedEOF. In that case, we returun an error so the fetch is retried.
2023-02-27 14:33:44 +01:00
74cf5d422b fix(GODT-2390): Missing changes from pervious commit
Always reports error type to sentry.

Add error checks for get event as well.
2023-02-27 14:33:38 +01:00
dcf694588c fix(GODT-2390): Add reports for uncaught json and net.opErr
Report to sentry if we see some uncaught network err, but don't force
the user logout.

If we catch an uncaught json parser error we report the error to sentry
and let the user be logged out later.

Finally this patch also prints the error type in UserBadEvent sentry
report to further help diagnose issues.
2023-02-27 14:33:21 +01:00
82c388a0dd chore: Bridge Perth Narrows 3.0.18 2023-02-23 06:58:54 +01:00
94ed09b437 feat(GODT-2366): Handle failed message updates as creates
This handles the following case:
- event says message was created
- we try to fetch the message but API says the doesn’t exist yet — we skip applying the “message created” update
- event then says message was updated at some point in the future
- we try to handle it but fail because we don’t have the message — we should treat it as a creation
2023-02-21 16:07:27 +01:00
57962e5757 chore: Bump gluon to create missing messages during MessageUpdated 2023-02-21 16:07:27 +01:00
8a5c8eaf6e chore: Use gluon temp/hotfix-perth-narrows branch 2023-02-21 16:07:27 +01:00
30029f489e doc: changelog typo 2023-02-17 13:34:03 +01:00
2faeebe9e7 chore: Bridge Perth Narrows 3.0.16/17 2023-02-16 17:46:31 +01:00
f6727a56d2 fix(GODT-2371): Continue, not return, when handling draft 2023-02-16 17:46:24 +01:00
d7fd39503f chore: Bridge Perth Narrows 3.0.15/16 2023-02-13 15:06:36 +01:00
b4b66f94ec feat(GODT-2355): improve wording and actions on bad event 2023-02-13 14:27:34 +01:00
cbd36184bd feat(GODT-2354): report failed to load users. 2023-02-10 11:53:05 +00:00
465f754803 feat(GODT-2353): show popup only after 3.0.16 2023-02-09 17:00:58 +01:00
2fa7c97f39 fix(GODT-2351): Bump GPA to better handle net.OpError 2023-02-09 12:04:39 +01:00
9048b14fdb chore: Bridge Perth Narrows v3.0.14 2023-02-07 16:36:38 +01:00
43100d11bf fix(GODT-2323): Fix Expunge not issued for move
When moving between system labels the expunge commands were not being
issued.
2023-02-07 15:09:23 +01:00
4876314cf5 fix(GODT-2341): Handle URL error 2023-02-07 14:31:06 +01:00
2f75131710 fix(GODT-2340): improve logging 2023-02-07 14:31:06 +01:00
1e09fd6662 feat(GODT-2278): improve sentry logs. 2023-02-07 14:31:06 +01:00
48f2c56caa fix(GODT-2327): Better sleep (with context) 2023-02-07 14:31:06 +01:00
20d83dd476 fix(GODT-2327): Loop to retry until sync has complete 2023-02-07 14:31:06 +01:00
9c6be78b4c fix(GODT-2327): Don't retry with abortable context because it's canceled 2023-02-07 14:31:06 +01:00
0a8e71771e fix(GODT-2327): Fix lint issue 2023-02-07 14:31:06 +01:00
29d1c7bccd fix(GODT-2327): Remove unnecessary sync abort call 2023-02-07 14:31:06 +01:00
ca1996a670 fix(GODT-2327): Properly cancel event stream when handling refresh 2023-02-07 14:31:06 +01:00
ab1c1c474a fix(GODT-2327): Clear update channels whenever clearing sync status 2023-02-07 14:31:06 +01:00
d7cac8a8f0 fix(GODT-2327): avoid windows delete all deadlock 2023-02-07 14:31:06 +01:00
63bc87cc86 fix(GODT-2327): Only start processing events once sync is finished 2023-02-07 14:31:06 +01:00
232875d5cc fix(GODT-2327): Delay event processing until gluon user exists
We don't want to start processing events until those events have
somewhere to be sent to.

Also, to be safe, ensure remove and re-add the gluon user while
clearing its sync status. This shouldn't be necessary.
2023-02-07 14:31:02 +01:00
5ea53ea5c0 fix(GODT-2318): Remove gluon DB if label sync was incomplete 2023-02-06 16:36:15 +00:00
367c505444 fix(GODT-1804): Use cherry-picked mail settings in GPA 2023-02-06 15:57:24 +00:00
3bd39b3ea5 fix(GODT-1804): Only promote content headers if non-empty
When attaching public key, we take the root mime part, create a new root,
and put the old root alongside an additional public key mime part.
But when moving the root, we would copy all content headers, even empty ones.
So we’d be left with Content-Disposition: "" which would fail to parse.
2023-02-06 15:57:24 +00:00
e89dcb2cca fix(GODT-2343): Only poll after send if sync is complete 2023-02-06 16:33:53 +01:00
2cb2ca15c7 fix(GODT-2336): Recover from changed address order while bridge is down 2023-02-03 16:05:30 +01:00
db41645159 test: Add failing test for changing address order while bridge is down 2023-02-03 15:51:18 +01:00
4cf23bb2e6 fix(GODT-2328): Ignore labels that aren't part of user label set 2023-02-02 16:59:07 +01:00
ea11c1046a chore: Bridge Perth Narrows v3.0.13 2023-02-02 16:30:45 +01:00
df40f27069 test(GODT-2326): Remove user tests
These tests no longer work due to sync only being started after an
account has been added. Functionality of these tests is covered in the
bridge unit tests.
2023-02-02 16:26:38 +01:00
76d732f247 fix(GODT-2326): Only run sync after addIMAPUser()
There is concurrency bug due to competing sync calls that can occur when
we clear the sync status in the Vault. Running sync at the end of
addIMAPUser() avoids the problem.

This patch also remove the execution of a sync task for
`user.ClearSyncStatus()`
2023-02-02 16:26:12 +01:00
b17bdad864 chore: Bridge Perth Narrows v3.0.13 2023-02-01 10:42:33 +01:00
52daa165a2 fix(GODT-2319): seed the math/rand RNG on app startup. 2023-02-01 10:42:09 +01:00
4c5ba04822 fix(GODT-1804): Preserve MIME parameters when uploading attachments 2023-02-01 10:41:55 +01:00
50 changed files with 971 additions and 315 deletions

View File

@ -2,6 +2,67 @@
Changelog [format](http://keepachangelog.com/en/1.0.0/)
## [Bridge 3.0.18] Perth Narrows
### Fixed
* GODT-2364: Wait and retry once if the gRPC service config file exists but cannot be opened.
* GODT-2364: Added optional details to C++ exceptions.
* GODT-2413: Use qEnvironmentVariable() instead of qgetenv().
* GODT-2412: Don't treat context cancellation as BadEvent.
* GODT-2404: Handle unexpected EOF.
* GODT-2400: Allow state updates to be applied if command fails.
* GODT-2399: Fix immediate message deletion during updates.
* GODT-2390: Missing changes from pervious commit.
* GODT-2390: Add reports for uncaught json and net.opErr.
* GODT-2414: Multiple deletion bug in WriteControlledStore.
## [Bridge 3.0.18] Perth Narrows
### Fixed
* GODT-2392: Create message if gluon updateMessage returns `no such message`.
* GODT-2391: Create draft if missing during message update on gluon side.
## [Bridge 3.0.16/17] Perth Narrows
### Fixed
* GODT-2371: Continue, not return, when handling draft.
## [Bridge 3.0.15] Perth Narrows
### Changed
* GODT-2355: Improve wording and actions on bad event.
### Fixed
* GODT-2354: Report failed load users.
* GODT-2353: Show popup only after 3.0.16.
* GODT-2351: Bump GPA to better handle net.OpError.
## [Bridge 3.0.14] Perth Narrows
### Fixed
* GODT-2323: Fix Expunge not issued for move.
* GODT-2341: Handle URL error.
* GODT-2340: Improve logging.
* GODT-2278: Improve sentry logs.
* GODT-2327: Sync issues when migrating DB.
* GODT-2318: Remove gluon DB if label sync was incomplete.
* GODT-1804: Only promote content headers if non-empty.
* GODT-2343: Only poll after send if sync is complete.
* GODT-2336: Recover from changed address order while bridge is down.
## [Bridge 3.0.13] Perth Narrows
### Fixed
GODT-2328: Ignore labels that aren't part of user label set.
GODT-2326: Sync issue on missing fresh DB file.
GODT-2319: Seed the math/rand RNG on app startup.
GODT-1804: Preserve MIME parameters when uploading attachments.
## [Bridge 3.0.12] Perth Narrows
### Added

View File

@ -11,7 +11,7 @@ ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
.PHONY: build build-gui build-nogui build-launcher versioner hasher
# Keep version hardcoded so app build works also without Git repository.
BRIDGE_APP_VERSION?=3.0.12+git
BRIDGE_APP_VERSION?=3.0.19+git
APP_VERSION:=${BRIDGE_APP_VERSION}
APP_FULL_NAME:=Proton Mail Bridge
APP_VENDOR:=Proton AG
@ -294,7 +294,7 @@ gofiles: ./internal/bridge/credits.go
cd ./utils/ && ./credits.sh bridge
## Run and debug
.PHONY: run run-qt run-qt-cli run-nogui run-cli run-noninteractive run-debug run-qml-preview clean-vendor clean-frontend-qt clean-frontend-qt-common clean
.PHONY: run run-qt run-qt-cli run-nogui run-cli run-noninteractive run-debug run-gui-tester clean-vendor clean-frontend-qt clean-frontend-qt-common clean
LOG?=debug
LOG_IMAP?=client # client/server/all, or empty to turn it off
@ -321,6 +321,20 @@ run-nogui: build-nogui clean-vendor gofiles
run-debug:
dlv debug ./cmd/Desktop-Bridge/main.go -- -l=debug
ifeq "${TARGET_OS}" "windows"
EXE_SUFFIX=.exe
endif
bridge-gui-tester: build-gui
cp ./cmd/Desktop-Bridge/deploy/${TARGET_OS}/bridge-gui${EXE_SUFFIX} .
cd ./internal/frontend/bridge-gui/bridge-gui-tester && cmake . && make
run-gui-tester: bridge-gui-tester
# copying tester as bridge so bridge-gui will start it and connect to it automatically
cp ./internal/frontend/bridge-gui/bridge-gui-tester/bridge-gui-tester${EXE_SUFFIX} bridge${EXE_SUFFIX}
./bridge-gui${EXE_SUFFIX}
clean-vendor:
rm -rf ./vendor

4
go.mod
View File

@ -5,9 +5,9 @@ go 1.18
require (
github.com/0xAX/notificator v0.0.0-20220220101646-ee9b8921e557
github.com/Masterminds/semver/v3 v3.1.1
github.com/ProtonMail/gluon v0.14.2-0.20230127085305-bc2d818d9d13
github.com/ProtonMail/gluon v0.14.2-0.20230227135029-cef8f5824680
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/ProtonMail/go-proton-api v0.3.1-0.20230126112849-3c1ac277855e
github.com/ProtonMail/go-proton-api v0.3.1-0.20230209110241-fe7894c4931a
github.com/ProtonMail/go-rfc5322 v0.11.0
github.com/ProtonMail/gopenpgp/v2 v2.4.10
github.com/PuerkitoBio/goquery v1.8.0

8
go.sum
View File

@ -28,8 +28,8 @@ github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo=
github.com/ProtonMail/docker-credential-helpers v1.1.0 h1:+kvUIpwWcbtP3WFv5sSvkFn/XLzSqPOB5AAthuk9xPk=
github.com/ProtonMail/docker-credential-helpers v1.1.0/go.mod h1:mK0aBveCxhnQ756AmaTfXMZDeULvheYVhF/MWMErN5g=
github.com/ProtonMail/gluon v0.14.2-0.20230127085305-bc2d818d9d13 h1:rljNZVgfq/F1LLyJ4NmCfEzWayC/rk+l9QgJjtQTLKI=
github.com/ProtonMail/gluon v0.14.2-0.20230127085305-bc2d818d9d13/go.mod h1:z2AxLIiBCT1K+0OBHyaDI7AEaO5qI6/BEC2TE42vs4Q=
github.com/ProtonMail/gluon v0.14.2-0.20230227135029-cef8f5824680 h1:NGp7LfbsKePRHBgMcgquycHx3CSuS7255i0wanAiCuY=
github.com/ProtonMail/gluon v0.14.2-0.20230227135029-cef8f5824680/go.mod h1:z2AxLIiBCT1K+0OBHyaDI7AEaO5qI6/BEC2TE42vs4Q=
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a h1:D+aZah+k14Gn6kmL7eKxoo/4Dr/lK3ChBcwce2+SQP4=
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a/go.mod h1:oTGdE7/DlWIr23G0IKW3OXK9wZ5Hw1GGiaJFccTvZi4=
github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
@ -41,8 +41,8 @@ github.com/ProtonMail/go-message v0.0.0-20210611055058-fabeff2ec753/go.mod h1:NB
github.com/ProtonMail/go-mime v0.0.0-20220302105931-303f85f7fe0f/go.mod h1:NYt+V3/4rEeDuaev/zw1zCq8uqVEuPHzDPo3OZrlGJ4=
github.com/ProtonMail/go-mime v0.0.0-20220429130430-2192574d760f h1:4IWzKjHzZxdrW9k4zl/qCwenOVHDbVDADPPHFLjs0Oc=
github.com/ProtonMail/go-mime v0.0.0-20220429130430-2192574d760f/go.mod h1:qRZgbeASl2a9OwmsV85aWwRqic0NHPh+9ewGAzb4cgM=
github.com/ProtonMail/go-proton-api v0.3.1-0.20230126112849-3c1ac277855e h1:UkfLQc44UvknNCLoBEZb1qg7zfVWVLMvCE/LtdVEcAw=
github.com/ProtonMail/go-proton-api v0.3.1-0.20230126112849-3c1ac277855e/go.mod h1:JUo5IQG0hNuPRuDpOUsCOvtee6UjTEHHF1QN2i8RSos=
github.com/ProtonMail/go-proton-api v0.3.1-0.20230209110241-fe7894c4931a h1:h9KLPt0HTCJjILYHREWCYnZv+1xaYmOVx/rxiT/1dIg=
github.com/ProtonMail/go-proton-api v0.3.1-0.20230209110241-fe7894c4931a/go.mod h1:JUo5IQG0hNuPRuDpOUsCOvtee6UjTEHHF1QN2i8RSos=
github.com/ProtonMail/go-rfc5322 v0.11.0 h1:o5Obrm4DpmQEffvgsVqG6S4BKwC1Wat+hYwjIp2YcCY=
github.com/ProtonMail/go-rfc5322 v0.11.0/go.mod h1:6oOKr0jXvpoE6pwTx/HukigQpX2J9WUf6h0auplrFTw=
github.com/ProtonMail/go-srp v0.0.5 h1:xhUioxZgDbCnpo9JehyFhwwsn9JLWkUGfB0oiKXgiGg=

View File

@ -19,11 +19,13 @@ package app
import (
"fmt"
"math/rand"
"net/http"
"net/http/cookiejar"
"os"
"path/filepath"
"runtime"
"time"
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
@ -155,6 +157,9 @@ func New() *cli.App { //nolint:funlen
}
func run(c *cli.Context) error { //nolint:funlen
// Seed the default RNG from the math/rand package.
rand.Seed(time.Now().UnixNano())
// Get the current bridge version.
version, err := semver.NewVersion(constants.Version)
if err != nil {

View File

@ -21,6 +21,7 @@ package bridge
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
@ -38,6 +39,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/focus"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/sentry"
"github.com/ProtonMail/proton-bridge/v3/internal/user"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/bradenaw/juniper/xslices"
@ -376,10 +378,11 @@ func (bridge *Bridge) init(tlsReporter TLSReporter) error {
// Attempt to lazy load users when triggered.
bridge.goLoad = bridge.tasks.Trigger(func(ctx context.Context) {
logrus.Info("Loading users")
if err := bridge.loadUsers(ctx); err != nil {
logrus.WithError(err).Error("Failed to load users")
if netErr := new(proton.NetError); !errors.As(err, &netErr) {
sentry.ReportError(bridge.reporter, "Failed to load users", err)
}
} else {
bridge.publish(events.AllUsersLoaded{})
}

View File

@ -559,6 +559,69 @@ func TestBridge_ChangeCacheDirectory(t *testing.T) {
})
}
func TestBridge_ChangeAddressOrder(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) {
// Create a user.
userID, addrID, err := s.CreateUser("imap", password)
require.NoError(t, err)
// Create a second address for the user.
aliasID, err := s.CreateAddress(userID, "alias@"+s.GetDomain(), password)
require.NoError(t, err)
// Create 10 messages for the user.
withClient(ctx, t, s, "imap", password, func(ctx context.Context, c *proton.Client) {
createNumMessages(ctx, t, c, addrID, proton.InboxLabel, 10)
})
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) {
// Log the user in with its first address.
syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{}))
defer done()
userID, err := b.LoginFull(ctx, "imap", password, nil, nil)
require.NoError(t, err)
require.Equal(t, userID, (<-syncCh).UserID)
// We should see 10 messages in the inbox.
info, err := b.GetUserInfo(userID)
require.NoError(t, err)
require.True(t, info.State == bridge.Connected)
client, err := client.Dial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, client.Login(info.Addresses[0], string(info.BridgePass)))
defer func() { _ = client.Logout() }()
status, err := client.Select(`Inbox`, false)
require.NoError(t, err)
require.Equal(t, uint32(10), status.Messages)
})
// Make the second address the primary one.
withClient(ctx, t, s, "imap", password, func(ctx context.Context, c *proton.Client) {
require.NoError(t, c.OrderAddresses(ctx, proton.OrderAddressesReq{AddressIDs: []string{aliasID, addrID}}))
})
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) {
// We should still see 10 messages in the inbox.
info, err := b.GetUserInfo(userID)
require.NoError(t, err)
require.True(t, info.State == bridge.Connected)
client, err := client.Dial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, client.Login(info.Addresses[0], string(info.BridgePass)))
defer func() { _ = client.Logout() }()
require.Eventually(t, func() bool {
status, err := client.Select(`Inbox`, false)
require.NoError(t, err)
return status.Messages == 10
}, 5*time.Second, 100*time.Millisecond)
})
})
}
// withEnv creates the full test environment and runs the tests.
func withEnv(t *testing.T, tests func(context.Context, *server.Server, *proton.NetCtl, bridge.Locator, []byte), opts ...server.Option) {
server := server.New(opts...)

View File

@ -100,6 +100,8 @@ func (bridge *Bridge) closeIMAP(ctx context.Context) error {
}
// addIMAPUser connects the given user to gluon.
//
//nolint:funlen
func (bridge *Bridge) addIMAPUser(ctx context.Context, user *user.User) error {
if bridge.imapServer == nil {
return fmt.Errorf("no imap server instance running")
@ -125,13 +127,46 @@ func (bridge *Bridge) addIMAPUser(ctx context.Context, user *user.User) error {
return fmt.Errorf("failed to load IMAP user: %w", err)
}
// If the DB was newly created, clear the sync status; gluon's DB was not found.
if isNew {
// If the DB was newly created, clear the sync status; gluon's DB was not found.
logrus.Warn("IMAP user DB was newly created, clearing sync status")
// Remove the user from IMAP so we can clear the sync status.
if err := bridge.imapServer.RemoveUser(ctx, gluonID, false); err != nil {
return fmt.Errorf("failed to remove IMAP user: %w", err)
}
// Clear the sync status -- we need to resync all messages.
if err := user.ClearSyncStatus(); err != nil {
return fmt.Errorf("failed to clear sync status: %w", err)
}
// Add the user back to the IMAP server.
if isNew, err := bridge.imapServer.LoadUser(ctx, imapConn, gluonID, user.GluonKey()); err != nil {
return fmt.Errorf("failed to add IMAP user: %w", err)
} else if isNew {
panic("IMAP user should already have a database")
}
} else if status := user.GetSyncStatus(); !status.HasLabels {
// Otherwise, the DB already exists -- if the labels are not yet synced, we need to re-create the DB.
if err := bridge.imapServer.RemoveUser(ctx, gluonID, true); err != nil {
return fmt.Errorf("failed to remove old IMAP user: %w", err)
}
if err := user.RemoveGluonID(addrID, gluonID); err != nil {
return fmt.Errorf("failed to remove old IMAP user ID: %w", err)
}
gluonID, err := bridge.imapServer.AddUser(ctx, imapConn, user.GluonKey())
if err != nil {
return fmt.Errorf("failed to add IMAP user: %w", err)
}
if err := user.SetGluonID(addrID, gluonID); err != nil {
return fmt.Errorf("failed to set IMAP user ID: %w", err)
}
log.WithField("gluonID", gluonID).Info("Re-created IMAP user")
}
} else {
log.Info("Creating new IMAP user")
@ -149,6 +184,9 @@ func (bridge *Bridge) addIMAPUser(ctx context.Context, user *user.User) error {
}
}
// Trigger a sync for the user, if needed.
user.TriggerSync()
return nil
}

View File

@ -18,10 +18,12 @@
package bridge_test
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"net"
"os"
"strings"
"testing"
"time"
@ -217,3 +219,114 @@ func TestBridge_SendDraftFlags(t *testing.T) {
})
})
}
func TestBridge_SendInvite(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
// Create a recipient user.
_, _, err := s.CreateUser("recipient", password)
require.NoError(t, err)
// Set "attach public keys" to true for the user.
withClient(ctx, t, s, username, password, func(ctx context.Context, client *proton.Client) {
settings, err := client.SetAttachPublicKey(ctx, proton.SetAttachPublicKeyReq{AttachPublicKey: true})
require.NoError(t, err)
require.Equal(t, proton.Bool(true), settings.AttachPublicKey)
})
// The sender should be fully synced.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
defer done()
userID, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err)
require.Equal(t, userID, (<-syncCh).UserID)
})
// Start the bridge.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Get the sender user info.
userInfo, err := bridge.QueryUserInfo(username)
require.NoError(t, err)
// Connect the sender IMAP client.
imapClient, err := client.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetIMAPPort())))
require.NoError(t, err)
require.NoError(t, imapClient.Login(userInfo.Addresses[0], string(userInfo.BridgePass)))
defer imapClient.Logout() //nolint:errcheck
// The message to send.
b, err := os.ReadFile("testdata/invite.eml")
require.NoError(t, err)
// Save a draft.
require.NoError(t, imapClient.Append("Drafts", []string{imap.DraftFlag}, time.Now(), bytes.NewReader(b)))
// Assert that the draft exists and is marked as a draft.
{
messages, err := clientFetch(imapClient, "Drafts")
require.NoError(t, err)
require.Len(t, messages, 1)
require.Contains(t, messages[0].Flags, imap.DraftFlag)
}
// Connect the SMTP client.
smtpClient, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
require.NoError(t, err)
defer smtpClient.Close() //nolint:errcheck
// Upgrade to TLS.
require.NoError(t, smtpClient.StartTLS(&tls.Config{InsecureSkipVerify: true}))
// Authorize with SASL PLAIN.
require.NoError(t, smtpClient.Auth(sasl.NewPlainClient(
userInfo.Addresses[0],
userInfo.Addresses[0],
string(userInfo.BridgePass)),
))
// Send the message.
require.NoError(t, smtpClient.SendMail(
userInfo.Addresses[0],
[]string{"recipient@" + s.GetDomain()},
bytes.NewReader(b),
))
// Delete the draft: add the \Deleted flag and expunge.
{
status, err := imapClient.Select("Drafts", false)
require.NoError(t, err)
require.Equal(t, uint32(1), status.Messages)
// Add the \Deleted flag.
require.NoError(t, clientStore(imapClient, 1, 1, true, imap.FormatFlagsOp(imap.AddFlags, true), imap.DeletedFlag))
// Expunge.
require.NoError(t, imapClient.Expunge(nil))
}
// Assert that the draft is eventually gone.
require.Eventually(t, func() bool {
status, err := imapClient.Select("Drafts", false)
require.NoError(t, err)
return status.Messages == 0
}, 10*time.Second, 100*time.Millisecond)
// Assert that the message is eventually in the sent folder.
require.Eventually(t, func() bool {
messages, err := clientFetch(imapClient, "Sent")
require.NoError(t, err)
return len(messages) == 1
}, 10*time.Second, 100*time.Millisecond)
// Assert that the message is not marked as a draft.
{
messages, err := clientFetch(imapClient, "Sent")
require.NoError(t, err)
require.Len(t, messages, 1)
require.NotContains(t, messages[0].Flags, imap.DraftFlag)
}
})
})
}

85
internal/bridge/testdata/invite.eml vendored Normal file
View File

@ -0,0 +1,85 @@
From: <username@proton.local>
To: <recipient@proton.local>
Subject: Testing calendar invite
Date: Fri, 3 Feb 2023 01:04:32 +0100
Message-ID: <000001d93763$183b74e0$48b25ea0$@proton.local>
MIME-Version: 1.0
Content-Type: text/calendar; method=REQUEST;
charset="utf-8"
Content-Transfer-Encoding: 7bit
X-Mailer: Microsoft Outlook 16.0
Thread-Index: Adk3Yw5pLdgwsT46RviXb/nfvQlesQAAAmGA
Content-Language: en-gb
BEGIN:VCALENDAR
PRODID:-//Microsoft Corporation//Outlook 16.0 MIMEDIR//EN
VERSION:2.0
METHOD:REQUEST
X-MS-OLK-FORCEINSPECTOROPEN:TRUE
BEGIN:VTIMEZONE
TZID:Central European Standard Time
BEGIN:STANDARD
DTSTART:16011028T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010325T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
ATTENDEE;CN=recipient@proton.local;RSVP=TRUE:mailto:recipient@proton.local
CLASS:PUBLIC
CREATED:20230203T000432Z
DESCRIPTION:qweqweqweqweqweqwe/gn\\n
DTEND;TZID="Central European Standard Time":20230203T020000
DTSTAMP:20230203T000432Z
DTSTART;TZID="Central European Standard Time":20230203T013000
LAST-MODIFIED:20230203T000432Z
LOCATION:qweqwe
ORGANIZER;CN=username@proton.local:mailto:username@proton.local
PRIORITY:5
SEQUENCE:0
SUMMARY;LANGUAGE=en-gb:Testing calendar invite
TRANSP:OPAQUE
UID:040000008200E00074C5B7101A82E008000000003080B2796B37D901000000000000000
0100000001236CD1CD93CA9449C6FF1AC4DEAC44E
X-ALT-DESC;FMTTYPE=text/html:<html xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-mic
rosoft-com:office:word" xmlns:m="http://schemas.microsoft.com/office/2004/
12/omml" xmlns="http://www.w3.org/TR/REC-html40"><head><meta http-equiv=Co
ntent-Type content="text/html/g; charset=us-ascii"><meta name=Generator con
tent="Microsoft Word 15 (filtered medium)"><style><!--/gn/* Font Definition
s *//gn@font-face\\n {font-family:"Cambria Math"\\;\\n panose-1:2 4 5 3 5 4 6
3 2 4/g;}\\n@font-face\\n {font-family:Calibri\\;\\n panose-1:2 15 5 2 2 2 4 3
2 4/g;}\\n/* Style Definitions */\\np.MsoNormal\\, li.MsoNormal\\, div.MsoNorma
l/gn {margin:0cm\\;\\n font-size:11.0pt\\;\\n font-family:"Calibri"\\,sans-serif
/g;\\n mso-fareast-language:EN-US\\;}\\nspan.EmailStyle18\\n {mso-style-type:pe
rsonal-compose/g;\\n font-family:"Calibri"\\,sans-serif\\;\\n color:windowtext\\
;}/gn.MsoChpDefault\\n {mso-style-type:export-only\\;\\n font-size:10.0pt\\;}\\n
@page WordSection1/gn {size:612.0pt 792.0pt\\;\\n margin:72.0pt 72.0pt 72.0pt
72.0pt/g;}\\ndiv.WordSection1\\n {page:WordSection1\\;}\\n--></style><!--[if g
te mso 9]><xml>/gn<o:shapedefaults v:ext="edit" spidmax="1026" />\\n</xml><!
[endif]--><!--[if gte mso 9]><xml>/gn<o:shapelayout v:ext="edit">\\n<o:idmap
v:ext="edit" data="1" />/gn</o:shapelayout></xml><![endif]--></head><body
lang=EN-GB link="#0563C1" vlink="#954F72" style='word-wrap:break-word'><di
v class=WordSection1><p class=MsoNormal><span lang=EN-US>qweqweqweqweqweqw
e<o:p></o:p></span></p></div></body></html>
X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE
X-MICROSOFT-CDO-IMPORTANCE:1
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
X-MICROSOFT-DISALLOW-COUNTER:FALSE
X-MS-OLK-AUTOSTARTCHECK:FALSE
X-MS-OLK-CONFTYPE:0
BEGIN:VALARM
TRIGGER:-PT15M
ACTION:DISPLAY
DESCRIPTION:Reminder
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@ -330,17 +330,18 @@ func (bridge *Bridge) loginUser(ctx context.Context, client *proton.Client, auth
// loadUsers tries to load each user in the vault that isn't already loaded.
func (bridge *Bridge) loadUsers(ctx context.Context) error {
logrus.WithField("count", len(bridge.vault.GetUserIDs())).Info("Loading users")
defer logrus.Info("Finished loading users")
return bridge.vault.ForUser(runtime.NumCPU(), func(user *vault.User) error {
log := logrus.WithField("userID", user.UserID())
if user.AuthUID() == "" {
log.Info("Not loading disconnected user")
log.Info("User is not connected (skipping)")
return nil
}
if safe.RLockRet(func() bool { return mapHas(bridge.users, user.UserID()) }, bridge.usersLock) {
log.Debug("Not loading already-loaded user")
log.Info("User is already loaded (skipping)")
return nil
}

View File

@ -22,6 +22,7 @@ import (
"fmt"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/proton-bridge/v3/internal"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/user"
@ -56,6 +57,9 @@ func (bridge *Bridge) handleUserEvent(ctx context.Context, user *user.User, even
case events.UserBadEvent:
bridge.handleUserBadEvent(ctx, user, event.Error)
case events.UncategorizedEventError:
bridge.handleUncategorizedErrorEvent(event)
}
return nil
@ -139,7 +143,8 @@ func (bridge *Bridge) handleUserDeauth(ctx context.Context, user *user.User) {
func (bridge *Bridge) handleUserBadEvent(ctx context.Context, user *user.User, err error) {
safe.Lock(func() {
if rerr := bridge.reporter.ReportMessageWithContext("Failed to handle event", reporter.Context{
"error": err,
"error_type": fmt.Sprintf("%T", internal.ErrCause(err)),
"error": err,
}); rerr != nil {
logrus.WithError(rerr).Error("Failed to report failed event handling")
}
@ -147,3 +152,12 @@ func (bridge *Bridge) handleUserBadEvent(ctx context.Context, user *user.User, e
bridge.logoutUser(ctx, user, true, false)
}, bridge.usersLock)
}
func (bridge *Bridge) handleUncategorizedErrorEvent(event events.UncategorizedEventError) {
if rerr := bridge.reporter.ReportMessageWithContext("Failed to handle due to uncategorized error", reporter.Context{
"error_type": fmt.Sprintf("%T", internal.ErrCause(event.Error)),
"error": event.Error,
}); rerr != nil {
logrus.WithError(rerr).Error("Failed to report failed event handling")
}
}

31
internal/errors.go Normal file
View File

@ -0,0 +1,31 @@
// Copyright (c) 2023 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 <https://www.gnu.org/licenses/>.
package internal
import "errors"
// ErrCause returns the cause of the error, the inner-most error in the wrapped chain.
func ErrCause(err error) error {
cause := err
for errors.Unwrap(cause) != nil {
cause = errors.Unwrap(cause)
}
return cause
}

View File

@ -156,3 +156,14 @@ type AddressModeChanged struct {
func (event AddressModeChanged) String() string {
return fmt.Sprintf("AddressModeChanged: UserID: %s, AddressMode: %s", event.UserID, event.AddressMode)
}
type UncategorizedEventError struct {
eventBase
UserID string
Error error
}
func (event UncategorizedEventError) String() string {
return fmt.Sprintf("UncategorizedEventError: UserID: %s, Source:%T, Error: %s", event.UserID, event.Error, event.Error)
}

View File

@ -73,13 +73,17 @@ ProcessMonitor *AppController::bridgeMonitor() const {
//****************************************************************************************************************************************************
/// \param[in] function The function that caught the exception.
/// \param[in] message The error message.
/// \param[in] details The details for the error.
//****************************************************************************************************************************************************
void AppController::onFatalError(QString const &function, QString const &message) {
QString const fullMessage = QString("%1(): %2").arg(function, message);
reportSentryException(SENTRY_LEVEL_ERROR, "AppController got notified of a fatal error", "Exception", fullMessage.toLocal8Bit());
void AppController::onFatalError(QString const &function, QString const &message, QString const& details) {
QString fullMessage = QString("%1(): %2").arg(function, message);
if (!details.isEmpty())
fullMessage += "\n\nDetails:\n" + details;
sentry_uuid_s const uuid = reportSentryException(SENTRY_LEVEL_ERROR, "AppController got notified of a fatal error", "Exception",
fullMessage.toLocal8Bit());
QMessageBox::critical(nullptr, tr("Error"), message);
restart(true);
log().fatal(fullMessage);
log().fatal(QString("reportID: %1 Captured exception: %2").arg(QByteArray(uuid.bytes, 16).toHex(), fullMessage));
qApp->exit(EXIT_FAILURE);
}

View File

@ -58,7 +58,7 @@ public: // member functions.
void setLauncherArgs(const QString& launcher, const QStringList& args);
public slots:
void onFatalError(QString const &function, QString const &message); ///< Handle fatal errors.
void onFatalError(QString const &function, QString const &message, QString const& details); ///< Handle fatal errors.
private: // member functions
AppController(); ///< Default constructor.

View File

@ -25,8 +25,8 @@
#define HANDLE_EXCEPTION(x) try { x } \
catch (Exception const &e) { emit fatalError(__func__, e.qwhat()); } \
catch (...) { emit fatalError(__func__, QString("An unknown exception occurred")); }
catch (Exception const &e) { emit fatalError(__func__, e.qwhat(), e.details()); } \
catch (...) { emit fatalError(__func__, QString("An unknown exception occurred"), QString()); }
#define HANDLE_EXCEPTION_RETURN_BOOL(x) HANDLE_EXCEPTION(x) return false;
#define HANDLE_EXCEPTION_RETURN_QSTRING(x) HANDLE_EXCEPTION(x) return QString();
#define HANDLE_EXCEPTION_RETURN_ZERO(x) HANDLE_EXCEPTION(x) return 0;
@ -56,12 +56,8 @@ void QMLBackend::init(GRPCConfig const &serviceConfig) {
app().grpc().setLog(&log);
this->connectGrpcEvents();
QString error;
if (app().grpc().connectToServer(serviceConfig, app().bridgeMonitor(), error)) {
app().log().info("Connected to backend via gRPC service.");
} else {
throw Exception(QString("Cannot connectToServer to go backend via gRPC: %1").arg(error));
}
app().grpc().connectToServer(serviceConfig, app().bridgeMonitor());
app().log().info("Connected to backend via gRPC service.");
QString bridgeVer;
app().grpc().version(bridgeVer);
@ -597,7 +593,8 @@ void QMLBackend::setDiskCachePath(QUrl const &path) const {
void QMLBackend::login(QString const &username, QString const &password) const {
HANDLE_EXCEPTION(
if (username.compare("coco@bandicoot", Qt::CaseInsensitive) == 0) {
throw Exception("User requested bridge-gui to crash by trying to log as coco@bandicoot");
throw Exception("User requested bridge-gui to crash by trying to log as coco@bandicoot",
"This error exists for test purposes and should be ignored.");
}
app().grpc().login(username, password);
)
@ -874,12 +871,14 @@ void QMLBackend::onLoginAlreadyLoggedIn(QString const &userID) {
//****************************************************************************************************************************************************
void QMLBackend::onUserBadEvent(QString const &userID, QString const &errorMessage) {
HANDLE_EXCEPTION(
Q_UNUSED(errorMessage);
SPUser const user = users_->getUserWithID(userID);
if (!user)
app().log().error(QString("Received bad event for unknown user %1").arg(user->id()));
user->setState(UserState::SignedOut);
emit userBadEvent(tr("%1 was logged out because of an internal error.").arg(user->primaryEmailOrUsername()));
emit userBadEvent(
tr("Internal error: %1 was automatically logged out. Please log in again or report this problem if the issue persists.").arg(user->primaryEmailOrUsername()),
errorMessage
);
emit selectUser(userID);
emit showMainWindow();
)

View File

@ -222,7 +222,7 @@ signals: // Signals received from the Go backend, to be forwarded to QML
void addressChangedLogout(QString const &address); ///< Signal for the 'addressChangedLogout' gRPC stream event.
void apiCertIssue(); ///< Signal for the 'apiCertIssue' gRPC stream event.
void userDisconnected(QString const &username); ///< Signal for the 'userDisconnected' gRPC stream event.
void userBadEvent(QString const &message); ///< Signal for the 'userBadEvent' gRPC stream event.
void userBadEvent(QString const &description, QString const &errorMessage); ///< Signal for the 'userBadEvent' gRPC stream event.
void internetOff(); ///< Signal for the 'internetOff' gRPC stream event.
void internetOn(); ///< Signal for the 'internetOn' gRPC stream event.
void resetFinished(); ///< Signal for the 'resetFinished' gRPC stream event.
@ -235,7 +235,7 @@ signals: // Signals received from the Go backend, to be forwarded to QML
void selectUser(QString const); ///< Signal that request the given user account to be displayed.
// This signal is emitted when an exception is intercepted is calls triggered by QML. QML engine would intercept the exception otherwise.
void fatalError(QString const &function, QString const &message) const; ///< Signal emitted when an fatal error occurs.
void fatalError(QString const &function, QString const &message, QString const &details) const; ///< Signal emitted when an fatal error occurs.
private: // member functions
void retrieveUserList(); ///< Retrieve the list of users via gRPC.

View File

@ -16,19 +16,38 @@
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
#include "SentryUtils.h"
#include "Version.h"
#include <bridgepp/BridgeUtils.h>
#include <QByteArray>
#include <QCryptographicHash>
#include <QString>
#include <QSysInfo>
static constexpr const char *LoggerName = "bridge-gui";
QByteArray getProtectedHostname() {
QByteArray hostname = QCryptographicHash::hash(QSysInfo::machineHostName().toUtf8(), QCryptographicHash::Sha256);
return hostname.toHex();
}
void reportSentryEvent(sentry_level_t level, const char *message) {
void setSentryReportScope() {
sentry_set_tag("OS", bridgepp::goos().toUtf8());
sentry_set_tag("Client", PROJECT_FULL_NAME);
sentry_set_tag("Version", PROJECT_VER);
sentry_set_tag("UserAgent", QString("/ (%1)").arg(bridgepp::goos()).toUtf8());
sentry_set_tag("HostArch", QSysInfo::currentCpuArchitecture().toUtf8());
sentry_set_tag("server_name", getProtectedHostname());
}
sentry_uuid_t reportSentryEvent(sentry_level_t level, const char *message) {
auto event = sentry_value_new_message_event(level, LoggerName, message);
sentry_capture_event(event);
return sentry_capture_event(event);
}
void reportSentryException(sentry_level_t level, const char *message, const char *exceptionType, const char *exception) {
sentry_uuid_t reportSentryException(sentry_level_t level, const char *message, const char *exceptionType, const char *exception) {
auto event = sentry_value_new_message_event(level, LoggerName, message);
sentry_event_add_exception(event, sentry_value_new_exception(exceptionType, exception));
sentry_capture_event(event);
return sentry_capture_event(event);
}

View File

@ -21,8 +21,8 @@
#include <sentry.h>
void reportSentryEvent(sentry_level_t level, const char *message);
void reportSentryException(sentry_level_t level, const char *message, const char *exceptionType, const char *exception);
void setSentryReportScope();
sentry_uuid_t reportSentryEvent(sentry_level_t level, const char *message);
sentry_uuid_t reportSentryException(sentry_level_t level, const char *message, const char *exceptionType, const char *exception);
#endif //BRIDGE_GUI_SENTRYUTILS_H

View File

@ -75,6 +75,7 @@ function check_exit() {
Write-host "Running build for version $bridgeVersion - $buildConfig in $buildDir"
$REVISION_HASH = git rev-parse --short=10 HEAD
git submodule update --init --recursive $vcpkgRoot
. $vcpkgBootstrap -disableMetrics
. $vcpkgExe install sentry-native:x64-windows grpc:x64-windows --clean-after-build
@ -82,6 +83,7 @@ git submodule update --init --recursive $vcpkgRoot
. $cmakeExe -G "Visual Studio 17 2022" -DCMAKE_BUILD_TYPE="$buildConfig" `
-DBRIDGE_APP_FULL_NAME="$bridgeFullName" `
-DBRIDGE_VENDOR="$bridgeVendor" `
-DBRIDGE_REVISION=$REVISION_HASH `
-DBRIDGE_APP_VERSION="$bridgeVersion" `
-S . -B $buildDir

View File

@ -55,7 +55,7 @@ BRIDGE_VENDOR=${BRIDGE_VENDOR:-"Proton AG"}
BUILD_CONFIG=${BRIDGE_GUI_BUILD_CONFIG:-Debug}
BUILD_DIR=$(echo "./cmake-build-${BUILD_CONFIG}" | tr '[:upper:]' '[:lower:]')
VCPKG_ROOT="${BRIDGE_REPO_ROOT}/extern/vcpkg"
BRIDGE_REVISION=$(git rev-parse --short=10 HEAD)
git submodule update --init --recursive ${VCPKG_ROOT}
check_exit "Failed to initialize vcpkg as a submodule."
@ -93,6 +93,7 @@ cmake \
-DCMAKE_BUILD_TYPE="${BUILD_CONFIG}" \
-DBRIDGE_APP_FULL_NAME="${BRIDGE_APP_FULL_NAME}" \
-DBRIDGE_VENDOR="${BRIDGE_VENDOR}" \
-DBRIDGE_REVISION="${BRIDGE_REVISION}" \
-DBRIDGE_APP_VERSION="${BRIDGE_APP_VERSION}" "${BRIDGE_CMAKE_MACOS_OPTS}" \
-G Ninja \
-S . \

View File

@ -28,6 +28,7 @@
#include <bridgepp/Log/Log.h>
#include <bridgepp/ProcessMonitor.h>
#include <sentry.h>
#include <SentryUtils.h>
#include <project_sentry_config.h>
@ -238,7 +239,8 @@ void focusOtherInstance() {
}
catch (Exception const &e) {
app().log().error(e.qwhat());
reportSentryException(SENTRY_LEVEL_ERROR, "Exception occurred during focusOtherInstance()", "Exception", e.what());
auto uuid = reportSentryException(SENTRY_LEVEL_ERROR, "Exception occurred during focusOtherInstance()", "Exception", e.what());
app().log().fatal(QString("reportID: %1 Captured exception: %2").arg(QByteArray(uuid.bytes, 16).toHex()).arg(e.qwhat()));
}
}
@ -296,13 +298,13 @@ int main(int argc, char *argv[]) {
const QString sentryCachePath = sentryCacheDir();
sentry_options_set_database_path(sentryOptions, sentryCachePath.toStdString().c_str());
}
sentry_options_set_release(sentryOptions, SentryProductID);
sentry_options_set_release(sentryOptions, QByteArray(PROJECT_REVISION).toHex());
// Enable this for debugging sentry.
// sentry_options_set_debug(sentryOptions, 1);
if (sentry_init(sentryOptions) != 0) {
std::cerr << "Failed to initialize sentry" << std::endl;
}
setSentryReportScope();
auto sentryClose = qScopeGuard([] { sentry_close(); });
// The application instance is needed to display system message boxes. As we may have to do it in the exception handler,
@ -426,9 +428,16 @@ int main(int argc, char *argv[]) {
return result;
}
catch (Exception const &e) {
reportSentryException(SENTRY_LEVEL_ERROR, "Exception occurred during main", "Exception", e.what());
QString fullMessage = e.qwhat();
bool const hasDetails = !e.details().isEmpty();
if (hasDetails)
fullMessage += "\n\nDetails:\n" + e.details();
sentry_uuid_s const uuid = reportSentryException(SENTRY_LEVEL_ERROR, "Exception occurred during main", "Exception", fullMessage.toLocal8Bit());
QMessageBox::critical(nullptr, "Error", e.qwhat());
QTextStream(stderr) << e.qwhat() << "\n";
QTextStream errStream(stderr);
errStream << "reportID: " << QByteArray(uuid.bytes, 16).toHex() << " Captured exception :" << e.qwhat() << "\n";
if (hasDetails)
errStream << "\nDetails:\n" << e.details() << "\n";
return EXIT_FAILURE;
}
}

View File

@ -170,6 +170,10 @@ SettingsView {
}
}
function setDescription(message) {
description.text = message
}
function setDefaultValue() {
description.text = ""
address.text = root.selectedAddress

View File

@ -348,6 +348,7 @@ Item {
}
BugReportView { // 8
id: bugReport
colorScheme: root.colorScheme
selectedAddress: {
if (accounts.currentIndex < 0) return ""
@ -409,10 +410,14 @@ Item {
}
accounts.currentIndex = i;
if (user.state === EUserState.SignedOut)
showSignIn(user.primaryEmailOrUsername())
showSignIn(user.primaryEmailOrUsername())
return;
}
console.error("User with ID ", userID, " was not found in the account list")
}
function showBugReportAndPrefill(description) {
rightContent.showBugReport()
bugReport.setDescription(description)
}
}

View File

@ -169,7 +169,6 @@ ApplicationWindow {
root.showSetup(null,"")
}
}
}
NotificationPopups {
@ -188,6 +187,10 @@ ApplicationWindow {
function showHelp() { contentWrapper.showHelp() }
function selectUser(userID) { contentWrapper.selectUser(userID) }
function showBugReportAndPrefill(message) {
contentWrapper.showBugReportAndPrefill(message)
}
function showSignIn(username) {
if (contentLayout.currentIndex == 1) return
contentWrapper.showSignIn(username)

View File

@ -129,6 +129,11 @@ Item {
notification: root.notifications.noActiveKeyForRecipient
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.userBadEvent
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.genericError

View File

@ -1046,8 +1046,8 @@ QtObject {
property Notification apiCertIssue: Notification {
title: qsTr("Unable to establish a \nsecure connection to \nProton servers")
description: qsTr("Bridge cannot verify the authenticity of Proton servers on your current network due to a TLS certificate error. " +
"Start Bridge again after ensuring your connection is secure and/or connecting to a VPN. Learn more about TLS pinning " +
"<a href=\"https://proton.me/blog/tls-ssl-certificate#Extra-security-precautions-taken-by-ProtonMail\">here</a>.")
"Start Bridge again after ensuring your connection is secure and/or connecting to a VPN. Learn more about TLS pinning " +
"<a href=\"https://proton.me/blog/tls-ssl-certificate#Extra-security-precautions-taken-by-ProtonMail\">here</a>.")
brief: title
icon: "./icons/ic-exclamation-circle-filled.svg"
@ -1086,7 +1086,7 @@ QtObject {
function onNoActiveKeyForRecipient(email) {
root.noActiveKeyForRecipient.description = qsTr("There are no active keys to encrypt your message to %1. "+
"Please update the setting for this contact.").arg(email)
"Please update the setting for this contact.").arg(email)
root.noActiveKeyForRecipient.active = true
}
}
@ -1103,17 +1103,21 @@ QtObject {
}
property Notification userBadEvent: Notification {
title: qsTr("User was logged out")
title: qsTr("Your account was logged out")
brief: title
description: "#PlaceHolderText"
icon: "./icons/ic-exclamation-circle-filled.svg"
type: Notification.NotificationType.Danger
group: Notifications.Group.Connection
group: Notifications.Group.Connection | Notifications.Group.Dialogs
property var bugReportMsg: "Reporting an issue:\n\n\"%1\"\n\nError: %2\n\nThe issue persists even after loggin back in."
property var errorMessage: ""
Connections {
target: Backend
function onUserBadEvent(message) {
root.userBadEvent.description = message
function onUserBadEvent(description, errorMessage) {
root.userBadEvent.description = description
root.userBadEvent.errorMessage = errorMessage
root.userBadEvent.active = true
}
}
@ -1125,8 +1129,22 @@ QtObject {
onTriggered: {
root.userBadEvent.active = false
}
},
Action {
text: qsTr("Report")
onTriggered: {
root.frontendMain.showBugReportAndPrefill(
root.userBadEvent.bugReportMsg.
arg( root.userBadEvent.description).
arg(root.userBadEvent.errorMessage)
)
root.userBadEvent.active = false
}
}
]
}
property Notification genericError: Notification {
@ -1135,14 +1153,14 @@ QtObject {
icon: "./icons/ic-exclamation-circle-filled.svg"
type: Notification.NotificationType.Danger
group: Notifications.Group.Dialogs
Connections {
target: Backend
function onGenericError(title, description) {
root.genericError.title = title
root.genericError.description = description
root.genericError.active = true;
}
Connections {
target: Backend
function onGenericError(title, description) {
root.genericError.title = title
root.genericError.description = description
root.genericError.active = true;
}
}
action: [
Action {

View File

@ -71,20 +71,20 @@ std::mt19937_64 &rng() {
QString userConfigDir() {
QString dir;
#ifdef Q_OS_WIN
dir = qgetenv ("AppData");
dir = qEnvironmentVariable("AppData");
if (dir.isEmpty())
throw Exception("%AppData% is not defined.");
#elif defined(Q_OS_IOS) || defined(Q_OS_DARWIN)
dir = qgetenv("HOME");
dir = qEnvironmentVariable("HOME");
if (dir.isEmpty()) {
throw Exception("$HOME is not defined.");
}
dir += "/Library/Application Support";
#else
dir = qgetenv ("XDG_CONFIG_HOME");
dir = qEnvironmentVariable("XDG_CONFIG_HOME");
if (dir.isEmpty())
{
dir = qgetenv ("HOME");
dir = qEnvironmentVariable("HOME");
if (dir.isEmpty())
throw Exception("neither $XDG_CONFIG_HOME nor $HOME are defined");
dir += "/.config";
@ -104,20 +104,20 @@ QString userCacheDir() {
QString dir;
#ifdef Q_OS_WIN
dir = qgetenv ("LocalAppData");
dir = qEnvironmentVariable("LocalAppData");
if (dir.isEmpty())
throw Exception("%LocalAppData% is not defined.");
#elif defined(Q_OS_IOS) || defined(Q_OS_DARWIN)
dir = qgetenv("HOME");
dir = qEnvironmentVariable("HOME");
if (dir.isEmpty()) {
throw Exception("$HOME is not defined.");
}
dir += "/Library/Caches";
#else
dir = qgetenv ("XDG_CACHE_HOME");
dir = qEnvironmentVariable("XDG_CACHE_HOME");
if (dir.isEmpty())
{
dir = qgetenv ("HOME");
dir = qEnvironmentVariable("HOME");
if (dir.isEmpty())
throw Exception("neither $XDG_CACHE_HOME nor $HOME are defined");
dir += "/.cache";
@ -138,10 +138,10 @@ QString userDataDir() {
QString folder;
#ifdef Q_OS_LINUX
QString dir = qgetenv ("XDG_DATA_HOME");
QString dir = qEnvironmentVariable("XDG_DATA_HOME");
if (dir.isEmpty())
{
dir = qgetenv ("HOME");
dir = qEnvironmentVariable("HOME");
if (dir.isEmpty())
throw Exception("neither $XDG_DATA_HOME nor $HOME are defined");
dir += "/.local/share";

View File

@ -23,11 +23,13 @@ namespace bridgepp {
//****************************************************************************************************************************************************
/// \param[in] what A description of the exception
/// \param[in] what A description of the exception.
/// \param[in] details The optional details for the exception.
//****************************************************************************************************************************************************
Exception::Exception(QString what) noexcept
Exception::Exception(QString what, QString details) noexcept
: std::exception()
, what_(std::move(what)) {
, what_(std::move(what))
, details_(std::move(details)) {
}
@ -36,7 +38,8 @@ Exception::Exception(QString what) noexcept
//****************************************************************************************************************************************************
Exception::Exception(Exception const &ref) noexcept
: std::exception(ref)
, what_(ref.what_) {
, what_(ref.what_)
, details_(ref.details_) {
}
@ -45,14 +48,15 @@ Exception::Exception(Exception const &ref) noexcept
//****************************************************************************************************************************************************
Exception::Exception(Exception &&ref) noexcept
: std::exception(ref)
, what_(ref.what_) {
, what_(ref.what_)
, details_(ref.details_) {
}
//****************************************************************************************************************************************************
/// \return a string describing the exception
//****************************************************************************************************************************************************
QString const &Exception::qwhat() const noexcept {
QString Exception::qwhat() const noexcept {
return what_;
}
@ -65,4 +69,12 @@ const char *Exception::what() const noexcept {
}
//****************************************************************************************************************************************************
/// \return The details for the exception.
//****************************************************************************************************************************************************
QString Exception::details() const noexcept {
return details_;
}
} // namespace bridgepp

View File

@ -31,17 +31,19 @@ namespace bridgepp {
//****************************************************************************************************************************************************
class Exception : public std::exception {
public: // member functions
explicit Exception(QString what = QString()) noexcept; ///< Constructor
explicit Exception(QString what = QString(), QString details = QString()) noexcept; ///< Constructor
Exception(Exception const &ref) noexcept; ///< copy constructor
Exception(Exception &&ref) noexcept; ///< copy constructor
Exception &operator=(Exception const &) = delete; ///< Disabled assignment operator
Exception &operator=(Exception &&) = delete; ///< Disabled assignment operator
~Exception() noexcept override = default; ///< Destructor
QString const &qwhat() const noexcept; ///< Return the description of the exception as a QString
QString qwhat() const noexcept; ///< Return the description of the exception as a QString
const char *what() const noexcept override; ///< Return the description of the exception as C style string
QString details() const noexcept; ///< Return the details for the exception
private: // data members
QString const what_; ///< The description of the exception
QString const what_; ///< The description of the exception.
QString const details_; ///< The optional details for the exception.
};

View File

@ -88,8 +88,9 @@ GRPCConfig GRPCClient::waitAndRetrieveServiceConfig(qint64 timeoutMs, ProcessMon
}
GRPCConfig sc;
if (!sc.load(path)) {
throw Exception("The gRPC service configuration file is invalid.");
QString err;
if (!sc.load(path, &err)) {
throw Exception("The gRPC service configuration file is invalid.", err);
}
return sc;
@ -105,11 +106,10 @@ void GRPCClient::setLog(Log *log) {
//****************************************************************************************************************************************************
/// \param[out] outError If the function returns false, this variable contains a description of the error.
/// \param[in] serverProcess An optional server process to monitor. If the process it, no need and retry, as connexion cannot be established. Ignored if null.
/// \return true iff the connection was successful.
//****************************************************************************************************************************************************
bool GRPCClient::connectToServer(GRPCConfig const &config, ProcessMonitor *serverProcess, QString &outError) {
void GRPCClient::connectToServer(GRPCConfig const &config, ProcessMonitor *serverProcess) {
try {
serverToken_ = config.token.toStdString();
QString address;
@ -158,9 +158,10 @@ bool GRPCClient::connectToServer(GRPCConfig const &config, ProcessMonitor *serve
this->logInfo("Successfully connected to gRPC server.");
QString const clientToken = QUuid::createUuid().toString();
QString clientConfigPath = createClientConfigFile(clientToken);
QString error;
QString clientConfigPath = createClientConfigFile(clientToken, &error);
if (clientConfigPath.isEmpty()) {
throw Exception("gRPC client config could not be saved.");
throw Exception("gRPC client config could not be saved.", error);
}
this->logInfo(QString("Client config file was saved to '%1'").arg(QDir::toNativeSeparators(clientConfigPath)));
@ -176,12 +177,9 @@ bool GRPCClient::connectToServer(GRPCConfig const &config, ProcessMonitor *serve
}
log_->info("gRPC token was validated");
return true;
}
catch (Exception const &e) {
outError = e.qwhat();
return false;
throw Exception("Cannot connect to Go backend via gRPC: " + e.qwhat(), e.details());
}
}

View File

@ -59,7 +59,7 @@ public: // member functions.
GRPCClient &operator=(GRPCClient const &) = delete; ///< Disabled assignment operator.
GRPCClient &operator=(GRPCClient &&) = delete; ///< Disabled move assignment operator.
void setLog(Log *log); ///< Set the log for the client.
bool connectToServer(GRPCConfig const &config, class ProcessMonitor *serverProcess, QString &outError); ///< Establish connection to the gRPC server.
void connectToServer(GRPCConfig const &config, class ProcessMonitor *serverProcess); ///< Establish connection to the gRPC server.
grpc::Status checkTokens(QString const &clientConfigPath, QString &outReturnedClientToken); ///< Performs a token check.
grpc::Status addLogEntry(Log::Level level, QString const &package, QString const &message); ///< Performs the "AddLogEntry" gRPC call.

View File

@ -25,8 +25,7 @@ using namespace bridgepp;
namespace {
Exception const invalidFileException("The service configuration file is invalid"); // Exception for invalid config.
Exception const couldNotSaveException("The service configuration file could not be saved"); ///< Exception for write errors.
Exception const invalidFileException("The content of the service configuration file is invalid"); // Exception for invalid config.
QString const keyPort = "port"; ///< The JSON key for the port.
QString const keyCert = "cert"; ///< The JSON key for the TLS certificate.
QString const keyToken = "token"; ///< The JSON key for the identification token.
@ -78,8 +77,14 @@ qint32 jsonIntValue(QJsonObject const &object, QString const &key) {
bool GRPCConfig::load(QString const &path, QString *outError) {
try {
QFile file(path);
if (!file.exists())
throw Exception("The gRPC service configuration file does not exist.");
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
throw Exception("Could not open gRPC service config file.");
QThread::msleep(500); // we wait a bit and retry once, just in case server is not done writing/moving the config file.
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
throw Exception("The gRPC service configuration file exists but cannot be opened.");
}
}
QJsonDocument const doc = QJsonDocument::fromJson(file.readAll());
@ -93,7 +98,7 @@ bool GRPCConfig::load(QString const &path, QString *outError) {
}
catch (Exception const &e) {
if (outError) {
*outError = e.qwhat();
*outError = QString("Error loading gRPC service configuration file '%1'.\n%2").arg(QFileInfo(path).absoluteFilePath(), e.qwhat());
}
return false;
}
@ -115,19 +120,19 @@ bool GRPCConfig::save(QString const &path, QString *outError) {
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
throw couldNotSaveException;
throw Exception("The file could not be opened for writing.");
}
QByteArray const array = QJsonDocument(object).toJson();
if (array.size() != file.write(array)) {
throw couldNotSaveException;
throw Exception("An error occurred while writing to the file.");
}
return true;
}
catch (Exception const &e) {
if (outError) {
*outError = e.qwhat();
*outError = QString("Error saving gRPC service configuration file '%1'.\n%2").arg(QFileInfo(path).absoluteFilePath(), e.qwhat());
}
return false;
}

View File

@ -76,10 +76,12 @@ QString grpcClientConfigBasePath() {
//****************************************************************************************************************************************************
/// \param[in] token The token to put in the file.
/// \param[out] outError if the function returns an empty string and this pointer is not null, the pointer variable holds a description of the error
/// on exit.
/// \return The path of the created file.
/// \return A null string if the file could not be saved..
/// \return A null string if the file could not be saved.
//****************************************************************************************************************************************************
QString createClientConfigFile(QString const &token) {
QString createClientConfigFile(QString const &token, QString *outError) {
QString const basePath = grpcClientConfigBasePath();
QString path, error;
for (qint32 i = 0; i < 1000; ++i) // we try a decent amount of times
@ -88,13 +90,16 @@ QString createClientConfigFile(QString const &token) {
if (!QFileInfo(path).exists()) {
GRPCConfig config;
config.token = token;
if (!config.save(path)) {
if (!config.save(path, outError)) {
return QString();
}
return path;
}
}
if (outError)
*outError = "no usable client configuration file name could be found.";
return QString();
}

View File

@ -36,7 +36,7 @@ typedef std::shared_ptr<grpc::StreamEvent> SPStreamEvent; ///< Type definition f
QString grpcServerConfigPath(); ///< Return the path of the gRPC server config file.
QString grpcClientConfigBasePath(); ///< Return the path of the gRPC client config file.
QString createClientConfigFile(QString const &token); ///< Create the client config file the server will retrieve and return its path.
QString createClientConfigFile(QString const &token, QString *outError); ///< Create the client config file the server will retrieve and return its path.
grpc::LogLevel logLevelToGRPC(Log::Level level); ///< Convert a Log::Level to gRPC enum value.
Log::Level logLevelFromGRPC(grpc::LogLevel level); ///< Convert a grpc::LogLevel to a Log::Level.
grpc::UserState userStateToGRPC(UserState state); ///< Convert a bridgepp::UserState to a grpc::UserState.

View File

@ -96,9 +96,11 @@ func (s *Service) GuiReady(ctx context.Context, _ *emptypb.Empty) (*GuiReadyResp
s.initializationDone.Do(s.initializing.Done)
// Splash screen should be displayed only to users who start v3 for the first time after upgrading from v2.
// Splash screen should be displayed only to users who start v3.0.17 or later for the first time after upgrading from v2.
return &GuiReadyResponse{
ShowSplashScreen: (!s.bridge.GetFirstStart()) && s.bridge.GetLastVersion().LessThan(semver.MustParse("3.0.0")),
ShowSplashScreen: (!s.bridge.GetFirstStart()) &&
s.bridge.GetLastVersion().LessThan(semver.MustParse("3.0.0")) &&
s.bridge.GetCurrentVersion().GreaterThan(semver.MustParse("3.0.16")),
}, nil
}

View File

@ -18,6 +18,7 @@
package sentry
import (
"crypto/sha256"
"errors"
"fmt"
"log"
@ -25,6 +26,7 @@ import (
"runtime"
"time"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/pkg/restarter"
"github.com/getsentry/sentry-go"
@ -62,12 +64,21 @@ type Reporter struct {
appVersion string
identifier Identifier
hostArch string
serverName string
}
type Identifier interface {
GetUserAgent() string
}
func getProtectedHostname() string {
hostname, err := os.Hostname()
if err != nil {
return "Unknown"
}
return fmt.Sprintf("%x", sha256.Sum256([]byte(hostname)))
}
// NewReporter creates new sentry reporter with appName and appVersion to report.
func NewReporter(appName, appVersion string, identifier Identifier) *Reporter {
return &Reporter{
@ -75,6 +86,7 @@ func NewReporter(appName, appVersion string, identifier Identifier) *Reporter {
appVersion: appVersion,
identifier: identifier,
hostArch: getHostArch(),
serverName: getProtectedHostname(),
}
}
@ -126,11 +138,12 @@ func (r *Reporter) scopedReport(context map[string]interface{}, doReport func())
}
tags := map[string]string{
"OS": runtime.GOOS,
"Client": r.appName,
"Version": r.appVersion,
"UserAgent": r.identifier.GetUserAgent(),
"HostArch": r.hostArch,
"OS": runtime.GOOS,
"Client": r.appName,
"Version": r.appVersion,
"UserAgent": r.identifier.GetUserAgent(),
"HostArch": r.hostArch,
"server_name": r.serverName,
}
sentry.WithScope(func(scope *sentry.Scope) {
@ -151,6 +164,14 @@ func (r *Reporter) scopedReport(context map[string]interface{}, doReport func())
return nil
}
func ReportError(r reporter.Reporter, msg string, err error) {
if rerr := r.ReportMessageWithContext(msg, reporter.Context{
"error": err.Error(),
}); rerr != nil {
logrus.WithError(rerr).WithField("msg", msg).Error("Failed to send report")
}
}
// SkipDuringUnwind removes caller from the traceback.
func SkipDuringUnwind() {
pcs := make([]uintptr, 2)

View File

@ -23,6 +23,7 @@ import (
"fmt"
"net/http"
"github.com/ProtonMail/gluon"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/gluon/queue"
"github.com/ProtonMail/gluon/reporter"
@ -85,8 +86,10 @@ func (user *User) handleRefreshEvent(ctx context.Context, refresh proton.Refresh
l.WithError(err).Error("Failed to report refresh to sentry")
}
// Cancel and restart ongoing syncs.
user.abortable.Abort()
// Cancel the event stream once this refresh is done.
defer user.pollAbort.Abort()
// Resync after the refresh.
defer user.goSync()
return safe.LockRet(func() error {
@ -113,11 +116,8 @@ func (user *User) handleRefreshEvent(ctx context.Context, refresh proton.Refresh
user.apiAddrs = groupBy(apiAddrs, func(addr proton.Address) string { return addr.ID })
user.apiLabels = groupBy(apiLabels, func(label proton.Label) string { return label.ID })
// Reinitialize the update channels.
user.initUpdateCh(user.vault.AddressMode())
// Clear sync status; we want to sync everything again.
if err := user.vault.ClearSyncStatus(); err != nil {
if err := user.clearSyncStatus(); err != nil {
return fmt.Errorf("failed to clear sync status: %w", err)
}
@ -416,9 +416,7 @@ func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proto
switch event.Action {
case proton.EventCreate:
updates, err := user.handleCreateMessageEvent(
logging.WithLogrusField(ctx, "action", "create message"),
event)
updates, err := user.handleCreateMessageEvent(logging.WithLogrusField(ctx, "action", "create message"), event.Message)
if err != nil {
if rerr := user.reporter.ReportMessageWithContext("Failed to apply create message event", reporter.Context{
"error": err,
@ -447,6 +445,7 @@ func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proto
}); rerr != nil {
user.log.WithError(err).Error("Failed to report update draft message event error")
}
return fmt.Errorf("failed to handle update draft event: %w", err)
}
@ -454,7 +453,7 @@ func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proto
return err
}
return nil
continue
}
// GODT-2028 - Use better events here. It should be possible to have 3 separate events that refrain to
@ -463,7 +462,7 @@ func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proto
// Issue regular update to handle mailboxes and flag changes.
updates, err := user.handleUpdateMessageEvent(
logging.WithLogrusField(ctx, "action", "update message"),
event,
event.Message,
)
if err != nil {
if rerr := user.reporter.ReportMessageWithContext("Failed to apply update message event", reporter.Context{
@ -471,10 +470,23 @@ func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proto
}); rerr != nil {
user.log.WithError(err).Error("Failed to report update message event error")
}
return fmt.Errorf("failed to handle update message event: %w", err)
}
if err := waitOnIMAPUpdates(ctx, updates); err != nil {
// If the update fails on the gluon side because it doesn't exist, we try to create the message instead.
if err := waitOnIMAPUpdates(ctx, updates); gluon.IsNoSuchMessage(err) {
user.log.WithError(err).Error("Failed to handle update message event in gluon, will try creating it")
updates, err := user.handleCreateMessageEvent(ctx, event.Message)
if err != nil {
return fmt.Errorf("failed to handle update message event as create: %w", err)
}
if err := waitOnIMAPUpdates(ctx, updates); err != nil {
return err
}
} else if err != nil {
return err
}
@ -489,6 +501,7 @@ func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proto
}); rerr != nil {
user.log.WithError(err).Error("Failed to report delete message event error")
}
return fmt.Errorf("failed to handle delete message event: %w", err)
}
@ -501,12 +514,17 @@ func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proto
return nil
}
func (user *User) handleCreateMessageEvent(ctx context.Context, event proton.MessageEvent) ([]imap.Update, error) {
full, err := user.client.GetFullMessage(ctx, event.Message.ID)
func (user *User) handleCreateMessageEvent(ctx context.Context, message proton.MessageMetadata) ([]imap.Update, error) {
user.log.WithFields(logrus.Fields{
"messageID": message.ID,
"subject": logging.Sensitive(message.Subject),
}).Info("Handling message created event")
full, err := user.client.GetFullMessage(ctx, message.ID)
if err != nil {
// If the message is not found, it means that it has been deleted before we could fetch it.
if apiErr := new(proton.APIError); errors.As(err, &apiErr) && apiErr.Status == http.StatusUnprocessableEntity {
user.log.WithField("messageID", event.Message.ID).Warn("Cannot add new message: full message is missing on API")
user.log.WithField("messageID", message.ID).Warn("Cannot create new message: full message is missing on API")
return nil, nil
}
@ -514,19 +532,15 @@ func (user *User) handleCreateMessageEvent(ctx context.Context, event proton.Mes
}
return safe.RLockRetErr(func() ([]imap.Update, error) {
user.log.WithFields(logrus.Fields{
"messageID": event.ID,
"subject": logging.Sensitive(event.Message.Subject),
}).Info("Handling message created event")
var update imap.Update
if err := withAddrKR(user.apiUser, user.apiAddrs[event.Message.AddressID], user.vault.KeyPass(), func(_, addrKR *crypto.KeyRing) error {
if err := withAddrKR(user.apiUser, user.apiAddrs[message.AddressID], user.vault.KeyPass(), func(_, addrKR *crypto.KeyRing) error {
res := buildRFC822(user.apiLabels, full, addrKR)
if res.err != nil {
user.log.WithError(err).Error("Failed to build RFC822 message")
if err := user.vault.AddFailedMessageID(event.ID); err != nil {
if err := user.vault.AddFailedMessageID(message.ID); err != nil {
user.log.WithError(err).Error("Failed to add failed message ID to vault")
}
@ -540,7 +554,7 @@ func (user *User) handleCreateMessageEvent(ctx context.Context, event proton.Mes
return nil
}
if err := user.vault.RemFailedMessageID(event.ID); err != nil {
if err := user.vault.RemFailedMessageID(message.ID); err != nil {
user.log.WithError(err).Error("Failed to remove failed message ID from vault")
}
@ -556,21 +570,21 @@ func (user *User) handleCreateMessageEvent(ctx context.Context, event proton.Mes
}, user.apiUserLock, user.apiAddrsLock, user.apiLabelsLock, user.updateChLock)
}
func (user *User) handleUpdateMessageEvent(ctx context.Context, event proton.MessageEvent) ([]imap.Update, error) { //nolint:unparam
func (user *User) handleUpdateMessageEvent(ctx context.Context, message proton.MessageMetadata) ([]imap.Update, error) { //nolint:unparam
return safe.RLockRetErr(func() ([]imap.Update, error) {
user.log.WithFields(logrus.Fields{
"messageID": event.ID,
"subject": logging.Sensitive(event.Message.Subject),
"messageID": message.ID,
"subject": logging.Sensitive(message.Subject),
}).Info("Handling message updated event")
update := imap.NewMessageMailboxesUpdated(
imap.MessageID(event.ID),
mapTo[string, imap.MailboxID](wantLabels(user.apiLabels, event.Message.LabelIDs)),
event.Message.Seen(),
event.Message.Starred(),
imap.MessageID(message.ID),
mapTo[string, imap.MailboxID](wantLabels(user.apiLabels, message.LabelIDs)),
message.Seen(),
message.Starred(),
)
user.updateCh[event.Message.AddressID].Enqueue(update)
user.updateCh[message.AddressID].Enqueue(update)
return []imap.Update{update}, nil
}, user.apiLabelsLock, user.updateChLock)
@ -603,7 +617,7 @@ func (user *User) handleUpdateDraftEvent(ctx context.Context, event proton.Messa
if err != nil {
// If the message is not found, it means that it has been deleted before we could fetch it.
if apiErr := new(proton.APIError); errors.As(err, &apiErr) && apiErr.Status == http.StatusUnprocessableEntity {
user.log.WithField("messageID", event.Message.ID).Warn("Cannot add new draft: full message is missing on API")
user.log.WithField("messageID", event.Message.ID).Warn("Cannot update draft: full message is missing on API")
return nil, nil
}
@ -641,6 +655,7 @@ func (user *User) handleUpdateDraftEvent(ctx context.Context, event proton.Messa
res.update.Literal,
res.update.MailboxIDs,
res.update.ParsedMessage,
true, // Is the message doesn't exist, silently create it.
)
user.updateCh[full.AddressID].Enqueue(update)

View File

@ -287,12 +287,28 @@ func (conn *imapConnector) CreateMessage(
} else if ok {
conn.log.WithField("messageID", messageID).Warn("Message already sent")
message, err := conn.client.GetMessage(ctx, messageID)
// Query the server-side message.
full, err := conn.client.GetFullMessage(ctx, messageID)
if err != nil {
return imap.Message{}, nil, fmt.Errorf("failed to fetch message: %w", err)
}
return toIMAPMessage(message.MessageMetadata), nil, nil
// Build the message as it is on the server.
if err := safe.RLockRet(func() error {
return withAddrKR(conn.apiUser, conn.apiAddrs[full.AddressID], conn.vault.KeyPass(), func(_, addrKR *crypto.KeyRing) error {
var err error
if literal, err = message.BuildRFC822(addrKR, full.Message, full.AttData, defaultJobOpts()); err != nil {
return err
}
return nil
})
}, conn.apiUserLock, conn.apiAddrsLock); err != nil {
return imap.Message{}, nil, fmt.Errorf("failed to build message: %w", err)
}
return toIMAPMessage(full.MessageMetadata), literal, nil
}
wantLabelIDs := []string{string(mailboxID)}
@ -427,7 +443,7 @@ func (conn *imapConnector) MoveMessages(ctx context.Context, messageIDs []imap.M
result = result || true
}
if v, ok := conn.apiLabels[string(labelToID)]; ok && v.Type == proton.LabelTypeFolder {
if v, ok := conn.apiLabels[string(labelToID)]; ok && (v.Type == proton.LabelTypeFolder || v.Type == proton.LabelTypeSystem) {
result = result || true
}

View File

@ -23,6 +23,7 @@ import (
"encoding/base64"
"fmt"
"io"
"mime"
"net/mail"
"runtime"
"strings"
@ -119,7 +120,7 @@ func (user *User) sendMail(authID string, from string, to []string, r io.Reader)
}
// If we have to attach the public key, do it now.
if settings.AttachPublicKey == proton.AttachPublicKeyEnabled {
if settings.AttachPublicKey {
key, err := addrKR.GetKey(0)
if err != nil {
return fmt.Errorf("failed to get sending key: %w", err)
@ -419,7 +420,7 @@ func createAttachments(
attachment, err := client.UploadAttachment(ctx, addrKR, proton.CreateAttachmentReq{
Filename: att.Name,
MessageID: draftID,
MIMEType: rfc822.MIMEType(att.MIMEType),
MIMEType: rfc822.MIMEType(mime.FormatMediaType(att.MIMEType, att.MIMEParams)),
Disposition: att.Disposition,
ContentID: att.ContentID,
Body: att.Data,

View File

@ -463,6 +463,11 @@ func wantLabel(label proton.Label) bool {
func wantLabels(apiLabels map[string]proton.Label, labelIDs []string) []string {
return xslices.Filter(labelIDs, func(labelID string) bool {
return wantLabel(apiLabels[labelID])
apiLabel, ok := apiLabels[labelID]
if !ok {
return false
}
return wantLabel(apiLabel)
})
}

View File

@ -20,9 +20,11 @@ package user
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"strings"
"sync/atomic"
"time"
@ -32,6 +34,7 @@ import (
"github.com/ProtonMail/gluon/queue"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal"
"github.com/ProtonMail/proton-bridge/v3/internal/async"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/logging"
@ -78,7 +81,8 @@ type User struct {
updateChLock safe.RWMutex
tasks *async.Group
abortable async.Abortable
syncAbort async.Abortable
pollAbort async.Abortable
goSync func()
pollAPIEventsCh chan chan struct{}
@ -171,42 +175,6 @@ func New(
return nil
})
// Stream events from the API, logging any errors that occur.
// This does nothing until the sync has been marked as complete.
// When we receive an API event, we attempt to handle it.
// If successful, we update the event ID in the vault.
user.tasks.Once(func(ctx context.Context) {
ticker := proton.NewTicker(EventPeriod, EventJitter)
defer ticker.Stop()
for {
var doneCh chan struct{}
select {
case <-ctx.Done():
return
case doneCh = <-user.pollAPIEventsCh:
// ...
case <-ticker.C:
// ...
}
user.log.Debug("Event poll triggered")
if !user.vault.SyncStatus().IsComplete() {
user.log.Debug("Sync is incomplete, skipping event poll")
} else if err := user.doEventPoll(ctx); err != nil {
user.log.WithError(err).Error("Failed to poll events")
}
if doneCh != nil {
close(doneCh)
}
}
})
// When triggered, poll the API for events, optionally blocking until the poll is complete.
user.goPollAPIEvents = func(wait bool) {
doneCh := make(chan struct{})
@ -218,26 +186,46 @@ func New(
}
}
// When triggered, attempt to sync the user.
// When triggered, sync the user and then begin streaming API events.
user.goSync = user.tasks.Trigger(func(ctx context.Context) {
user.log.Debug("Sync triggered")
user.log.Info("Sync triggered")
user.abortable.Do(ctx, func(ctx context.Context) {
// Sync the user.
user.syncAbort.Do(ctx, func(ctx context.Context) {
if user.vault.SyncStatus().IsComplete() {
user.log.Debug("Sync is already complete, skipping")
} else if err := user.doSync(ctx); err != nil {
user.log.WithError(err).Error("Failed to sync, will retry later")
time.AfterFunc(SyncRetryCooldown, user.goSync)
user.log.Info("Sync already complete, skipping")
return
}
for {
if err := ctx.Err(); err != nil {
user.log.WithError(err).Error("Sync aborted")
return
} else if err := user.doSync(ctx); err != nil {
user.log.WithError(err).Error("Failed to sync, will retry later")
sleepCtx(ctx, SyncRetryCooldown)
} else {
user.log.Info("Sync complete, starting API event stream")
return
}
}
})
// Once we know the sync has completed, we can start polling for API events.
if user.vault.SyncStatus().IsComplete() {
user.pollAbort.Do(ctx, func(ctx context.Context) {
user.startEvents(ctx)
})
}
})
// Trigger an initial sync (if necessary).
user.goSync()
return user, nil
}
func (user *User) TriggerSync() {
user.goSync()
}
// ID returns the user's ID.
func (user *User) ID() string {
return safe.RLockRet(func() string {
@ -294,22 +282,21 @@ func (user *User) GetAddressMode() vault.AddressMode {
func (user *User) SetAddressMode(_ context.Context, mode vault.AddressMode) error {
user.log.WithField("mode", mode).Info("Setting address mode")
user.abortable.Abort()
user.syncAbort.Abort()
user.pollAbort.Abort()
defer user.goSync()
return safe.LockRet(func() error {
user.initUpdateCh(mode)
if err := user.vault.SetAddressMode(mode); err != nil {
return fmt.Errorf("failed to set address mode: %w", err)
}
if err := user.vault.ClearSyncStatus(); err != nil {
if err := user.clearSyncStatus(); err != nil {
return fmt.Errorf("failed to clear sync status: %w", err)
}
return nil
}, user.apiAddrsLock, user.updateChLock)
}, user.eventLock, user.apiAddrsLock, user.updateChLock)
}
// SetShowAllMail sets whether to show the All Mail mailbox.
@ -326,12 +313,25 @@ func (user *User) GetGluonIDs() map[string]string {
// GetGluonID returns the gluon ID for the given address, if present.
func (user *User) GetGluonID(addrID string) (string, bool) {
gluonID, ok := user.vault.GetGluonIDs()[addrID]
if !ok {
if gluonID, ok := user.vault.GetGluonIDs()[addrID]; ok {
return gluonID, true
}
if user.vault.AddressMode() != vault.CombinedMode {
return "", false
}
return gluonID, true
// If there is only one address, return its gluon ID.
// This can happen if we are in combined mode and the primary address ID has changed.
if gluonIDs := maps.Values(user.vault.GetGluonIDs()); len(gluonIDs) == 1 {
if err := user.vault.SetGluonID(addrID, gluonIDs[0]); err != nil {
user.log.WithError(err).Error("Failed to set gluon ID for updated primary address")
}
return gluonIDs[0], true
}
return "", false
}
// SetGluonID sets the gluon ID for the given address.
@ -419,7 +419,9 @@ func (user *User) NewIMAPConnectors() (map[string]connector.Connector, error) {
//
// nolint:funlen
func (user *User) SendMail(authID string, from string, to []string, r io.Reader) error {
defer user.goPollAPIEvents(true)
if user.vault.SyncStatus().IsComplete() {
defer user.goPollAPIEvents(true)
}
if len(to) == 0 {
return ErrInvalidRecipient
@ -472,15 +474,40 @@ func (user *User) OnStatusUp(context.Context) {
func (user *User) OnStatusDown(context.Context) {
user.log.Info("Connection is down")
user.abortable.Abort()
user.syncAbort.Abort()
user.pollAbort.Abort()
}
// ClearSyncStatus clears the sync status of the user. This triggers a resync.
func (user *User) ClearSyncStatus() error {
user.abortable.Abort()
defer user.goSync()
// GetSyncStatus returns the sync status of the user.
func (user *User) GetSyncStatus() vault.SyncStatus {
return user.vault.GetSyncStatus()
}
return user.vault.ClearSyncStatus()
// ClearSyncStatus clears the sync status of the user.
// This also drops any updates in the update channel(s).
// Warning: the gluon user must be removed and re-added if this happens!
func (user *User) ClearSyncStatus() error {
user.log.Info("Clearing sync status")
return safe.LockRet(func() error {
return user.clearSyncStatus()
}, user.eventLock, user.apiAddrsLock, user.updateChLock)
}
// clearSyncStatus clears the sync status of the user.
// This also drops any updates in the update channel(s).
// Warning: the gluon user must be removed and re-added if this happens!
// It is assumed that the eventLock, apiAddrsLock and updateChLock are already locked.
func (user *User) clearSyncStatus() error {
user.log.Info("Clearing sync status")
user.initUpdateCh(user.vault.AddressMode())
if err := user.vault.ClearSyncStatus(); err != nil {
return fmt.Errorf("failed to clear sync status: %w", err)
}
return nil
}
// Logout logs the user out from the API.
@ -558,14 +585,50 @@ func (user *User) initUpdateCh(mode vault.AddressMode) {
}
}
// startEvents streams events from the API, logging any errors that occur.
// This does nothing until the sync has been marked as complete.
// When we receive an API event, we attempt to handle it.
// If successful, we update the event ID in the vault.
func (user *User) startEvents(ctx context.Context) {
ticker := proton.NewTicker(EventPeriod, EventJitter)
defer ticker.Stop()
for {
var doneCh chan struct{}
select {
case <-ctx.Done():
return
case doneCh = <-user.pollAPIEventsCh:
// ...
case <-ticker.C:
// ...
}
user.log.Debug("Event poll triggered")
if err := user.doEventPoll(ctx); err != nil {
user.log.WithError(err).Error("Failed to poll events")
}
if doneCh != nil {
close(doneCh)
}
}
}
// doEventPoll is called whenever API events should be polled.
//
//nolint:funlen
func (user *User) doEventPoll(ctx context.Context) error {
user.eventLock.Lock()
defer user.eventLock.Unlock()
event, err := user.client.GetEvent(ctx, user.vault.EventID())
if err != nil {
return fmt.Errorf("failed to get event: %w", err)
return fmt.Errorf("failed to get event (caused by %T): %w", internal.ErrCause(err), err)
}
// If the event ID hasn't changed, there are no new events.
@ -581,11 +644,46 @@ func (user *User) doEventPoll(ctx context.Context) error {
// Handle the event.
if err := user.handleAPIEvent(ctx, event); err != nil {
// If the error is a context cancellation, return error to retry later.
if errors.Is(err, context.Canceled) {
return fmt.Errorf("failed to handle event due to context cancellation: %w", err)
}
// If the error is a network error, return error to retry later.
if netErr := new(proton.NetError); errors.As(err, &netErr) {
return fmt.Errorf("failed to handle event due to network issue: %w", err)
}
// Catch all for uncategorized net errors that may slip through.
if netErr := new(net.OpError); errors.As(err, &netErr) {
user.eventCh.Enqueue(events.UncategorizedEventError{
UserID: user.ID(),
Error: err,
})
return fmt.Errorf("failed to handle event due to network issues (uncategorized): %w", err)
}
// In case a json decode error slips through.
if jsonErr := new(json.UnmarshalTypeError); errors.As(err, &jsonErr) {
user.eventCh.Enqueue(events.UncategorizedEventError{
UserID: user.ID(),
Error: err,
})
return fmt.Errorf("failed to handle event due to JSON issue: %w", err)
}
// If the error is an unexpected EOF, return error to retry later.
if errors.Is(err, io.ErrUnexpectedEOF) {
user.eventCh.Enqueue(events.UncategorizedEventError{
UserID: user.ID(),
Error: err,
})
return fmt.Errorf("failed to handle event due to EOF: %w", err)
}
// If the error is a server-side issue, return error to retry later.
if apiErr := new(proton.APIError); errors.As(err, &apiErr) && apiErr.Status >= 500 {
return fmt.Errorf("failed to handle event due to server error: %w", err)
@ -627,3 +725,11 @@ func b32(b bool) uint32 {
return 0
}
// sleepCtx sleeps for the given duration, or until the context is canceled.
func sleepCtx(ctx context.Context, d time.Duration) {
select {
case <-ctx.Done():
case <-time.After(d):
}
}

View File

@ -22,16 +22,12 @@ import (
"testing"
"time"
"github.com/ProtonMail/gluon/connector"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/go-proton-api/server"
"github.com/ProtonMail/go-proton-api/server/backend"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge/mocks"
"github.com/ProtonMail/proton-bridge/v3/internal/certs"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/tests"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
)
@ -70,101 +66,15 @@ func TestUser_Info(t *testing.T) {
})
}
func TestUser_Sync(t *testing.T) {
withAPI(t, context.Background(), func(ctx context.Context, s *server.Server, m *proton.Manager) {
withAccount(t, s, "username", "password", []string{}, func(string, []string) {
withUser(t, ctx, s, m, "username", "password", func(user *User) {
// User starts a sync at startup.
require.IsType(t, events.SyncStarted{}, <-user.GetEventCh())
// User sends sync progress.
require.IsType(t, events.SyncProgress{}, <-user.GetEventCh())
// User finishes a sync at startup.
require.IsType(t, events.SyncFinished{}, <-user.GetEventCh())
})
})
})
}
func TestUser_AddressMode(t *testing.T) {
withAPI(t, context.Background(), func(ctx context.Context, s *server.Server, m *proton.Manager) {
withAccount(t, s, "username", "password", []string{}, func(string, []string) {
withUser(t, ctx, s, m, "username", "password", func(user *User) {
// User finishes syncing at startup.
require.IsType(t, events.SyncStarted{}, <-user.GetEventCh())
require.IsType(t, events.SyncProgress{}, <-user.GetEventCh())
require.IsType(t, events.SyncFinished{}, <-user.GetEventCh())
// By default, user should be in combined mode.
require.Equal(t, vault.CombinedMode, user.GetAddressMode())
// User should be able to switch to split mode.
require.NoError(t, user.SetAddressMode(ctx, vault.SplitMode))
// Create a new set of IMAP connectors (after switching to split mode).
imapConn, err := user.NewIMAPConnectors()
require.NoError(t, err)
// Process updates from the new set of IMAP connectors.
for _, imapConn := range imapConn {
go func(imapConn connector.Connector) {
for update := range imapConn.GetUpdates() {
update.Done(nil)
}
}(imapConn)
}
// User finishes syncing after switching to split mode.
require.IsType(t, events.SyncStarted{}, <-user.GetEventCh())
require.IsType(t, events.SyncProgress{}, <-user.GetEventCh())
require.IsType(t, events.SyncFinished{}, <-user.GetEventCh())
})
})
})
}
func TestUser_Deauth(t *testing.T) {
withAPI(t, context.Background(), func(ctx context.Context, s *server.Server, m *proton.Manager) {
withAccount(t, s, "username", "password", []string{}, func(string, []string) {
withUser(t, ctx, s, m, "username", "password", func(user *User) {
require.IsType(t, events.SyncStarted{}, <-user.GetEventCh())
require.IsType(t, events.SyncProgress{}, <-user.GetEventCh())
require.IsType(t, events.SyncFinished{}, <-user.GetEventCh())
// Revoke the user's auth token.
require.NoError(t, s.RevokeUser(user.ID()))
// The user should eventually be logged out.
require.Eventually(t, func() bool { _, ok := (<-user.GetEventCh()).(events.UserDeauth); return ok }, 500*time.Second, 100*time.Millisecond)
})
})
})
}
func TestUser_Refresh(t *testing.T) {
ctl := gomock.NewController(t)
mockReporter := mocks.NewMockReporter(ctl)
withAPI(t, context.Background(), func(ctx context.Context, s *server.Server, m *proton.Manager) {
withAccount(t, s, "username", "password", []string{}, func(string, []string) {
withUser(t, ctx, s, m, "username", "password", func(user *User) {
require.IsType(t, events.SyncStarted{}, <-user.GetEventCh())
require.IsType(t, events.SyncProgress{}, <-user.GetEventCh())
require.IsType(t, events.SyncFinished{}, <-user.GetEventCh())
user.reporter = mockReporter
mockReporter.EXPECT().ReportMessageWithContext(
gomock.Eq("Warning: refresh occurred"),
mocks.NewRefreshContextMatcher(proton.RefreshAll),
).Return(nil)
// Send refresh event
require.NoError(t, s.RefreshUser(user.ID(), proton.RefreshAll))
// The user should eventually be re-synced.
require.Eventually(t, func() bool { _, ok := (<-user.GetEventCh()).(events.UserRefreshed); return ok }, 5*time.Second, 100*time.Millisecond)
})
})
})
@ -220,16 +130,5 @@ func withUser(tb testing.TB, ctx context.Context, _ *server.Server, m *proton.Ma
require.NoError(tb, err)
defer user.Close()
imapConn, err := user.NewIMAPConnectors()
require.NoError(tb, err)
for _, imapConn := range imapConn {
go func(imapConn connector.Connector) {
for update := range imapConn.GetUpdates() {
update.Done(nil)
}
}(imapConn)
}
fn(user)
}

View File

@ -18,8 +18,16 @@
package vault
import (
"math"
"math/rand"
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
"github.com/sirupsen/logrus"
)
const (
ForbiddenRollout = 0.6046602879796196
)
// GetIMAPPort sets the port that the IMAP server should listen on.
@ -96,7 +104,17 @@ func (vault *Vault) SetUpdateChannel(channel updater.Channel) error {
// GetUpdateRollout sets the update rollout.
func (vault *Vault) GetUpdateRollout() float64 {
return vault.get().Settings.UpdateRollout
// The rollout value 0.6046602879796196 is forbidden. The RNG was not seeded when it was picked (GODT-2319).
rollout := vault.get().Settings.UpdateRollout
if math.Abs(rollout-ForbiddenRollout) >= 0.00000001 {
return rollout
}
rollout = rand.Float64() //nolint:gosec
if err := vault.SetUpdateRollout(rollout); err != nil {
logrus.WithError(err).Warning("Failed writing updateRollout value in vault")
}
return rollout
}
// SetUpdateRollout sets the update rollout.

View File

@ -18,6 +18,7 @@
package vault_test
import (
"math"
"testing"
"github.com/Masterminds/semver/v3"
@ -103,6 +104,10 @@ func TestVault_Settings_UpdateRollout(t *testing.T) {
// Check the new update rollout.
require.Equal(t, float64(0.5), s.GetUpdateRollout())
// Since GODT-2319 0.6046602879796196 is not allowed as a rollout value (RNG was not seeded)
require.NoError(t, s.SetUpdateRollout(vault.ForbiddenRollout))
require.GreaterOrEqual(t, math.Abs(s.GetUpdateRollout()-vault.ForbiddenRollout), 0.00000001)
}
func TestVault_Settings_ColorScheme(t *testing.T) {

View File

@ -197,6 +197,11 @@ func (user *User) RemFailedMessageID(messageID string) error {
})
}
// GetSyncStatus returns the user's sync status.
func (user *User) GetSyncStatus() SyncStatus {
return user.vault.getUser(user.userID).SyncStatus
}
// ClearSyncStatus clears the user's sync status.
func (user *User) ClearSyncStatus() error {
return user.vault.modUser(user.userID, func(data *UserData) {

View File

@ -66,6 +66,7 @@ type Attachment struct {
Name string
ContentID string
MIMEType string
MIMEParams map[string]string
Disposition proton.Disposition
Data []byte
}
@ -523,6 +524,7 @@ func parseAttachment(h message.Header, body []byte) (Attachment, error) {
return Attachment{}, err
}
att.MIMEType = mimeType
att.MIMEParams = mimeTypeParams
// Prefer attachment name from filename param in content disposition.
// If not available, try to get it from name param in content type.

View File

@ -186,9 +186,17 @@ func (p *Part) isMultipartMixed() bool {
func getContentHeaders(header message.Header) message.Header {
var res message.Header
res.Set("Content-Type", header.Get("Content-Type"))
res.Set("Content-Disposition", header.Get("Content-Disposition"))
res.Set("Content-Transfer-Encoding", header.Get("Content-Transfer-Encoding"))
if contentType := header.Get("Content-Type"); contentType != "" {
res.Set("Content-Type", contentType)
}
if contentDisposition := header.Get("Content-Disposition"); contentDisposition != "" {
res.Set("Content-Disposition", contentDisposition)
}
if contentTransferEncoding := header.Get("Content-Transfer-Encoding"); contentTransferEncoding != "" {
res.Set("Content-Transfer-Encoding", contentTransferEncoding)
}
return res
}

View File

@ -64,6 +64,29 @@ Feature: IMAP move messages
| john.doe@mail.com | [user:user]@[domain] | baz | false |
And IMAP client "1" sees 0 messages in "Labels/label2"
Scenario: Move message from system label to system label
When IMAP client "1" moves the message with subject "foo" from "INBOX" to "Trash"
And it succeeds
And IMAP client "1" sees the following messages in "INBOX":
| from | to | subject | unread |
| jane.doe@mail.com | name@[domain] | bar | true |
And IMAP client "1" sees the following messages in "Trash":
| from | to | subject | unread |
| john.doe@mail.com | [user:user]@[domain] | foo | false |
Scenario: Move message from folder to system label
When IMAP client "1" moves the message with subject "baz" from "Labels/label2" to "Folders/mbox"
And it succeeds
And IMAP client "1" sees the following messages in "Folders/mbox":
| from | to | subject | unread |
| john.doe@mail.com | [user:user]@[domain] | baz | false |
When IMAP client "1" moves the message with subject "baz" from "Folders/mbox" to "Trash"
And it succeeds
And IMAP client "1" sees 0 messages in "Folders/mbox"
And IMAP client "1" sees the following messages in "Trash":
| from | to | subject | unread |
| john.doe@mail.com | [user:user]@[domain] | baz | false |
Scenario: Move message from All Mail is not possible
When IMAP client "1" moves the message with subject "baz" from "All Mail" to "Folders/folder"
Then it fails