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/) 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 ## [Bridge 3.0.12] Perth Narrows
### Added ### 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 .PHONY: build build-gui build-nogui build-launcher versioner hasher
# Keep version hardcoded so app build works also without Git repository. # 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_VERSION:=${BRIDGE_APP_VERSION}
APP_FULL_NAME:=Proton Mail Bridge APP_FULL_NAME:=Proton Mail Bridge
APP_VENDOR:=Proton AG APP_VENDOR:=Proton AG
@ -294,7 +294,7 @@ gofiles: ./internal/bridge/credits.go
cd ./utils/ && ./credits.sh bridge cd ./utils/ && ./credits.sh bridge
## Run and debug ## 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?=debug
LOG_IMAP?=client # client/server/all, or empty to turn it off 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: run-debug:
dlv debug ./cmd/Desktop-Bridge/main.go -- -l=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: clean-vendor:
rm -rf ./vendor rm -rf ./vendor

4
go.mod
View File

@ -5,9 +5,9 @@ go 1.18
require ( require (
github.com/0xAX/notificator v0.0.0-20220220101646-ee9b8921e557 github.com/0xAX/notificator v0.0.0-20220220101646-ee9b8921e557
github.com/Masterminds/semver/v3 v3.1.1 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-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/go-rfc5322 v0.11.0
github.com/ProtonMail/gopenpgp/v2 v2.4.10 github.com/ProtonMail/gopenpgp/v2 v2.4.10
github.com/PuerkitoBio/goquery v1.8.0 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/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 h1:+kvUIpwWcbtP3WFv5sSvkFn/XLzSqPOB5AAthuk9xPk=
github.com/ProtonMail/docker-credential-helpers v1.1.0/go.mod h1:mK0aBveCxhnQ756AmaTfXMZDeULvheYVhF/MWMErN5g= 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.20230227135029-cef8f5824680 h1:NGp7LfbsKePRHBgMcgquycHx3CSuS7255i0wanAiCuY=
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/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 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-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= 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-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 h1:4IWzKjHzZxdrW9k4zl/qCwenOVHDbVDADPPHFLjs0Oc=
github.com/ProtonMail/go-mime v0.0.0-20220429130430-2192574d760f/go.mod h1:qRZgbeASl2a9OwmsV85aWwRqic0NHPh+9ewGAzb4cgM= 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.20230209110241-fe7894c4931a h1:h9KLPt0HTCJjILYHREWCYnZv+1xaYmOVx/rxiT/1dIg=
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/go.mod h1:JUo5IQG0hNuPRuDpOUsCOvtee6UjTEHHF1QN2i8RSos=
github.com/ProtonMail/go-rfc5322 v0.11.0 h1:o5Obrm4DpmQEffvgsVqG6S4BKwC1Wat+hYwjIp2YcCY= 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-rfc5322 v0.11.0/go.mod h1:6oOKr0jXvpoE6pwTx/HukigQpX2J9WUf6h0auplrFTw=
github.com/ProtonMail/go-srp v0.0.5 h1:xhUioxZgDbCnpo9JehyFhwwsn9JLWkUGfB0oiKXgiGg= github.com/ProtonMail/go-srp v0.0.5 h1:xhUioxZgDbCnpo9JehyFhwwsn9JLWkUGfB0oiKXgiGg=

View File

@ -19,11 +19,13 @@ package app
import ( import (
"fmt" "fmt"
"math/rand"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"time"
"github.com/Masterminds/semver/v3" "github.com/Masterminds/semver/v3"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge" "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 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. // Get the current bridge version.
version, err := semver.NewVersion(constants.Version) version, err := semver.NewVersion(constants.Version)
if err != nil { if err != nil {

View File

@ -21,6 +21,7 @@ package bridge
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
@ -38,6 +39,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/events" "github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/focus" "github.com/ProtonMail/proton-bridge/v3/internal/focus"
"github.com/ProtonMail/proton-bridge/v3/internal/safe" "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/user"
"github.com/ProtonMail/proton-bridge/v3/internal/vault" "github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/bradenaw/juniper/xslices" "github.com/bradenaw/juniper/xslices"
@ -376,10 +378,11 @@ func (bridge *Bridge) init(tlsReporter TLSReporter) error {
// Attempt to lazy load users when triggered. // Attempt to lazy load users when triggered.
bridge.goLoad = bridge.tasks.Trigger(func(ctx context.Context) { bridge.goLoad = bridge.tasks.Trigger(func(ctx context.Context) {
logrus.Info("Loading users")
if err := bridge.loadUsers(ctx); err != nil { if err := bridge.loadUsers(ctx); err != nil {
logrus.WithError(err).Error("Failed to load users") 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 { } else {
bridge.publish(events.AllUsersLoaded{}) 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. // 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) { func withEnv(t *testing.T, tests func(context.Context, *server.Server, *proton.NetCtl, bridge.Locator, []byte), opts ...server.Option) {
server := server.New(opts...) 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. // addIMAPUser connects the given user to gluon.
//
//nolint:funlen
func (bridge *Bridge) addIMAPUser(ctx context.Context, user *user.User) error { func (bridge *Bridge) addIMAPUser(ctx context.Context, user *user.User) error {
if bridge.imapServer == nil { if bridge.imapServer == nil {
return fmt.Errorf("no imap server instance running") 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) 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 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") 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 { if err := user.ClearSyncStatus(); err != nil {
return fmt.Errorf("failed to clear sync status: %w", err) 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 { } else {
log.Info("Creating new IMAP user") 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 return nil
} }

View File

@ -18,10 +18,12 @@
package bridge_test package bridge_test
import ( import (
"bytes"
"context" "context"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"net" "net"
"os"
"strings" "strings"
"testing" "testing"
"time" "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. // loadUsers tries to load each user in the vault that isn't already loaded.
func (bridge *Bridge) loadUsers(ctx context.Context) error { func (bridge *Bridge) loadUsers(ctx context.Context) error {
logrus.WithField("count", len(bridge.vault.GetUserIDs())).Info("Loading users") 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 { return bridge.vault.ForUser(runtime.NumCPU(), func(user *vault.User) error {
log := logrus.WithField("userID", user.UserID()) log := logrus.WithField("userID", user.UserID())
if user.AuthUID() == "" { if user.AuthUID() == "" {
log.Info("Not loading disconnected user") log.Info("User is not connected (skipping)")
return nil return nil
} }
if safe.RLockRet(func() bool { return mapHas(bridge.users, user.UserID()) }, bridge.usersLock) { 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 return nil
} }

View File

@ -22,6 +22,7 @@ import (
"fmt" "fmt"
"github.com/ProtonMail/gluon/reporter" "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/events"
"github.com/ProtonMail/proton-bridge/v3/internal/safe" "github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/user" "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: case events.UserBadEvent:
bridge.handleUserBadEvent(ctx, user, event.Error) bridge.handleUserBadEvent(ctx, user, event.Error)
case events.UncategorizedEventError:
bridge.handleUncategorizedErrorEvent(event)
} }
return nil 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) { func (bridge *Bridge) handleUserBadEvent(ctx context.Context, user *user.User, err error) {
safe.Lock(func() { safe.Lock(func() {
if rerr := bridge.reporter.ReportMessageWithContext("Failed to handle event", reporter.Context{ 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 { }); rerr != nil {
logrus.WithError(rerr).Error("Failed to report failed event handling") 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.logoutUser(ctx, user, true, false)
}, bridge.usersLock) }, 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 { func (event AddressModeChanged) String() string {
return fmt.Sprintf("AddressModeChanged: UserID: %s, AddressMode: %s", event.UserID, event.AddressMode) 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] function The function that caught the exception.
/// \param[in] message The error message. /// \param[in] message The error message.
/// \param[in] details The details for the error.
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
void AppController::onFatalError(QString const &function, QString const &message) { void AppController::onFatalError(QString const &function, QString const &message, QString const& details) {
QString const fullMessage = QString("%1(): %2").arg(function, message); QString fullMessage = QString("%1(): %2").arg(function, message);
reportSentryException(SENTRY_LEVEL_ERROR, "AppController got notified of a fatal error", "Exception", fullMessage.toLocal8Bit()); 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); QMessageBox::critical(nullptr, tr("Error"), message);
restart(true); restart(true);
log().fatal(fullMessage); log().fatal(QString("reportID: %1 Captured exception: %2").arg(QByteArray(uuid.bytes, 16).toHex(), fullMessage));
qApp->exit(EXIT_FAILURE); qApp->exit(EXIT_FAILURE);
} }

View File

@ -58,7 +58,7 @@ public: // member functions.
void setLauncherArgs(const QString& launcher, const QStringList& args); void setLauncherArgs(const QString& launcher, const QStringList& args);
public slots: 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 private: // member functions
AppController(); ///< Default constructor. AppController(); ///< Default constructor.

View File

@ -25,8 +25,8 @@
#define HANDLE_EXCEPTION(x) try { x } \ #define HANDLE_EXCEPTION(x) try { x } \
catch (Exception const &e) { emit fatalError(__func__, e.qwhat()); } \ catch (Exception const &e) { emit fatalError(__func__, e.qwhat(), e.details()); } \
catch (...) { emit fatalError(__func__, QString("An unknown exception occurred")); } 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_BOOL(x) HANDLE_EXCEPTION(x) return false;
#define HANDLE_EXCEPTION_RETURN_QSTRING(x) HANDLE_EXCEPTION(x) return QString(); #define HANDLE_EXCEPTION_RETURN_QSTRING(x) HANDLE_EXCEPTION(x) return QString();
#define HANDLE_EXCEPTION_RETURN_ZERO(x) HANDLE_EXCEPTION(x) return 0; #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); app().grpc().setLog(&log);
this->connectGrpcEvents(); this->connectGrpcEvents();
QString error; app().grpc().connectToServer(serviceConfig, app().bridgeMonitor());
if (app().grpc().connectToServer(serviceConfig, app().bridgeMonitor(), error)) { app().log().info("Connected to backend via gRPC service.");
app().log().info("Connected to backend via gRPC service.");
} else {
throw Exception(QString("Cannot connectToServer to go backend via gRPC: %1").arg(error));
}
QString bridgeVer; QString bridgeVer;
app().grpc().version(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 { void QMLBackend::login(QString const &username, QString const &password) const {
HANDLE_EXCEPTION( HANDLE_EXCEPTION(
if (username.compare("coco@bandicoot", Qt::CaseInsensitive) == 0) { 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); app().grpc().login(username, password);
) )
@ -874,12 +871,14 @@ void QMLBackend::onLoginAlreadyLoggedIn(QString const &userID) {
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
void QMLBackend::onUserBadEvent(QString const &userID, QString const &errorMessage) { void QMLBackend::onUserBadEvent(QString const &userID, QString const &errorMessage) {
HANDLE_EXCEPTION( HANDLE_EXCEPTION(
Q_UNUSED(errorMessage);
SPUser const user = users_->getUserWithID(userID); SPUser const user = users_->getUserWithID(userID);
if (!user) if (!user)
app().log().error(QString("Received bad event for unknown user %1").arg(user->id())); app().log().error(QString("Received bad event for unknown user %1").arg(user->id()));
user->setState(UserState::SignedOut); 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 selectUser(userID);
emit showMainWindow(); 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 addressChangedLogout(QString const &address); ///< Signal for the 'addressChangedLogout' gRPC stream event.
void apiCertIssue(); ///< Signal for the 'apiCertIssue' 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 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 internetOff(); ///< Signal for the 'internetOff' gRPC stream event.
void internetOn(); ///< Signal for the 'internetOn' gRPC stream event. void internetOn(); ///< Signal for the 'internetOn' gRPC stream event.
void resetFinished(); ///< Signal for the 'resetFinished' 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. 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. // 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 private: // member functions
void retrieveUserList(); ///< Retrieve the list of users via gRPC. 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/>. // along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
#include "SentryUtils.h" #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"; 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); 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); auto event = sentry_value_new_message_event(level, LoggerName, message);
sentry_event_add_exception(event, sentry_value_new_exception(exceptionType, exception)); 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> #include <sentry.h>
void setSentryReportScope();
void reportSentryEvent(sentry_level_t level, const char *message); sentry_uuid_t reportSentryEvent(sentry_level_t level, const char *message);
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);
#endif //BRIDGE_GUI_SENTRYUTILS_H #endif //BRIDGE_GUI_SENTRYUTILS_H

View File

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

View File

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

View File

@ -28,6 +28,7 @@
#include <bridgepp/Log/Log.h> #include <bridgepp/Log/Log.h>
#include <bridgepp/ProcessMonitor.h> #include <bridgepp/ProcessMonitor.h>
#include <sentry.h> #include <sentry.h>
#include <SentryUtils.h>
#include <project_sentry_config.h> #include <project_sentry_config.h>
@ -238,7 +239,8 @@ void focusOtherInstance() {
} }
catch (Exception const &e) { catch (Exception const &e) {
app().log().error(e.qwhat()); 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(); const QString sentryCachePath = sentryCacheDir();
sentry_options_set_database_path(sentryOptions, sentryCachePath.toStdString().c_str()); 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. // Enable this for debugging sentry.
// sentry_options_set_debug(sentryOptions, 1); // sentry_options_set_debug(sentryOptions, 1);
if (sentry_init(sentryOptions) != 0) { if (sentry_init(sentryOptions) != 0) {
std::cerr << "Failed to initialize sentry" << std::endl; std::cerr << "Failed to initialize sentry" << std::endl;
} }
setSentryReportScope();
auto sentryClose = qScopeGuard([] { sentry_close(); }); 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, // 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; return result;
} }
catch (Exception const &e) { 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()); 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; return EXIT_FAILURE;
} }
} }

View File

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

View File

@ -348,6 +348,7 @@ Item {
} }
BugReportView { // 8 BugReportView { // 8
id: bugReport
colorScheme: root.colorScheme colorScheme: root.colorScheme
selectedAddress: { selectedAddress: {
if (accounts.currentIndex < 0) return "" if (accounts.currentIndex < 0) return ""
@ -409,10 +410,14 @@ Item {
} }
accounts.currentIndex = i; accounts.currentIndex = i;
if (user.state === EUserState.SignedOut) if (user.state === EUserState.SignedOut)
showSignIn(user.primaryEmailOrUsername()) showSignIn(user.primaryEmailOrUsername())
return; return;
} }
console.error("User with ID ", userID, " was not found in the account list") 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,"") root.showSetup(null,"")
} }
} }
} }
NotificationPopups { NotificationPopups {
@ -188,6 +187,10 @@ ApplicationWindow {
function showHelp() { contentWrapper.showHelp() } function showHelp() { contentWrapper.showHelp() }
function selectUser(userID) { contentWrapper.selectUser(userID) } function selectUser(userID) { contentWrapper.selectUser(userID) }
function showBugReportAndPrefill(message) {
contentWrapper.showBugReportAndPrefill(message)
}
function showSignIn(username) { function showSignIn(username) {
if (contentLayout.currentIndex == 1) return if (contentLayout.currentIndex == 1) return
contentWrapper.showSignIn(username) contentWrapper.showSignIn(username)

View File

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

View File

@ -1046,8 +1046,8 @@ QtObject {
property Notification apiCertIssue: Notification { property Notification apiCertIssue: Notification {
title: qsTr("Unable to establish a \nsecure connection to \nProton servers") 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. " + 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 " + "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>.") "<a href=\"https://proton.me/blog/tls-ssl-certificate#Extra-security-precautions-taken-by-ProtonMail\">here</a>.")
brief: title brief: title
icon: "./icons/ic-exclamation-circle-filled.svg" icon: "./icons/ic-exclamation-circle-filled.svg"
@ -1086,7 +1086,7 @@ QtObject {
function onNoActiveKeyForRecipient(email) { function onNoActiveKeyForRecipient(email) {
root.noActiveKeyForRecipient.description = qsTr("There are no active keys to encrypt your message to %1. "+ 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 root.noActiveKeyForRecipient.active = true
} }
} }
@ -1103,17 +1103,21 @@ QtObject {
} }
property Notification userBadEvent: Notification { property Notification userBadEvent: Notification {
title: qsTr("User was logged out") title: qsTr("Your account was logged out")
brief: title brief: title
description: "#PlaceHolderText" description: "#PlaceHolderText"
icon: "./icons/ic-exclamation-circle-filled.svg" icon: "./icons/ic-exclamation-circle-filled.svg"
type: Notification.NotificationType.Danger 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 { Connections {
target: Backend target: Backend
function onUserBadEvent(message) { function onUserBadEvent(description, errorMessage) {
root.userBadEvent.description = message root.userBadEvent.description = description
root.userBadEvent.errorMessage = errorMessage
root.userBadEvent.active = true root.userBadEvent.active = true
} }
} }
@ -1125,8 +1129,22 @@ QtObject {
onTriggered: { onTriggered: {
root.userBadEvent.active = false 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 { property Notification genericError: Notification {
@ -1135,14 +1153,14 @@ QtObject {
icon: "./icons/ic-exclamation-circle-filled.svg" icon: "./icons/ic-exclamation-circle-filled.svg"
type: Notification.NotificationType.Danger type: Notification.NotificationType.Danger
group: Notifications.Group.Dialogs group: Notifications.Group.Dialogs
Connections { Connections {
target: Backend target: Backend
function onGenericError(title, description) { function onGenericError(title, description) {
root.genericError.title = title root.genericError.title = title
root.genericError.description = description root.genericError.description = description
root.genericError.active = true; root.genericError.active = true;
}
} }
}
action: [ action: [
Action { Action {

View File

@ -71,20 +71,20 @@ std::mt19937_64 &rng() {
QString userConfigDir() { QString userConfigDir() {
QString dir; QString dir;
#ifdef Q_OS_WIN #ifdef Q_OS_WIN
dir = qgetenv ("AppData"); dir = qEnvironmentVariable("AppData");
if (dir.isEmpty()) if (dir.isEmpty())
throw Exception("%AppData% is not defined."); throw Exception("%AppData% is not defined.");
#elif defined(Q_OS_IOS) || defined(Q_OS_DARWIN) #elif defined(Q_OS_IOS) || defined(Q_OS_DARWIN)
dir = qgetenv("HOME"); dir = qEnvironmentVariable("HOME");
if (dir.isEmpty()) { if (dir.isEmpty()) {
throw Exception("$HOME is not defined."); throw Exception("$HOME is not defined.");
} }
dir += "/Library/Application Support"; dir += "/Library/Application Support";
#else #else
dir = qgetenv ("XDG_CONFIG_HOME"); dir = qEnvironmentVariable("XDG_CONFIG_HOME");
if (dir.isEmpty()) if (dir.isEmpty())
{ {
dir = qgetenv ("HOME"); dir = qEnvironmentVariable("HOME");
if (dir.isEmpty()) if (dir.isEmpty())
throw Exception("neither $XDG_CONFIG_HOME nor $HOME are defined"); throw Exception("neither $XDG_CONFIG_HOME nor $HOME are defined");
dir += "/.config"; dir += "/.config";
@ -104,20 +104,20 @@ QString userCacheDir() {
QString dir; QString dir;
#ifdef Q_OS_WIN #ifdef Q_OS_WIN
dir = qgetenv ("LocalAppData"); dir = qEnvironmentVariable("LocalAppData");
if (dir.isEmpty()) if (dir.isEmpty())
throw Exception("%LocalAppData% is not defined."); throw Exception("%LocalAppData% is not defined.");
#elif defined(Q_OS_IOS) || defined(Q_OS_DARWIN) #elif defined(Q_OS_IOS) || defined(Q_OS_DARWIN)
dir = qgetenv("HOME"); dir = qEnvironmentVariable("HOME");
if (dir.isEmpty()) { if (dir.isEmpty()) {
throw Exception("$HOME is not defined."); throw Exception("$HOME is not defined.");
} }
dir += "/Library/Caches"; dir += "/Library/Caches";
#else #else
dir = qgetenv ("XDG_CACHE_HOME"); dir = qEnvironmentVariable("XDG_CACHE_HOME");
if (dir.isEmpty()) if (dir.isEmpty())
{ {
dir = qgetenv ("HOME"); dir = qEnvironmentVariable("HOME");
if (dir.isEmpty()) if (dir.isEmpty())
throw Exception("neither $XDG_CACHE_HOME nor $HOME are defined"); throw Exception("neither $XDG_CACHE_HOME nor $HOME are defined");
dir += "/.cache"; dir += "/.cache";
@ -138,10 +138,10 @@ QString userDataDir() {
QString folder; QString folder;
#ifdef Q_OS_LINUX #ifdef Q_OS_LINUX
QString dir = qgetenv ("XDG_DATA_HOME"); QString dir = qEnvironmentVariable("XDG_DATA_HOME");
if (dir.isEmpty()) if (dir.isEmpty())
{ {
dir = qgetenv ("HOME"); dir = qEnvironmentVariable("HOME");
if (dir.isEmpty()) if (dir.isEmpty())
throw Exception("neither $XDG_DATA_HOME nor $HOME are defined"); throw Exception("neither $XDG_DATA_HOME nor $HOME are defined");
dir += "/.local/share"; 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() : 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 Exception::Exception(Exception const &ref) noexcept
: std::exception(ref) : 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 Exception::Exception(Exception &&ref) noexcept
: std::exception(ref) : std::exception(ref)
, what_(ref.what_) { , what_(ref.what_)
, details_(ref.details_) {
} }
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \return a string describing the exception /// \return a string describing the exception
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
QString const &Exception::qwhat() const noexcept { QString Exception::qwhat() const noexcept {
return what_; 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 } // namespace bridgepp

View File

@ -31,17 +31,19 @@ namespace bridgepp {
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
class Exception : public std::exception { class Exception : public std::exception {
public: // member functions 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 const &ref) noexcept; ///< copy constructor
Exception(Exception &&ref) noexcept; ///< copy constructor Exception(Exception &&ref) noexcept; ///< copy constructor
Exception &operator=(Exception const &) = delete; ///< Disabled assignment operator Exception &operator=(Exception const &) = delete; ///< Disabled assignment operator
Exception &operator=(Exception &&) = delete; ///< Disabled assignment operator Exception &operator=(Exception &&) = delete; ///< Disabled assignment operator
~Exception() noexcept override = default; ///< Destructor ~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 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 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; GRPCConfig sc;
if (!sc.load(path)) { QString err;
throw Exception("The gRPC service configuration file is invalid."); if (!sc.load(path, &err)) {
throw Exception("The gRPC service configuration file is invalid.", err);
} }
return sc; 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. /// \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. /// \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 { try {
serverToken_ = config.token.toStdString(); serverToken_ = config.token.toStdString();
QString address; QString address;
@ -158,9 +158,10 @@ bool GRPCClient::connectToServer(GRPCConfig const &config, ProcessMonitor *serve
this->logInfo("Successfully connected to gRPC server."); this->logInfo("Successfully connected to gRPC server.");
QString const clientToken = QUuid::createUuid().toString(); QString const clientToken = QUuid::createUuid().toString();
QString clientConfigPath = createClientConfigFile(clientToken); QString error;
QString clientConfigPath = createClientConfigFile(clientToken, &error);
if (clientConfigPath.isEmpty()) { 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))); 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"); log_->info("gRPC token was validated");
return true;
} }
catch (Exception const &e) { catch (Exception const &e) {
outError = e.qwhat(); throw Exception("Cannot connect to Go backend via gRPC: " + e.qwhat(), e.details());
return false;
} }
} }

View File

@ -59,7 +59,7 @@ public: // member functions.
GRPCClient &operator=(GRPCClient const &) = delete; ///< Disabled assignment operator. GRPCClient &operator=(GRPCClient const &) = delete; ///< Disabled assignment operator.
GRPCClient &operator=(GRPCClient &&) = delete; ///< Disabled move assignment operator. GRPCClient &operator=(GRPCClient &&) = delete; ///< Disabled move assignment operator.
void setLog(Log *log); ///< Set the log for the client. 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 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. 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 { namespace {
Exception const invalidFileException("The service configuration file is invalid"); // Exception for invalid config. Exception const invalidFileException("The content of 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.
QString const keyPort = "port"; ///< The JSON key for the port. QString const keyPort = "port"; ///< The JSON key for the port.
QString const keyCert = "cert"; ///< The JSON key for the TLS certificate. QString const keyCert = "cert"; ///< The JSON key for the TLS certificate.
QString const keyToken = "token"; ///< The JSON key for the identification token. 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) { bool GRPCConfig::load(QString const &path, QString *outError) {
try { try {
QFile file(path); QFile file(path);
if (!file.exists())
throw Exception("The gRPC service configuration file does not exist.");
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { 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()); QJsonDocument const doc = QJsonDocument::fromJson(file.readAll());
@ -93,7 +98,7 @@ bool GRPCConfig::load(QString const &path, QString *outError) {
} }
catch (Exception const &e) { catch (Exception const &e) {
if (outError) { if (outError) {
*outError = e.qwhat(); *outError = QString("Error loading gRPC service configuration file '%1'.\n%2").arg(QFileInfo(path).absoluteFilePath(), e.qwhat());
} }
return false; return false;
} }
@ -115,19 +120,19 @@ bool GRPCConfig::save(QString const &path, QString *outError) {
QFile file(path); QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { 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(); QByteArray const array = QJsonDocument(object).toJson();
if (array.size() != file.write(array)) { if (array.size() != file.write(array)) {
throw couldNotSaveException; throw Exception("An error occurred while writing to the file.");
} }
return true; return true;
} }
catch (Exception const &e) { catch (Exception const &e) {
if (outError) { if (outError) {
*outError = e.qwhat(); *outError = QString("Error saving gRPC service configuration file '%1'.\n%2").arg(QFileInfo(path).absoluteFilePath(), e.qwhat());
} }
return false; return false;
} }

View File

@ -76,10 +76,12 @@ QString grpcClientConfigBasePath() {
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \param[in] token The token to put in the file. /// \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 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 const basePath = grpcClientConfigBasePath();
QString path, error; QString path, error;
for (qint32 i = 0; i < 1000; ++i) // we try a decent amount of times 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()) { if (!QFileInfo(path).exists()) {
GRPCConfig config; GRPCConfig config;
config.token = token; config.token = token;
if (!config.save(path)) {
if (!config.save(path, outError)) {
return QString(); return QString();
} }
return path; return path;
} }
} }
if (outError)
*outError = "no usable client configuration file name could be found.";
return QString(); 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 grpcServerConfigPath(); ///< Return the path of the gRPC server config file.
QString grpcClientConfigBasePath(); ///< Return the path of the gRPC client 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. 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. 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. 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) 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{ 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 }, nil
} }

View File

@ -18,6 +18,7 @@
package sentry package sentry
import ( import (
"crypto/sha256"
"errors" "errors"
"fmt" "fmt"
"log" "log"
@ -25,6 +26,7 @@ import (
"runtime" "runtime"
"time" "time"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/proton-bridge/v3/internal/constants" "github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/pkg/restarter" "github.com/ProtonMail/proton-bridge/v3/pkg/restarter"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
@ -62,12 +64,21 @@ type Reporter struct {
appVersion string appVersion string
identifier Identifier identifier Identifier
hostArch string hostArch string
serverName string
} }
type Identifier interface { type Identifier interface {
GetUserAgent() string 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. // NewReporter creates new sentry reporter with appName and appVersion to report.
func NewReporter(appName, appVersion string, identifier Identifier) *Reporter { func NewReporter(appName, appVersion string, identifier Identifier) *Reporter {
return &Reporter{ return &Reporter{
@ -75,6 +86,7 @@ func NewReporter(appName, appVersion string, identifier Identifier) *Reporter {
appVersion: appVersion, appVersion: appVersion,
identifier: identifier, identifier: identifier,
hostArch: getHostArch(), hostArch: getHostArch(),
serverName: getProtectedHostname(),
} }
} }
@ -126,11 +138,12 @@ func (r *Reporter) scopedReport(context map[string]interface{}, doReport func())
} }
tags := map[string]string{ tags := map[string]string{
"OS": runtime.GOOS, "OS": runtime.GOOS,
"Client": r.appName, "Client": r.appName,
"Version": r.appVersion, "Version": r.appVersion,
"UserAgent": r.identifier.GetUserAgent(), "UserAgent": r.identifier.GetUserAgent(),
"HostArch": r.hostArch, "HostArch": r.hostArch,
"server_name": r.serverName,
} }
sentry.WithScope(func(scope *sentry.Scope) { sentry.WithScope(func(scope *sentry.Scope) {
@ -151,6 +164,14 @@ func (r *Reporter) scopedReport(context map[string]interface{}, doReport func())
return nil 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. // SkipDuringUnwind removes caller from the traceback.
func SkipDuringUnwind() { func SkipDuringUnwind() {
pcs := make([]uintptr, 2) pcs := make([]uintptr, 2)

View File

@ -23,6 +23,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"github.com/ProtonMail/gluon"
"github.com/ProtonMail/gluon/imap" "github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/gluon/queue" "github.com/ProtonMail/gluon/queue"
"github.com/ProtonMail/gluon/reporter" "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") l.WithError(err).Error("Failed to report refresh to sentry")
} }
// Cancel and restart ongoing syncs. // Cancel the event stream once this refresh is done.
user.abortable.Abort() defer user.pollAbort.Abort()
// Resync after the refresh.
defer user.goSync() defer user.goSync()
return safe.LockRet(func() error { 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.apiAddrs = groupBy(apiAddrs, func(addr proton.Address) string { return addr.ID })
user.apiLabels = groupBy(apiLabels, func(label proton.Label) string { return label.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. // 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) 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 { switch event.Action {
case proton.EventCreate: case proton.EventCreate:
updates, err := user.handleCreateMessageEvent( updates, err := user.handleCreateMessageEvent(logging.WithLogrusField(ctx, "action", "create message"), event.Message)
logging.WithLogrusField(ctx, "action", "create message"),
event)
if err != nil { if err != nil {
if rerr := user.reporter.ReportMessageWithContext("Failed to apply create message event", reporter.Context{ if rerr := user.reporter.ReportMessageWithContext("Failed to apply create message event", reporter.Context{
"error": err, "error": err,
@ -447,6 +445,7 @@ func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proto
}); rerr != nil { }); rerr != nil {
user.log.WithError(err).Error("Failed to report update draft message event error") user.log.WithError(err).Error("Failed to report update draft message event error")
} }
return fmt.Errorf("failed to handle update draft event: %w", err) 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 err
} }
return nil continue
} }
// GODT-2028 - Use better events here. It should be possible to have 3 separate events that refrain to // 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. // Issue regular update to handle mailboxes and flag changes.
updates, err := user.handleUpdateMessageEvent( updates, err := user.handleUpdateMessageEvent(
logging.WithLogrusField(ctx, "action", "update message"), logging.WithLogrusField(ctx, "action", "update message"),
event, event.Message,
) )
if err != nil { if err != nil {
if rerr := user.reporter.ReportMessageWithContext("Failed to apply update message event", reporter.Context{ 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 { }); rerr != nil {
user.log.WithError(err).Error("Failed to report update message event error") user.log.WithError(err).Error("Failed to report update message event error")
} }
return fmt.Errorf("failed to handle update message event: %w", err) 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 return err
} }
@ -489,6 +501,7 @@ func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proto
}); rerr != nil { }); rerr != nil {
user.log.WithError(err).Error("Failed to report delete message event error") user.log.WithError(err).Error("Failed to report delete message event error")
} }
return fmt.Errorf("failed to handle delete message event: %w", err) 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 return nil
} }
func (user *User) handleCreateMessageEvent(ctx context.Context, event proton.MessageEvent) ([]imap.Update, error) { func (user *User) handleCreateMessageEvent(ctx context.Context, message proton.MessageMetadata) ([]imap.Update, error) {
full, err := user.client.GetFullMessage(ctx, event.Message.ID) 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 err != nil {
// If the message is not found, it means that it has been deleted before we could fetch it. // 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 { 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 return nil, nil
} }
@ -514,19 +532,15 @@ func (user *User) handleCreateMessageEvent(ctx context.Context, event proton.Mes
} }
return safe.RLockRetErr(func() ([]imap.Update, error) { 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 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) res := buildRFC822(user.apiLabels, full, addrKR)
if res.err != nil { if res.err != nil {
user.log.WithError(err).Error("Failed to build RFC822 message") 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") 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 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") 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) }, 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) { return safe.RLockRetErr(func() ([]imap.Update, error) {
user.log.WithFields(logrus.Fields{ user.log.WithFields(logrus.Fields{
"messageID": event.ID, "messageID": message.ID,
"subject": logging.Sensitive(event.Message.Subject), "subject": logging.Sensitive(message.Subject),
}).Info("Handling message updated event") }).Info("Handling message updated event")
update := imap.NewMessageMailboxesUpdated( update := imap.NewMessageMailboxesUpdated(
imap.MessageID(event.ID), imap.MessageID(message.ID),
mapTo[string, imap.MailboxID](wantLabels(user.apiLabels, event.Message.LabelIDs)), mapTo[string, imap.MailboxID](wantLabels(user.apiLabels, message.LabelIDs)),
event.Message.Seen(), message.Seen(),
event.Message.Starred(), message.Starred(),
) )
user.updateCh[event.Message.AddressID].Enqueue(update) user.updateCh[message.AddressID].Enqueue(update)
return []imap.Update{update}, nil return []imap.Update{update}, nil
}, user.apiLabelsLock, user.updateChLock) }, user.apiLabelsLock, user.updateChLock)
@ -603,7 +617,7 @@ func (user *User) handleUpdateDraftEvent(ctx context.Context, event proton.Messa
if err != nil { if err != nil {
// If the message is not found, it means that it has been deleted before we could fetch it. // 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 { 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 return nil, nil
} }
@ -641,6 +655,7 @@ func (user *User) handleUpdateDraftEvent(ctx context.Context, event proton.Messa
res.update.Literal, res.update.Literal,
res.update.MailboxIDs, res.update.MailboxIDs,
res.update.ParsedMessage, res.update.ParsedMessage,
true, // Is the message doesn't exist, silently create it.
) )
user.updateCh[full.AddressID].Enqueue(update) user.updateCh[full.AddressID].Enqueue(update)

View File

@ -287,12 +287,28 @@ func (conn *imapConnector) CreateMessage(
} else if ok { } else if ok {
conn.log.WithField("messageID", messageID).Warn("Message already sent") 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 { if err != nil {
return imap.Message{}, nil, fmt.Errorf("failed to fetch message: %w", err) 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)} wantLabelIDs := []string{string(mailboxID)}
@ -427,7 +443,7 @@ func (conn *imapConnector) MoveMessages(ctx context.Context, messageIDs []imap.M
result = result || true 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 result = result || true
} }

View File

@ -23,6 +23,7 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io" "io"
"mime"
"net/mail" "net/mail"
"runtime" "runtime"
"strings" "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 we have to attach the public key, do it now.
if settings.AttachPublicKey == proton.AttachPublicKeyEnabled { if settings.AttachPublicKey {
key, err := addrKR.GetKey(0) key, err := addrKR.GetKey(0)
if err != nil { if err != nil {
return fmt.Errorf("failed to get sending key: %w", err) return fmt.Errorf("failed to get sending key: %w", err)
@ -419,7 +420,7 @@ func createAttachments(
attachment, err := client.UploadAttachment(ctx, addrKR, proton.CreateAttachmentReq{ attachment, err := client.UploadAttachment(ctx, addrKR, proton.CreateAttachmentReq{
Filename: att.Name, Filename: att.Name,
MessageID: draftID, MessageID: draftID,
MIMEType: rfc822.MIMEType(att.MIMEType), MIMEType: rfc822.MIMEType(mime.FormatMediaType(att.MIMEType, att.MIMEParams)),
Disposition: att.Disposition, Disposition: att.Disposition,
ContentID: att.ContentID, ContentID: att.ContentID,
Body: att.Data, 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 { func wantLabels(apiLabels map[string]proton.Label, labelIDs []string) []string {
return xslices.Filter(labelIDs, func(labelID string) bool { 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 ( import (
"context" "context"
"crypto/subtle" "crypto/subtle"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
@ -32,6 +34,7 @@ import (
"github.com/ProtonMail/gluon/queue" "github.com/ProtonMail/gluon/queue"
"github.com/ProtonMail/gluon/reporter" "github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/go-proton-api" "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/async"
"github.com/ProtonMail/proton-bridge/v3/internal/events" "github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/logging" "github.com/ProtonMail/proton-bridge/v3/internal/logging"
@ -78,7 +81,8 @@ type User struct {
updateChLock safe.RWMutex updateChLock safe.RWMutex
tasks *async.Group tasks *async.Group
abortable async.Abortable syncAbort async.Abortable
pollAbort async.Abortable
goSync func() goSync func()
pollAPIEventsCh chan chan struct{} pollAPIEventsCh chan chan struct{}
@ -171,42 +175,6 @@ func New(
return nil 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. // When triggered, poll the API for events, optionally blocking until the poll is complete.
user.goPollAPIEvents = func(wait bool) { user.goPollAPIEvents = func(wait bool) {
doneCh := make(chan struct{}) 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.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() { if user.vault.SyncStatus().IsComplete() {
user.log.Debug("Sync is already complete, skipping") user.log.Info("Sync already complete, skipping")
} else if err := user.doSync(ctx); err != nil { return
user.log.WithError(err).Error("Failed to sync, will retry later") }
time.AfterFunc(SyncRetryCooldown, user.goSync)
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 return user, nil
} }
func (user *User) TriggerSync() {
user.goSync()
}
// ID returns the user's ID. // ID returns the user's ID.
func (user *User) ID() string { func (user *User) ID() string {
return safe.RLockRet(func() 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 { func (user *User) SetAddressMode(_ context.Context, mode vault.AddressMode) error {
user.log.WithField("mode", mode).Info("Setting address mode") user.log.WithField("mode", mode).Info("Setting address mode")
user.abortable.Abort() user.syncAbort.Abort()
user.pollAbort.Abort()
defer user.goSync() defer user.goSync()
return safe.LockRet(func() error { return safe.LockRet(func() error {
user.initUpdateCh(mode)
if err := user.vault.SetAddressMode(mode); err != nil { if err := user.vault.SetAddressMode(mode); err != nil {
return fmt.Errorf("failed to set address mode: %w", err) 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 fmt.Errorf("failed to clear sync status: %w", err)
} }
return nil return nil
}, user.apiAddrsLock, user.updateChLock) }, user.eventLock, user.apiAddrsLock, user.updateChLock)
} }
// SetShowAllMail sets whether to show the All Mail mailbox. // 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. // GetGluonID returns the gluon ID for the given address, if present.
func (user *User) GetGluonID(addrID string) (string, bool) { func (user *User) GetGluonID(addrID string) (string, bool) {
gluonID, ok := user.vault.GetGluonIDs()[addrID] if gluonID, ok := user.vault.GetGluonIDs()[addrID]; ok {
if !ok { return gluonID, true
}
if user.vault.AddressMode() != vault.CombinedMode {
return "", false 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. // SetGluonID sets the gluon ID for the given address.
@ -419,7 +419,9 @@ func (user *User) NewIMAPConnectors() (map[string]connector.Connector, error) {
// //
// nolint:funlen // nolint:funlen
func (user *User) SendMail(authID string, from string, to []string, r io.Reader) error { 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 { if len(to) == 0 {
return ErrInvalidRecipient return ErrInvalidRecipient
@ -472,15 +474,40 @@ func (user *User) OnStatusUp(context.Context) {
func (user *User) OnStatusDown(context.Context) { func (user *User) OnStatusDown(context.Context) {
user.log.Info("Connection is down") 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. // GetSyncStatus returns the sync status of the user.
func (user *User) ClearSyncStatus() error { func (user *User) GetSyncStatus() vault.SyncStatus {
user.abortable.Abort() return user.vault.GetSyncStatus()
defer user.goSync() }
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. // 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. // doEventPoll is called whenever API events should be polled.
//
//nolint:funlen
func (user *User) doEventPoll(ctx context.Context) error { func (user *User) doEventPoll(ctx context.Context) error {
user.eventLock.Lock() user.eventLock.Lock()
defer user.eventLock.Unlock() defer user.eventLock.Unlock()
event, err := user.client.GetEvent(ctx, user.vault.EventID()) event, err := user.client.GetEvent(ctx, user.vault.EventID())
if err != nil { 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. // 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. // Handle the event.
if err := user.handleAPIEvent(ctx, event); err != nil { 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 the error is a network error, return error to retry later.
if netErr := new(proton.NetError); errors.As(err, &netErr) { if netErr := new(proton.NetError); errors.As(err, &netErr) {
return fmt.Errorf("failed to handle event due to network issue: %w", err) 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 the error is a server-side issue, return error to retry later.
if apiErr := new(proton.APIError); errors.As(err, &apiErr) && apiErr.Status >= 500 { 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) return fmt.Errorf("failed to handle event due to server error: %w", err)
@ -627,3 +725,11 @@ func b32(b bool) uint32 {
return 0 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" "testing"
"time" "time"
"github.com/ProtonMail/gluon/connector"
"github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/go-proton-api/server" "github.com/ProtonMail/go-proton-api/server"
"github.com/ProtonMail/go-proton-api/server/backend" "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/certs"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/vault" "github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/tests" "github.com/ProtonMail/proton-bridge/v3/tests"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/goleak" "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) { func TestUser_AddressMode(t *testing.T) {
withAPI(t, context.Background(), func(ctx context.Context, s *server.Server, m *proton.Manager) { withAPI(t, context.Background(), func(ctx context.Context, s *server.Server, m *proton.Manager) {
withAccount(t, s, "username", "password", []string{}, func(string, []string) { withAccount(t, s, "username", "password", []string{}, func(string, []string) {
withUser(t, ctx, s, m, "username", "password", func(user *User) { 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. // By default, user should be in combined mode.
require.Equal(t, vault.CombinedMode, user.GetAddressMode()) require.Equal(t, vault.CombinedMode, user.GetAddressMode())
// User should be able to switch to split mode. // User should be able to switch to split mode.
require.NoError(t, user.SetAddressMode(ctx, vault.SplitMode)) 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) require.NoError(tb, err)
defer user.Close() 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) fn(user)
} }

View File

@ -18,8 +18,16 @@
package vault package vault
import ( import (
"math"
"math/rand"
"github.com/Masterminds/semver/v3" "github.com/Masterminds/semver/v3"
"github.com/ProtonMail/proton-bridge/v3/internal/updater" "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. // 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. // GetUpdateRollout sets the update rollout.
func (vault *Vault) GetUpdateRollout() float64 { 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. // SetUpdateRollout sets the update rollout.

View File

@ -18,6 +18,7 @@
package vault_test package vault_test
import ( import (
"math"
"testing" "testing"
"github.com/Masterminds/semver/v3" "github.com/Masterminds/semver/v3"
@ -103,6 +104,10 @@ func TestVault_Settings_UpdateRollout(t *testing.T) {
// Check the new update rollout. // Check the new update rollout.
require.Equal(t, float64(0.5), s.GetUpdateRollout()) 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) { 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. // ClearSyncStatus clears the user's sync status.
func (user *User) ClearSyncStatus() error { func (user *User) ClearSyncStatus() error {
return user.vault.modUser(user.userID, func(data *UserData) { return user.vault.modUser(user.userID, func(data *UserData) {

View File

@ -66,6 +66,7 @@ type Attachment struct {
Name string Name string
ContentID string ContentID string
MIMEType string MIMEType string
MIMEParams map[string]string
Disposition proton.Disposition Disposition proton.Disposition
Data []byte Data []byte
} }
@ -523,6 +524,7 @@ func parseAttachment(h message.Header, body []byte) (Attachment, error) {
return Attachment{}, err return Attachment{}, err
} }
att.MIMEType = mimeType att.MIMEType = mimeType
att.MIMEParams = mimeTypeParams
// Prefer attachment name from filename param in content disposition. // Prefer attachment name from filename param in content disposition.
// If not available, try to get it from name param in content type. // 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 { func getContentHeaders(header message.Header) message.Header {
var res message.Header var res message.Header
res.Set("Content-Type", header.Get("Content-Type")) if contentType := header.Get("Content-Type"); contentType != "" {
res.Set("Content-Disposition", header.Get("Content-Disposition")) res.Set("Content-Type", contentType)
res.Set("Content-Transfer-Encoding", header.Get("Content-Transfer-Encoding")) }
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 return res
} }

View File

@ -64,6 +64,29 @@ Feature: IMAP move messages
| john.doe@mail.com | [user:user]@[domain] | baz | false | | john.doe@mail.com | [user:user]@[domain] | baz | false |
And IMAP client "1" sees 0 messages in "Labels/label2" 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 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" When IMAP client "1" moves the message with subject "baz" from "All Mail" to "Folders/folder"
Then it fails Then it fails