mirror of
https://github.com/ProtonMail/proton-bridge.git
synced 2025-12-10 04:36:43 +00:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6f1f159f3 | |||
| 82af4e01bc | |||
| 9ad5f74409 | |||
| 10cf153678 | |||
| 5ba07db7e3 | |||
| ad0d4ebd36 | |||
| 9f3c14ab1e | |||
| 74cf5d422b | |||
| dcf694588c | |||
| 82c388a0dd | |||
| 94ed09b437 | |||
| 57962e5757 | |||
| 8a5c8eaf6e | |||
| 30029f489e | |||
| 2faeebe9e7 | |||
| f6727a56d2 | |||
| d7fd39503f | |||
| b4b66f94ec | |||
| cbd36184bd | |||
| 465f754803 | |||
| 2fa7c97f39 | |||
| 9048b14fdb | |||
| 43100d11bf | |||
| 4876314cf5 | |||
| 2f75131710 | |||
| 1e09fd6662 | |||
| 48f2c56caa | |||
| 20d83dd476 | |||
| 9c6be78b4c | |||
| 0a8e71771e | |||
| 29d1c7bccd | |||
| ca1996a670 | |||
| ab1c1c474a | |||
| d7cac8a8f0 | |||
| 63bc87cc86 | |||
| 232875d5cc | |||
| 5ea53ea5c0 | |||
| 367c505444 | |||
| 3bd39b3ea5 | |||
| e89dcb2cca | |||
| 2cb2ca15c7 | |||
| db41645159 | |||
| 4cf23bb2e6 | |||
| ea11c1046a | |||
| df40f27069 | |||
| 76d732f247 | |||
| b17bdad864 | |||
| 52daa165a2 | |||
| 4c5ba04822 |
61
Changelog.md
61
Changelog.md
@ -2,6 +2,67 @@
|
||||
|
||||
Changelog [format](http://keepachangelog.com/en/1.0.0/)
|
||||
|
||||
## [Bridge 3.0.18] Perth Narrows
|
||||
|
||||
### Fixed
|
||||
* GODT-2364: Wait and retry once if the gRPC service config file exists but cannot be opened.
|
||||
* GODT-2364: Added optional details to C++ exceptions.
|
||||
* GODT-2413: Use qEnvironmentVariable() instead of qgetenv().
|
||||
* GODT-2412: Don't treat context cancellation as BadEvent.
|
||||
* GODT-2404: Handle unexpected EOF.
|
||||
* GODT-2400: Allow state updates to be applied if command fails.
|
||||
* GODT-2399: Fix immediate message deletion during updates.
|
||||
* GODT-2390: Missing changes from pervious commit.
|
||||
* GODT-2390: Add reports for uncaught json and net.opErr.
|
||||
* GODT-2414: Multiple deletion bug in WriteControlledStore.
|
||||
|
||||
|
||||
## [Bridge 3.0.18] Perth Narrows
|
||||
|
||||
### Fixed
|
||||
* GODT-2392: Create message if gluon updateMessage returns `no such message`.
|
||||
* GODT-2391: Create draft if missing during message update on gluon side.
|
||||
|
||||
## [Bridge 3.0.16/17] Perth Narrows
|
||||
|
||||
### Fixed
|
||||
* GODT-2371: Continue, not return, when handling draft.
|
||||
|
||||
## [Bridge 3.0.15] Perth Narrows
|
||||
|
||||
### Changed
|
||||
* GODT-2355: Improve wording and actions on bad event.
|
||||
|
||||
### Fixed
|
||||
* GODT-2354: Report failed load users.
|
||||
* GODT-2353: Show popup only after 3.0.16.
|
||||
* GODT-2351: Bump GPA to better handle net.OpError.
|
||||
|
||||
|
||||
## [Bridge 3.0.14] Perth Narrows
|
||||
|
||||
### Fixed
|
||||
* GODT-2323: Fix Expunge not issued for move.
|
||||
* GODT-2341: Handle URL error.
|
||||
* GODT-2340: Improve logging.
|
||||
* GODT-2278: Improve sentry logs.
|
||||
* GODT-2327: Sync issues when migrating DB.
|
||||
* GODT-2318: Remove gluon DB if label sync was incomplete.
|
||||
* GODT-1804: Only promote content headers if non-empty.
|
||||
* GODT-2343: Only poll after send if sync is complete.
|
||||
* GODT-2336: Recover from changed address order while bridge is down.
|
||||
|
||||
|
||||
|
||||
## [Bridge 3.0.13] Perth Narrows
|
||||
|
||||
### Fixed
|
||||
GODT-2328: Ignore labels that aren't part of user label set.
|
||||
GODT-2326: Sync issue on missing fresh DB file.
|
||||
GODT-2319: Seed the math/rand RNG on app startup.
|
||||
GODT-1804: Preserve MIME parameters when uploading attachments.
|
||||
|
||||
|
||||
## [Bridge 3.0.12] Perth Narrows
|
||||
|
||||
### Added
|
||||
|
||||
18
Makefile
18
Makefile
@ -11,7 +11,7 @@ ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
|
||||
.PHONY: build build-gui build-nogui build-launcher versioner hasher
|
||||
|
||||
# Keep version hardcoded so app build works also without Git repository.
|
||||
BRIDGE_APP_VERSION?=3.0.12+git
|
||||
BRIDGE_APP_VERSION?=3.0.19+git
|
||||
APP_VERSION:=${BRIDGE_APP_VERSION}
|
||||
APP_FULL_NAME:=Proton Mail Bridge
|
||||
APP_VENDOR:=Proton AG
|
||||
@ -294,7 +294,7 @@ gofiles: ./internal/bridge/credits.go
|
||||
cd ./utils/ && ./credits.sh bridge
|
||||
|
||||
## Run and debug
|
||||
.PHONY: run run-qt run-qt-cli run-nogui run-cli run-noninteractive run-debug run-qml-preview clean-vendor clean-frontend-qt clean-frontend-qt-common clean
|
||||
.PHONY: run run-qt run-qt-cli run-nogui run-cli run-noninteractive run-debug run-gui-tester clean-vendor clean-frontend-qt clean-frontend-qt-common clean
|
||||
|
||||
LOG?=debug
|
||||
LOG_IMAP?=client # client/server/all, or empty to turn it off
|
||||
@ -321,6 +321,20 @@ run-nogui: build-nogui clean-vendor gofiles
|
||||
run-debug:
|
||||
dlv debug ./cmd/Desktop-Bridge/main.go -- -l=debug
|
||||
|
||||
ifeq "${TARGET_OS}" "windows"
|
||||
EXE_SUFFIX=.exe
|
||||
endif
|
||||
|
||||
bridge-gui-tester: build-gui
|
||||
cp ./cmd/Desktop-Bridge/deploy/${TARGET_OS}/bridge-gui${EXE_SUFFIX} .
|
||||
cd ./internal/frontend/bridge-gui/bridge-gui-tester && cmake . && make
|
||||
|
||||
run-gui-tester: bridge-gui-tester
|
||||
# copying tester as bridge so bridge-gui will start it and connect to it automatically
|
||||
cp ./internal/frontend/bridge-gui/bridge-gui-tester/bridge-gui-tester${EXE_SUFFIX} bridge${EXE_SUFFIX}
|
||||
./bridge-gui${EXE_SUFFIX}
|
||||
|
||||
|
||||
clean-vendor:
|
||||
rm -rf ./vendor
|
||||
|
||||
|
||||
4
go.mod
4
go.mod
@ -5,9 +5,9 @@ go 1.18
|
||||
require (
|
||||
github.com/0xAX/notificator v0.0.0-20220220101646-ee9b8921e557
|
||||
github.com/Masterminds/semver/v3 v3.1.1
|
||||
github.com/ProtonMail/gluon v0.14.2-0.20230127085305-bc2d818d9d13
|
||||
github.com/ProtonMail/gluon v0.14.2-0.20230227135029-cef8f5824680
|
||||
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a
|
||||
github.com/ProtonMail/go-proton-api v0.3.1-0.20230126112849-3c1ac277855e
|
||||
github.com/ProtonMail/go-proton-api v0.3.1-0.20230209110241-fe7894c4931a
|
||||
github.com/ProtonMail/go-rfc5322 v0.11.0
|
||||
github.com/ProtonMail/gopenpgp/v2 v2.4.10
|
||||
github.com/PuerkitoBio/goquery v1.8.0
|
||||
|
||||
8
go.sum
8
go.sum
@ -28,8 +28,8 @@ github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs
|
||||
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo=
|
||||
github.com/ProtonMail/docker-credential-helpers v1.1.0 h1:+kvUIpwWcbtP3WFv5sSvkFn/XLzSqPOB5AAthuk9xPk=
|
||||
github.com/ProtonMail/docker-credential-helpers v1.1.0/go.mod h1:mK0aBveCxhnQ756AmaTfXMZDeULvheYVhF/MWMErN5g=
|
||||
github.com/ProtonMail/gluon v0.14.2-0.20230127085305-bc2d818d9d13 h1:rljNZVgfq/F1LLyJ4NmCfEzWayC/rk+l9QgJjtQTLKI=
|
||||
github.com/ProtonMail/gluon v0.14.2-0.20230127085305-bc2d818d9d13/go.mod h1:z2AxLIiBCT1K+0OBHyaDI7AEaO5qI6/BEC2TE42vs4Q=
|
||||
github.com/ProtonMail/gluon v0.14.2-0.20230227135029-cef8f5824680 h1:NGp7LfbsKePRHBgMcgquycHx3CSuS7255i0wanAiCuY=
|
||||
github.com/ProtonMail/gluon v0.14.2-0.20230227135029-cef8f5824680/go.mod h1:z2AxLIiBCT1K+0OBHyaDI7AEaO5qI6/BEC2TE42vs4Q=
|
||||
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a h1:D+aZah+k14Gn6kmL7eKxoo/4Dr/lK3ChBcwce2+SQP4=
|
||||
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a/go.mod h1:oTGdE7/DlWIr23G0IKW3OXK9wZ5Hw1GGiaJFccTvZi4=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
|
||||
@ -41,8 +41,8 @@ github.com/ProtonMail/go-message v0.0.0-20210611055058-fabeff2ec753/go.mod h1:NB
|
||||
github.com/ProtonMail/go-mime v0.0.0-20220302105931-303f85f7fe0f/go.mod h1:NYt+V3/4rEeDuaev/zw1zCq8uqVEuPHzDPo3OZrlGJ4=
|
||||
github.com/ProtonMail/go-mime v0.0.0-20220429130430-2192574d760f h1:4IWzKjHzZxdrW9k4zl/qCwenOVHDbVDADPPHFLjs0Oc=
|
||||
github.com/ProtonMail/go-mime v0.0.0-20220429130430-2192574d760f/go.mod h1:qRZgbeASl2a9OwmsV85aWwRqic0NHPh+9ewGAzb4cgM=
|
||||
github.com/ProtonMail/go-proton-api v0.3.1-0.20230126112849-3c1ac277855e h1:UkfLQc44UvknNCLoBEZb1qg7zfVWVLMvCE/LtdVEcAw=
|
||||
github.com/ProtonMail/go-proton-api v0.3.1-0.20230126112849-3c1ac277855e/go.mod h1:JUo5IQG0hNuPRuDpOUsCOvtee6UjTEHHF1QN2i8RSos=
|
||||
github.com/ProtonMail/go-proton-api v0.3.1-0.20230209110241-fe7894c4931a h1:h9KLPt0HTCJjILYHREWCYnZv+1xaYmOVx/rxiT/1dIg=
|
||||
github.com/ProtonMail/go-proton-api v0.3.1-0.20230209110241-fe7894c4931a/go.mod h1:JUo5IQG0hNuPRuDpOUsCOvtee6UjTEHHF1QN2i8RSos=
|
||||
github.com/ProtonMail/go-rfc5322 v0.11.0 h1:o5Obrm4DpmQEffvgsVqG6S4BKwC1Wat+hYwjIp2YcCY=
|
||||
github.com/ProtonMail/go-rfc5322 v0.11.0/go.mod h1:6oOKr0jXvpoE6pwTx/HukigQpX2J9WUf6h0auplrFTw=
|
||||
github.com/ProtonMail/go-srp v0.0.5 h1:xhUioxZgDbCnpo9JehyFhwwsn9JLWkUGfB0oiKXgiGg=
|
||||
|
||||
@ -19,11 +19,13 @@ package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
|
||||
@ -155,6 +157,9 @@ func New() *cli.App { //nolint:funlen
|
||||
}
|
||||
|
||||
func run(c *cli.Context) error { //nolint:funlen
|
||||
// Seed the default RNG from the math/rand package.
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
|
||||
// Get the current bridge version.
|
||||
version, err := semver.NewVersion(constants.Version)
|
||||
if err != nil {
|
||||
|
||||
@ -21,6 +21,7 @@ package bridge
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@ -38,6 +39,7 @@ import (
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/events"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/focus"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/sentry"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/user"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
|
||||
"github.com/bradenaw/juniper/xslices"
|
||||
@ -376,10 +378,11 @@ func (bridge *Bridge) init(tlsReporter TLSReporter) error {
|
||||
|
||||
// Attempt to lazy load users when triggered.
|
||||
bridge.goLoad = bridge.tasks.Trigger(func(ctx context.Context) {
|
||||
logrus.Info("Loading users")
|
||||
|
||||
if err := bridge.loadUsers(ctx); err != nil {
|
||||
logrus.WithError(err).Error("Failed to load users")
|
||||
if netErr := new(proton.NetError); !errors.As(err, &netErr) {
|
||||
sentry.ReportError(bridge.reporter, "Failed to load users", err)
|
||||
}
|
||||
} else {
|
||||
bridge.publish(events.AllUsersLoaded{})
|
||||
}
|
||||
|
||||
@ -559,6 +559,69 @@ func TestBridge_ChangeCacheDirectory(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestBridge_ChangeAddressOrder(t *testing.T) {
|
||||
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) {
|
||||
// Create a user.
|
||||
userID, addrID, err := s.CreateUser("imap", password)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a second address for the user.
|
||||
aliasID, err := s.CreateAddress(userID, "alias@"+s.GetDomain(), password)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create 10 messages for the user.
|
||||
withClient(ctx, t, s, "imap", password, func(ctx context.Context, c *proton.Client) {
|
||||
createNumMessages(ctx, t, c, addrID, proton.InboxLabel, 10)
|
||||
})
|
||||
|
||||
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) {
|
||||
// Log the user in with its first address.
|
||||
syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{}))
|
||||
defer done()
|
||||
userID, err := b.LoginFull(ctx, "imap", password, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userID, (<-syncCh).UserID)
|
||||
|
||||
// We should see 10 messages in the inbox.
|
||||
info, err := b.GetUserInfo(userID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, info.State == bridge.Connected)
|
||||
|
||||
client, err := client.Dial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.Login(info.Addresses[0], string(info.BridgePass)))
|
||||
defer func() { _ = client.Logout() }()
|
||||
|
||||
status, err := client.Select(`Inbox`, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint32(10), status.Messages)
|
||||
})
|
||||
|
||||
// Make the second address the primary one.
|
||||
withClient(ctx, t, s, "imap", password, func(ctx context.Context, c *proton.Client) {
|
||||
require.NoError(t, c.OrderAddresses(ctx, proton.OrderAddressesReq{AddressIDs: []string{aliasID, addrID}}))
|
||||
})
|
||||
|
||||
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) {
|
||||
// We should still see 10 messages in the inbox.
|
||||
info, err := b.GetUserInfo(userID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, info.State == bridge.Connected)
|
||||
|
||||
client, err := client.Dial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.Login(info.Addresses[0], string(info.BridgePass)))
|
||||
defer func() { _ = client.Logout() }()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
status, err := client.Select(`Inbox`, false)
|
||||
require.NoError(t, err)
|
||||
return status.Messages == 10
|
||||
}, 5*time.Second, 100*time.Millisecond)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// withEnv creates the full test environment and runs the tests.
|
||||
func withEnv(t *testing.T, tests func(context.Context, *server.Server, *proton.NetCtl, bridge.Locator, []byte), opts ...server.Option) {
|
||||
server := server.New(opts...)
|
||||
|
||||
@ -100,6 +100,8 @@ func (bridge *Bridge) closeIMAP(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// addIMAPUser connects the given user to gluon.
|
||||
//
|
||||
//nolint:funlen
|
||||
func (bridge *Bridge) addIMAPUser(ctx context.Context, user *user.User) error {
|
||||
if bridge.imapServer == nil {
|
||||
return fmt.Errorf("no imap server instance running")
|
||||
@ -125,13 +127,46 @@ func (bridge *Bridge) addIMAPUser(ctx context.Context, user *user.User) error {
|
||||
return fmt.Errorf("failed to load IMAP user: %w", err)
|
||||
}
|
||||
|
||||
// If the DB was newly created, clear the sync status; gluon's DB was not found.
|
||||
if isNew {
|
||||
// If the DB was newly created, clear the sync status; gluon's DB was not found.
|
||||
logrus.Warn("IMAP user DB was newly created, clearing sync status")
|
||||
|
||||
// Remove the user from IMAP so we can clear the sync status.
|
||||
if err := bridge.imapServer.RemoveUser(ctx, gluonID, false); err != nil {
|
||||
return fmt.Errorf("failed to remove IMAP user: %w", err)
|
||||
}
|
||||
|
||||
// Clear the sync status -- we need to resync all messages.
|
||||
if err := user.ClearSyncStatus(); err != nil {
|
||||
return fmt.Errorf("failed to clear sync status: %w", err)
|
||||
}
|
||||
|
||||
// Add the user back to the IMAP server.
|
||||
if isNew, err := bridge.imapServer.LoadUser(ctx, imapConn, gluonID, user.GluonKey()); err != nil {
|
||||
return fmt.Errorf("failed to add IMAP user: %w", err)
|
||||
} else if isNew {
|
||||
panic("IMAP user should already have a database")
|
||||
}
|
||||
} else if status := user.GetSyncStatus(); !status.HasLabels {
|
||||
// Otherwise, the DB already exists -- if the labels are not yet synced, we need to re-create the DB.
|
||||
if err := bridge.imapServer.RemoveUser(ctx, gluonID, true); err != nil {
|
||||
return fmt.Errorf("failed to remove old IMAP user: %w", err)
|
||||
}
|
||||
|
||||
if err := user.RemoveGluonID(addrID, gluonID); err != nil {
|
||||
return fmt.Errorf("failed to remove old IMAP user ID: %w", err)
|
||||
}
|
||||
|
||||
gluonID, err := bridge.imapServer.AddUser(ctx, imapConn, user.GluonKey())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add IMAP user: %w", err)
|
||||
}
|
||||
|
||||
if err := user.SetGluonID(addrID, gluonID); err != nil {
|
||||
return fmt.Errorf("failed to set IMAP user ID: %w", err)
|
||||
}
|
||||
|
||||
log.WithField("gluonID", gluonID).Info("Re-created IMAP user")
|
||||
}
|
||||
} else {
|
||||
log.Info("Creating new IMAP user")
|
||||
@ -149,6 +184,9 @@ func (bridge *Bridge) addIMAPUser(ctx context.Context, user *user.User) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger a sync for the user, if needed.
|
||||
user.TriggerSync()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -18,10 +18,12 @@
|
||||
package bridge_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@ -217,3 +219,114 @@ func TestBridge_SendDraftFlags(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestBridge_SendInvite(t *testing.T) {
|
||||
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
|
||||
// Create a recipient user.
|
||||
_, _, err := s.CreateUser("recipient", password)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set "attach public keys" to true for the user.
|
||||
withClient(ctx, t, s, username, password, func(ctx context.Context, client *proton.Client) {
|
||||
settings, err := client.SetAttachPublicKey(ctx, proton.SetAttachPublicKeyReq{AttachPublicKey: true})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, proton.Bool(true), settings.AttachPublicKey)
|
||||
})
|
||||
|
||||
// The sender should be fully synced.
|
||||
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
|
||||
syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
|
||||
defer done()
|
||||
|
||||
userID, err := bridge.LoginFull(ctx, username, password, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, userID, (<-syncCh).UserID)
|
||||
})
|
||||
|
||||
// Start the bridge.
|
||||
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
|
||||
// Get the sender user info.
|
||||
userInfo, err := bridge.QueryUserInfo(username)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Connect the sender IMAP client.
|
||||
imapClient, err := client.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetIMAPPort())))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, imapClient.Login(userInfo.Addresses[0], string(userInfo.BridgePass)))
|
||||
defer imapClient.Logout() //nolint:errcheck
|
||||
|
||||
// The message to send.
|
||||
b, err := os.ReadFile("testdata/invite.eml")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Save a draft.
|
||||
require.NoError(t, imapClient.Append("Drafts", []string{imap.DraftFlag}, time.Now(), bytes.NewReader(b)))
|
||||
|
||||
// Assert that the draft exists and is marked as a draft.
|
||||
{
|
||||
messages, err := clientFetch(imapClient, "Drafts")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, messages, 1)
|
||||
require.Contains(t, messages[0].Flags, imap.DraftFlag)
|
||||
}
|
||||
|
||||
// Connect the SMTP client.
|
||||
smtpClient, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
|
||||
require.NoError(t, err)
|
||||
defer smtpClient.Close() //nolint:errcheck
|
||||
|
||||
// Upgrade to TLS.
|
||||
require.NoError(t, smtpClient.StartTLS(&tls.Config{InsecureSkipVerify: true}))
|
||||
|
||||
// Authorize with SASL PLAIN.
|
||||
require.NoError(t, smtpClient.Auth(sasl.NewPlainClient(
|
||||
userInfo.Addresses[0],
|
||||
userInfo.Addresses[0],
|
||||
string(userInfo.BridgePass)),
|
||||
))
|
||||
|
||||
// Send the message.
|
||||
require.NoError(t, smtpClient.SendMail(
|
||||
userInfo.Addresses[0],
|
||||
[]string{"recipient@" + s.GetDomain()},
|
||||
bytes.NewReader(b),
|
||||
))
|
||||
|
||||
// Delete the draft: add the \Deleted flag and expunge.
|
||||
{
|
||||
status, err := imapClient.Select("Drafts", false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint32(1), status.Messages)
|
||||
|
||||
// Add the \Deleted flag.
|
||||
require.NoError(t, clientStore(imapClient, 1, 1, true, imap.FormatFlagsOp(imap.AddFlags, true), imap.DeletedFlag))
|
||||
|
||||
// Expunge.
|
||||
require.NoError(t, imapClient.Expunge(nil))
|
||||
}
|
||||
|
||||
// Assert that the draft is eventually gone.
|
||||
require.Eventually(t, func() bool {
|
||||
status, err := imapClient.Select("Drafts", false)
|
||||
require.NoError(t, err)
|
||||
return status.Messages == 0
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// Assert that the message is eventually in the sent folder.
|
||||
require.Eventually(t, func() bool {
|
||||
messages, err := clientFetch(imapClient, "Sent")
|
||||
require.NoError(t, err)
|
||||
return len(messages) == 1
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// Assert that the message is not marked as a draft.
|
||||
{
|
||||
messages, err := clientFetch(imapClient, "Sent")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, messages, 1)
|
||||
require.NotContains(t, messages[0].Flags, imap.DraftFlag)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
85
internal/bridge/testdata/invite.eml
vendored
Normal file
85
internal/bridge/testdata/invite.eml
vendored
Normal 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
|
||||
|
||||
@ -330,17 +330,18 @@ func (bridge *Bridge) loginUser(ctx context.Context, client *proton.Client, auth
|
||||
// loadUsers tries to load each user in the vault that isn't already loaded.
|
||||
func (bridge *Bridge) loadUsers(ctx context.Context) error {
|
||||
logrus.WithField("count", len(bridge.vault.GetUserIDs())).Info("Loading users")
|
||||
defer logrus.Info("Finished loading users")
|
||||
|
||||
return bridge.vault.ForUser(runtime.NumCPU(), func(user *vault.User) error {
|
||||
log := logrus.WithField("userID", user.UserID())
|
||||
|
||||
if user.AuthUID() == "" {
|
||||
log.Info("Not loading disconnected user")
|
||||
log.Info("User is not connected (skipping)")
|
||||
return nil
|
||||
}
|
||||
|
||||
if safe.RLockRet(func() bool { return mapHas(bridge.users, user.UserID()) }, bridge.usersLock) {
|
||||
log.Debug("Not loading already-loaded user")
|
||||
log.Info("User is already loaded (skipping)")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ProtonMail/gluon/reporter"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/events"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/user"
|
||||
@ -56,6 +57,9 @@ func (bridge *Bridge) handleUserEvent(ctx context.Context, user *user.User, even
|
||||
|
||||
case events.UserBadEvent:
|
||||
bridge.handleUserBadEvent(ctx, user, event.Error)
|
||||
|
||||
case events.UncategorizedEventError:
|
||||
bridge.handleUncategorizedErrorEvent(event)
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -139,7 +143,8 @@ func (bridge *Bridge) handleUserDeauth(ctx context.Context, user *user.User) {
|
||||
func (bridge *Bridge) handleUserBadEvent(ctx context.Context, user *user.User, err error) {
|
||||
safe.Lock(func() {
|
||||
if rerr := bridge.reporter.ReportMessageWithContext("Failed to handle event", reporter.Context{
|
||||
"error": err,
|
||||
"error_type": fmt.Sprintf("%T", internal.ErrCause(err)),
|
||||
"error": err,
|
||||
}); rerr != nil {
|
||||
logrus.WithError(rerr).Error("Failed to report failed event handling")
|
||||
}
|
||||
@ -147,3 +152,12 @@ func (bridge *Bridge) handleUserBadEvent(ctx context.Context, user *user.User, e
|
||||
bridge.logoutUser(ctx, user, true, false)
|
||||
}, bridge.usersLock)
|
||||
}
|
||||
|
||||
func (bridge *Bridge) handleUncategorizedErrorEvent(event events.UncategorizedEventError) {
|
||||
if rerr := bridge.reporter.ReportMessageWithContext("Failed to handle due to uncategorized error", reporter.Context{
|
||||
"error_type": fmt.Sprintf("%T", internal.ErrCause(event.Error)),
|
||||
"error": event.Error,
|
||||
}); rerr != nil {
|
||||
logrus.WithError(rerr).Error("Failed to report failed event handling")
|
||||
}
|
||||
}
|
||||
|
||||
31
internal/errors.go
Normal file
31
internal/errors.go
Normal 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
|
||||
}
|
||||
@ -156,3 +156,14 @@ type AddressModeChanged struct {
|
||||
func (event AddressModeChanged) String() string {
|
||||
return fmt.Sprintf("AddressModeChanged: UserID: %s, AddressMode: %s", event.UserID, event.AddressMode)
|
||||
}
|
||||
|
||||
type UncategorizedEventError struct {
|
||||
eventBase
|
||||
|
||||
UserID string
|
||||
Error error
|
||||
}
|
||||
|
||||
func (event UncategorizedEventError) String() string {
|
||||
return fmt.Sprintf("UncategorizedEventError: UserID: %s, Source:%T, Error: %s", event.UserID, event.Error, event.Error)
|
||||
}
|
||||
|
||||
@ -73,13 +73,17 @@ ProcessMonitor *AppController::bridgeMonitor() const {
|
||||
//****************************************************************************************************************************************************
|
||||
/// \param[in] function The function that caught the exception.
|
||||
/// \param[in] message The error message.
|
||||
/// \param[in] details The details for the error.
|
||||
//****************************************************************************************************************************************************
|
||||
void AppController::onFatalError(QString const &function, QString const &message) {
|
||||
QString const fullMessage = QString("%1(): %2").arg(function, message);
|
||||
reportSentryException(SENTRY_LEVEL_ERROR, "AppController got notified of a fatal error", "Exception", fullMessage.toLocal8Bit());
|
||||
void AppController::onFatalError(QString const &function, QString const &message, QString const& details) {
|
||||
QString fullMessage = QString("%1(): %2").arg(function, message);
|
||||
if (!details.isEmpty())
|
||||
fullMessage += "\n\nDetails:\n" + details;
|
||||
sentry_uuid_s const uuid = reportSentryException(SENTRY_LEVEL_ERROR, "AppController got notified of a fatal error", "Exception",
|
||||
fullMessage.toLocal8Bit());
|
||||
QMessageBox::critical(nullptr, tr("Error"), message);
|
||||
restart(true);
|
||||
log().fatal(fullMessage);
|
||||
log().fatal(QString("reportID: %1 Captured exception: %2").arg(QByteArray(uuid.bytes, 16).toHex(), fullMessage));
|
||||
qApp->exit(EXIT_FAILURE);
|
||||
}
|
||||
|
||||
|
||||
@ -58,7 +58,7 @@ public: // member functions.
|
||||
void setLauncherArgs(const QString& launcher, const QStringList& args);
|
||||
|
||||
public slots:
|
||||
void onFatalError(QString const &function, QString const &message); ///< Handle fatal errors.
|
||||
void onFatalError(QString const &function, QString const &message, QString const& details); ///< Handle fatal errors.
|
||||
|
||||
private: // member functions
|
||||
AppController(); ///< Default constructor.
|
||||
|
||||
@ -25,8 +25,8 @@
|
||||
|
||||
|
||||
#define HANDLE_EXCEPTION(x) try { x } \
|
||||
catch (Exception const &e) { emit fatalError(__func__, e.qwhat()); } \
|
||||
catch (...) { emit fatalError(__func__, QString("An unknown exception occurred")); }
|
||||
catch (Exception const &e) { emit fatalError(__func__, e.qwhat(), e.details()); } \
|
||||
catch (...) { emit fatalError(__func__, QString("An unknown exception occurred"), QString()); }
|
||||
#define HANDLE_EXCEPTION_RETURN_BOOL(x) HANDLE_EXCEPTION(x) return false;
|
||||
#define HANDLE_EXCEPTION_RETURN_QSTRING(x) HANDLE_EXCEPTION(x) return QString();
|
||||
#define HANDLE_EXCEPTION_RETURN_ZERO(x) HANDLE_EXCEPTION(x) return 0;
|
||||
@ -56,12 +56,8 @@ void QMLBackend::init(GRPCConfig const &serviceConfig) {
|
||||
app().grpc().setLog(&log);
|
||||
this->connectGrpcEvents();
|
||||
|
||||
QString error;
|
||||
if (app().grpc().connectToServer(serviceConfig, app().bridgeMonitor(), error)) {
|
||||
app().log().info("Connected to backend via gRPC service.");
|
||||
} else {
|
||||
throw Exception(QString("Cannot connectToServer to go backend via gRPC: %1").arg(error));
|
||||
}
|
||||
app().grpc().connectToServer(serviceConfig, app().bridgeMonitor());
|
||||
app().log().info("Connected to backend via gRPC service.");
|
||||
|
||||
QString bridgeVer;
|
||||
app().grpc().version(bridgeVer);
|
||||
@ -597,7 +593,8 @@ void QMLBackend::setDiskCachePath(QUrl const &path) const {
|
||||
void QMLBackend::login(QString const &username, QString const &password) const {
|
||||
HANDLE_EXCEPTION(
|
||||
if (username.compare("coco@bandicoot", Qt::CaseInsensitive) == 0) {
|
||||
throw Exception("User requested bridge-gui to crash by trying to log as coco@bandicoot");
|
||||
throw Exception("User requested bridge-gui to crash by trying to log as coco@bandicoot",
|
||||
"This error exists for test purposes and should be ignored.");
|
||||
}
|
||||
app().grpc().login(username, password);
|
||||
)
|
||||
@ -874,12 +871,14 @@ void QMLBackend::onLoginAlreadyLoggedIn(QString const &userID) {
|
||||
//****************************************************************************************************************************************************
|
||||
void QMLBackend::onUserBadEvent(QString const &userID, QString const &errorMessage) {
|
||||
HANDLE_EXCEPTION(
|
||||
Q_UNUSED(errorMessage);
|
||||
SPUser const user = users_->getUserWithID(userID);
|
||||
if (!user)
|
||||
app().log().error(QString("Received bad event for unknown user %1").arg(user->id()));
|
||||
user->setState(UserState::SignedOut);
|
||||
emit userBadEvent(tr("%1 was logged out because of an internal error.").arg(user->primaryEmailOrUsername()));
|
||||
emit userBadEvent(
|
||||
tr("Internal error: %1 was automatically logged out. Please log in again or report this problem if the issue persists.").arg(user->primaryEmailOrUsername()),
|
||||
errorMessage
|
||||
);
|
||||
emit selectUser(userID);
|
||||
emit showMainWindow();
|
||||
)
|
||||
|
||||
@ -222,7 +222,7 @@ signals: // Signals received from the Go backend, to be forwarded to QML
|
||||
void addressChangedLogout(QString const &address); ///< Signal for the 'addressChangedLogout' gRPC stream event.
|
||||
void apiCertIssue(); ///< Signal for the 'apiCertIssue' gRPC stream event.
|
||||
void userDisconnected(QString const &username); ///< Signal for the 'userDisconnected' gRPC stream event.
|
||||
void userBadEvent(QString const &message); ///< Signal for the 'userBadEvent' gRPC stream event.
|
||||
void userBadEvent(QString const &description, QString const &errorMessage); ///< Signal for the 'userBadEvent' gRPC stream event.
|
||||
void internetOff(); ///< Signal for the 'internetOff' gRPC stream event.
|
||||
void internetOn(); ///< Signal for the 'internetOn' gRPC stream event.
|
||||
void resetFinished(); ///< Signal for the 'resetFinished' gRPC stream event.
|
||||
@ -235,7 +235,7 @@ signals: // Signals received from the Go backend, to be forwarded to QML
|
||||
void selectUser(QString const); ///< Signal that request the given user account to be displayed.
|
||||
|
||||
// This signal is emitted when an exception is intercepted is calls triggered by QML. QML engine would intercept the exception otherwise.
|
||||
void fatalError(QString const &function, QString const &message) const; ///< Signal emitted when an fatal error occurs.
|
||||
void fatalError(QString const &function, QString const &message, QString const &details) const; ///< Signal emitted when an fatal error occurs.
|
||||
|
||||
private: // member functions
|
||||
void retrieveUserList(); ///< Retrieve the list of users via gRPC.
|
||||
|
||||
@ -16,19 +16,38 @@
|
||||
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#include "SentryUtils.h"
|
||||
#include "Version.h"
|
||||
#include <bridgepp/BridgeUtils.h>
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QCryptographicHash>
|
||||
#include <QString>
|
||||
#include <QSysInfo>
|
||||
|
||||
static constexpr const char *LoggerName = "bridge-gui";
|
||||
|
||||
QByteArray getProtectedHostname() {
|
||||
QByteArray hostname = QCryptographicHash::hash(QSysInfo::machineHostName().toUtf8(), QCryptographicHash::Sha256);
|
||||
return hostname.toHex();
|
||||
}
|
||||
|
||||
void reportSentryEvent(sentry_level_t level, const char *message) {
|
||||
void setSentryReportScope() {
|
||||
sentry_set_tag("OS", bridgepp::goos().toUtf8());
|
||||
sentry_set_tag("Client", PROJECT_FULL_NAME);
|
||||
sentry_set_tag("Version", PROJECT_VER);
|
||||
sentry_set_tag("UserAgent", QString("/ (%1)").arg(bridgepp::goos()).toUtf8());
|
||||
sentry_set_tag("HostArch", QSysInfo::currentCpuArchitecture().toUtf8());
|
||||
sentry_set_tag("server_name", getProtectedHostname());
|
||||
}
|
||||
|
||||
sentry_uuid_t reportSentryEvent(sentry_level_t level, const char *message) {
|
||||
auto event = sentry_value_new_message_event(level, LoggerName, message);
|
||||
sentry_capture_event(event);
|
||||
return sentry_capture_event(event);
|
||||
}
|
||||
|
||||
|
||||
void reportSentryException(sentry_level_t level, const char *message, const char *exceptionType, const char *exception) {
|
||||
sentry_uuid_t reportSentryException(sentry_level_t level, const char *message, const char *exceptionType, const char *exception) {
|
||||
auto event = sentry_value_new_message_event(level, LoggerName, message);
|
||||
sentry_event_add_exception(event, sentry_value_new_exception(exceptionType, exception));
|
||||
sentry_capture_event(event);
|
||||
return sentry_capture_event(event);
|
||||
}
|
||||
|
||||
@ -21,8 +21,8 @@
|
||||
|
||||
#include <sentry.h>
|
||||
|
||||
|
||||
void reportSentryEvent(sentry_level_t level, const char *message);
|
||||
void reportSentryException(sentry_level_t level, const char *message, const char *exceptionType, const char *exception);
|
||||
void setSentryReportScope();
|
||||
sentry_uuid_t reportSentryEvent(sentry_level_t level, const char *message);
|
||||
sentry_uuid_t reportSentryException(sentry_level_t level, const char *message, const char *exceptionType, const char *exception);
|
||||
|
||||
#endif //BRIDGE_GUI_SENTRYUTILS_H
|
||||
|
||||
@ -75,6 +75,7 @@ function check_exit() {
|
||||
|
||||
Write-host "Running build for version $bridgeVersion - $buildConfig in $buildDir"
|
||||
|
||||
$REVISION_HASH = git rev-parse --short=10 HEAD
|
||||
git submodule update --init --recursive $vcpkgRoot
|
||||
. $vcpkgBootstrap -disableMetrics
|
||||
. $vcpkgExe install sentry-native:x64-windows grpc:x64-windows --clean-after-build
|
||||
@ -82,6 +83,7 @@ git submodule update --init --recursive $vcpkgRoot
|
||||
. $cmakeExe -G "Visual Studio 17 2022" -DCMAKE_BUILD_TYPE="$buildConfig" `
|
||||
-DBRIDGE_APP_FULL_NAME="$bridgeFullName" `
|
||||
-DBRIDGE_VENDOR="$bridgeVendor" `
|
||||
-DBRIDGE_REVISION=$REVISION_HASH `
|
||||
-DBRIDGE_APP_VERSION="$bridgeVersion" `
|
||||
-S . -B $buildDir
|
||||
|
||||
|
||||
@ -55,7 +55,7 @@ BRIDGE_VENDOR=${BRIDGE_VENDOR:-"Proton AG"}
|
||||
BUILD_CONFIG=${BRIDGE_GUI_BUILD_CONFIG:-Debug}
|
||||
BUILD_DIR=$(echo "./cmake-build-${BUILD_CONFIG}" | tr '[:upper:]' '[:lower:]')
|
||||
VCPKG_ROOT="${BRIDGE_REPO_ROOT}/extern/vcpkg"
|
||||
|
||||
BRIDGE_REVISION=$(git rev-parse --short=10 HEAD)
|
||||
git submodule update --init --recursive ${VCPKG_ROOT}
|
||||
check_exit "Failed to initialize vcpkg as a submodule."
|
||||
|
||||
@ -93,6 +93,7 @@ cmake \
|
||||
-DCMAKE_BUILD_TYPE="${BUILD_CONFIG}" \
|
||||
-DBRIDGE_APP_FULL_NAME="${BRIDGE_APP_FULL_NAME}" \
|
||||
-DBRIDGE_VENDOR="${BRIDGE_VENDOR}" \
|
||||
-DBRIDGE_REVISION="${BRIDGE_REVISION}" \
|
||||
-DBRIDGE_APP_VERSION="${BRIDGE_APP_VERSION}" "${BRIDGE_CMAKE_MACOS_OPTS}" \
|
||||
-G Ninja \
|
||||
-S . \
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
#include <bridgepp/Log/Log.h>
|
||||
#include <bridgepp/ProcessMonitor.h>
|
||||
#include <sentry.h>
|
||||
#include <SentryUtils.h>
|
||||
#include <project_sentry_config.h>
|
||||
|
||||
|
||||
@ -238,7 +239,8 @@ void focusOtherInstance() {
|
||||
}
|
||||
catch (Exception const &e) {
|
||||
app().log().error(e.qwhat());
|
||||
reportSentryException(SENTRY_LEVEL_ERROR, "Exception occurred during focusOtherInstance()", "Exception", e.what());
|
||||
auto uuid = reportSentryException(SENTRY_LEVEL_ERROR, "Exception occurred during focusOtherInstance()", "Exception", e.what());
|
||||
app().log().fatal(QString("reportID: %1 Captured exception: %2").arg(QByteArray(uuid.bytes, 16).toHex()).arg(e.qwhat()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -296,13 +298,13 @@ int main(int argc, char *argv[]) {
|
||||
const QString sentryCachePath = sentryCacheDir();
|
||||
sentry_options_set_database_path(sentryOptions, sentryCachePath.toStdString().c_str());
|
||||
}
|
||||
sentry_options_set_release(sentryOptions, SentryProductID);
|
||||
sentry_options_set_release(sentryOptions, QByteArray(PROJECT_REVISION).toHex());
|
||||
// Enable this for debugging sentry.
|
||||
// sentry_options_set_debug(sentryOptions, 1);
|
||||
if (sentry_init(sentryOptions) != 0) {
|
||||
std::cerr << "Failed to initialize sentry" << std::endl;
|
||||
}
|
||||
|
||||
setSentryReportScope();
|
||||
auto sentryClose = qScopeGuard([] { sentry_close(); });
|
||||
|
||||
// The application instance is needed to display system message boxes. As we may have to do it in the exception handler,
|
||||
@ -426,9 +428,16 @@ int main(int argc, char *argv[]) {
|
||||
return result;
|
||||
}
|
||||
catch (Exception const &e) {
|
||||
reportSentryException(SENTRY_LEVEL_ERROR, "Exception occurred during main", "Exception", e.what());
|
||||
QString fullMessage = e.qwhat();
|
||||
bool const hasDetails = !e.details().isEmpty();
|
||||
if (hasDetails)
|
||||
fullMessage += "\n\nDetails:\n" + e.details();
|
||||
sentry_uuid_s const uuid = reportSentryException(SENTRY_LEVEL_ERROR, "Exception occurred during main", "Exception", fullMessage.toLocal8Bit());
|
||||
QMessageBox::critical(nullptr, "Error", e.qwhat());
|
||||
QTextStream(stderr) << e.qwhat() << "\n";
|
||||
QTextStream errStream(stderr);
|
||||
errStream << "reportID: " << QByteArray(uuid.bytes, 16).toHex() << " Captured exception :" << e.qwhat() << "\n";
|
||||
if (hasDetails)
|
||||
errStream << "\nDetails:\n" << e.details() << "\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
@ -170,6 +170,10 @@ SettingsView {
|
||||
}
|
||||
}
|
||||
|
||||
function setDescription(message) {
|
||||
description.text = message
|
||||
}
|
||||
|
||||
function setDefaultValue() {
|
||||
description.text = ""
|
||||
address.text = root.selectedAddress
|
||||
|
||||
@ -348,6 +348,7 @@ Item {
|
||||
}
|
||||
|
||||
BugReportView { // 8
|
||||
id: bugReport
|
||||
colorScheme: root.colorScheme
|
||||
selectedAddress: {
|
||||
if (accounts.currentIndex < 0) return ""
|
||||
@ -409,10 +410,14 @@ Item {
|
||||
}
|
||||
accounts.currentIndex = i;
|
||||
if (user.state === EUserState.SignedOut)
|
||||
showSignIn(user.primaryEmailOrUsername())
|
||||
showSignIn(user.primaryEmailOrUsername())
|
||||
return;
|
||||
}
|
||||
console.error("User with ID ", userID, " was not found in the account list")
|
||||
}
|
||||
|
||||
function showBugReportAndPrefill(description) {
|
||||
rightContent.showBugReport()
|
||||
bugReport.setDescription(description)
|
||||
}
|
||||
}
|
||||
|
||||
@ -169,7 +169,6 @@ ApplicationWindow {
|
||||
root.showSetup(null,"")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
NotificationPopups {
|
||||
@ -188,6 +187,10 @@ ApplicationWindow {
|
||||
function showHelp() { contentWrapper.showHelp() }
|
||||
function selectUser(userID) { contentWrapper.selectUser(userID) }
|
||||
|
||||
function showBugReportAndPrefill(message) {
|
||||
contentWrapper.showBugReportAndPrefill(message)
|
||||
}
|
||||
|
||||
function showSignIn(username) {
|
||||
if (contentLayout.currentIndex == 1) return
|
||||
contentWrapper.showSignIn(username)
|
||||
|
||||
@ -129,6 +129,11 @@ Item {
|
||||
notification: root.notifications.noActiveKeyForRecipient
|
||||
}
|
||||
|
||||
NotificationDialog {
|
||||
colorScheme: root.colorScheme
|
||||
notification: root.notifications.userBadEvent
|
||||
}
|
||||
|
||||
NotificationDialog {
|
||||
colorScheme: root.colorScheme
|
||||
notification: root.notifications.genericError
|
||||
|
||||
@ -1046,8 +1046,8 @@ QtObject {
|
||||
property Notification apiCertIssue: Notification {
|
||||
title: qsTr("Unable to establish a \nsecure connection to \nProton servers")
|
||||
description: qsTr("Bridge cannot verify the authenticity of Proton servers on your current network due to a TLS certificate error. " +
|
||||
"Start Bridge again after ensuring your connection is secure and/or connecting to a VPN. Learn more about TLS pinning " +
|
||||
"<a href=\"https://proton.me/blog/tls-ssl-certificate#Extra-security-precautions-taken-by-ProtonMail\">here</a>.")
|
||||
"Start Bridge again after ensuring your connection is secure and/or connecting to a VPN. Learn more about TLS pinning " +
|
||||
"<a href=\"https://proton.me/blog/tls-ssl-certificate#Extra-security-precautions-taken-by-ProtonMail\">here</a>.")
|
||||
|
||||
brief: title
|
||||
icon: "./icons/ic-exclamation-circle-filled.svg"
|
||||
@ -1086,7 +1086,7 @@ QtObject {
|
||||
|
||||
function onNoActiveKeyForRecipient(email) {
|
||||
root.noActiveKeyForRecipient.description = qsTr("There are no active keys to encrypt your message to %1. "+
|
||||
"Please update the setting for this contact.").arg(email)
|
||||
"Please update the setting for this contact.").arg(email)
|
||||
root.noActiveKeyForRecipient.active = true
|
||||
}
|
||||
}
|
||||
@ -1103,17 +1103,21 @@ QtObject {
|
||||
}
|
||||
|
||||
property Notification userBadEvent: Notification {
|
||||
title: qsTr("User was logged out")
|
||||
title: qsTr("Your account was logged out")
|
||||
brief: title
|
||||
description: "#PlaceHolderText"
|
||||
icon: "./icons/ic-exclamation-circle-filled.svg"
|
||||
type: Notification.NotificationType.Danger
|
||||
group: Notifications.Group.Connection
|
||||
group: Notifications.Group.Connection | Notifications.Group.Dialogs
|
||||
|
||||
property var bugReportMsg: "Reporting an issue:\n\n\"%1\"\n\nError: %2\n\nThe issue persists even after loggin back in."
|
||||
property var errorMessage: ""
|
||||
|
||||
Connections {
|
||||
target: Backend
|
||||
function onUserBadEvent(message) {
|
||||
root.userBadEvent.description = message
|
||||
function onUserBadEvent(description, errorMessage) {
|
||||
root.userBadEvent.description = description
|
||||
root.userBadEvent.errorMessage = errorMessage
|
||||
root.userBadEvent.active = true
|
||||
}
|
||||
}
|
||||
@ -1125,8 +1129,22 @@ QtObject {
|
||||
onTriggered: {
|
||||
root.userBadEvent.active = false
|
||||
}
|
||||
},
|
||||
|
||||
Action {
|
||||
text: qsTr("Report")
|
||||
|
||||
onTriggered: {
|
||||
root.frontendMain.showBugReportAndPrefill(
|
||||
root.userBadEvent.bugReportMsg.
|
||||
arg( root.userBadEvent.description).
|
||||
arg(root.userBadEvent.errorMessage)
|
||||
)
|
||||
root.userBadEvent.active = false
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
}
|
||||
|
||||
property Notification genericError: Notification {
|
||||
@ -1135,14 +1153,14 @@ QtObject {
|
||||
icon: "./icons/ic-exclamation-circle-filled.svg"
|
||||
type: Notification.NotificationType.Danger
|
||||
group: Notifications.Group.Dialogs
|
||||
Connections {
|
||||
target: Backend
|
||||
function onGenericError(title, description) {
|
||||
root.genericError.title = title
|
||||
root.genericError.description = description
|
||||
root.genericError.active = true;
|
||||
}
|
||||
Connections {
|
||||
target: Backend
|
||||
function onGenericError(title, description) {
|
||||
root.genericError.title = title
|
||||
root.genericError.description = description
|
||||
root.genericError.active = true;
|
||||
}
|
||||
}
|
||||
|
||||
action: [
|
||||
Action {
|
||||
|
||||
@ -71,20 +71,20 @@ std::mt19937_64 &rng() {
|
||||
QString userConfigDir() {
|
||||
QString dir;
|
||||
#ifdef Q_OS_WIN
|
||||
dir = qgetenv ("AppData");
|
||||
dir = qEnvironmentVariable("AppData");
|
||||
if (dir.isEmpty())
|
||||
throw Exception("%AppData% is not defined.");
|
||||
#elif defined(Q_OS_IOS) || defined(Q_OS_DARWIN)
|
||||
dir = qgetenv("HOME");
|
||||
dir = qEnvironmentVariable("HOME");
|
||||
if (dir.isEmpty()) {
|
||||
throw Exception("$HOME is not defined.");
|
||||
}
|
||||
dir += "/Library/Application Support";
|
||||
#else
|
||||
dir = qgetenv ("XDG_CONFIG_HOME");
|
||||
dir = qEnvironmentVariable("XDG_CONFIG_HOME");
|
||||
if (dir.isEmpty())
|
||||
{
|
||||
dir = qgetenv ("HOME");
|
||||
dir = qEnvironmentVariable("HOME");
|
||||
if (dir.isEmpty())
|
||||
throw Exception("neither $XDG_CONFIG_HOME nor $HOME are defined");
|
||||
dir += "/.config";
|
||||
@ -104,20 +104,20 @@ QString userCacheDir() {
|
||||
QString dir;
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
dir = qgetenv ("LocalAppData");
|
||||
dir = qEnvironmentVariable("LocalAppData");
|
||||
if (dir.isEmpty())
|
||||
throw Exception("%LocalAppData% is not defined.");
|
||||
#elif defined(Q_OS_IOS) || defined(Q_OS_DARWIN)
|
||||
dir = qgetenv("HOME");
|
||||
dir = qEnvironmentVariable("HOME");
|
||||
if (dir.isEmpty()) {
|
||||
throw Exception("$HOME is not defined.");
|
||||
}
|
||||
dir += "/Library/Caches";
|
||||
#else
|
||||
dir = qgetenv ("XDG_CACHE_HOME");
|
||||
dir = qEnvironmentVariable("XDG_CACHE_HOME");
|
||||
if (dir.isEmpty())
|
||||
{
|
||||
dir = qgetenv ("HOME");
|
||||
dir = qEnvironmentVariable("HOME");
|
||||
if (dir.isEmpty())
|
||||
throw Exception("neither $XDG_CACHE_HOME nor $HOME are defined");
|
||||
dir += "/.cache";
|
||||
@ -138,10 +138,10 @@ QString userDataDir() {
|
||||
QString folder;
|
||||
|
||||
#ifdef Q_OS_LINUX
|
||||
QString dir = qgetenv ("XDG_DATA_HOME");
|
||||
QString dir = qEnvironmentVariable("XDG_DATA_HOME");
|
||||
if (dir.isEmpty())
|
||||
{
|
||||
dir = qgetenv ("HOME");
|
||||
dir = qEnvironmentVariable("HOME");
|
||||
if (dir.isEmpty())
|
||||
throw Exception("neither $XDG_DATA_HOME nor $HOME are defined");
|
||||
dir += "/.local/share";
|
||||
|
||||
@ -23,11 +23,13 @@ namespace bridgepp {
|
||||
|
||||
|
||||
//****************************************************************************************************************************************************
|
||||
/// \param[in] what A description of the exception
|
||||
/// \param[in] what A description of the exception.
|
||||
/// \param[in] details The optional details for the exception.
|
||||
//****************************************************************************************************************************************************
|
||||
Exception::Exception(QString what) noexcept
|
||||
Exception::Exception(QString what, QString details) noexcept
|
||||
: std::exception()
|
||||
, what_(std::move(what)) {
|
||||
, what_(std::move(what))
|
||||
, details_(std::move(details)) {
|
||||
}
|
||||
|
||||
|
||||
@ -36,7 +38,8 @@ Exception::Exception(QString what) noexcept
|
||||
//****************************************************************************************************************************************************
|
||||
Exception::Exception(Exception const &ref) noexcept
|
||||
: std::exception(ref)
|
||||
, what_(ref.what_) {
|
||||
, what_(ref.what_)
|
||||
, details_(ref.details_) {
|
||||
}
|
||||
|
||||
|
||||
@ -45,14 +48,15 @@ Exception::Exception(Exception const &ref) noexcept
|
||||
//****************************************************************************************************************************************************
|
||||
Exception::Exception(Exception &&ref) noexcept
|
||||
: std::exception(ref)
|
||||
, what_(ref.what_) {
|
||||
, what_(ref.what_)
|
||||
, details_(ref.details_) {
|
||||
}
|
||||
|
||||
|
||||
//****************************************************************************************************************************************************
|
||||
/// \return a string describing the exception
|
||||
//****************************************************************************************************************************************************
|
||||
QString const &Exception::qwhat() const noexcept {
|
||||
QString Exception::qwhat() const noexcept {
|
||||
return what_;
|
||||
}
|
||||
|
||||
@ -65,4 +69,12 @@ const char *Exception::what() const noexcept {
|
||||
}
|
||||
|
||||
|
||||
//****************************************************************************************************************************************************
|
||||
/// \return The details for the exception.
|
||||
//****************************************************************************************************************************************************
|
||||
QString Exception::details() const noexcept {
|
||||
return details_;
|
||||
}
|
||||
|
||||
|
||||
} // namespace bridgepp
|
||||
|
||||
@ -31,17 +31,19 @@ namespace bridgepp {
|
||||
//****************************************************************************************************************************************************
|
||||
class Exception : public std::exception {
|
||||
public: // member functions
|
||||
explicit Exception(QString what = QString()) noexcept; ///< Constructor
|
||||
explicit Exception(QString what = QString(), QString details = QString()) noexcept; ///< Constructor
|
||||
Exception(Exception const &ref) noexcept; ///< copy constructor
|
||||
Exception(Exception &&ref) noexcept; ///< copy constructor
|
||||
Exception &operator=(Exception const &) = delete; ///< Disabled assignment operator
|
||||
Exception &operator=(Exception &&) = delete; ///< Disabled assignment operator
|
||||
~Exception() noexcept override = default; ///< Destructor
|
||||
QString const &qwhat() const noexcept; ///< Return the description of the exception as a QString
|
||||
QString qwhat() const noexcept; ///< Return the description of the exception as a QString
|
||||
const char *what() const noexcept override; ///< Return the description of the exception as C style string
|
||||
QString details() const noexcept; ///< Return the details for the exception
|
||||
|
||||
private: // data members
|
||||
QString const what_; ///< The description of the exception
|
||||
QString const what_; ///< The description of the exception.
|
||||
QString const details_; ///< The optional details for the exception.
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -88,8 +88,9 @@ GRPCConfig GRPCClient::waitAndRetrieveServiceConfig(qint64 timeoutMs, ProcessMon
|
||||
}
|
||||
|
||||
GRPCConfig sc;
|
||||
if (!sc.load(path)) {
|
||||
throw Exception("The gRPC service configuration file is invalid.");
|
||||
QString err;
|
||||
if (!sc.load(path, &err)) {
|
||||
throw Exception("The gRPC service configuration file is invalid.", err);
|
||||
}
|
||||
|
||||
return sc;
|
||||
@ -105,11 +106,10 @@ void GRPCClient::setLog(Log *log) {
|
||||
|
||||
|
||||
//****************************************************************************************************************************************************
|
||||
/// \param[out] outError If the function returns false, this variable contains a description of the error.
|
||||
/// \param[in] serverProcess An optional server process to monitor. If the process it, no need and retry, as connexion cannot be established. Ignored if null.
|
||||
/// \return true iff the connection was successful.
|
||||
//****************************************************************************************************************************************************
|
||||
bool GRPCClient::connectToServer(GRPCConfig const &config, ProcessMonitor *serverProcess, QString &outError) {
|
||||
void GRPCClient::connectToServer(GRPCConfig const &config, ProcessMonitor *serverProcess) {
|
||||
try {
|
||||
serverToken_ = config.token.toStdString();
|
||||
QString address;
|
||||
@ -158,9 +158,10 @@ bool GRPCClient::connectToServer(GRPCConfig const &config, ProcessMonitor *serve
|
||||
this->logInfo("Successfully connected to gRPC server.");
|
||||
|
||||
QString const clientToken = QUuid::createUuid().toString();
|
||||
QString clientConfigPath = createClientConfigFile(clientToken);
|
||||
QString error;
|
||||
QString clientConfigPath = createClientConfigFile(clientToken, &error);
|
||||
if (clientConfigPath.isEmpty()) {
|
||||
throw Exception("gRPC client config could not be saved.");
|
||||
throw Exception("gRPC client config could not be saved.", error);
|
||||
}
|
||||
this->logInfo(QString("Client config file was saved to '%1'").arg(QDir::toNativeSeparators(clientConfigPath)));
|
||||
|
||||
@ -176,12 +177,9 @@ bool GRPCClient::connectToServer(GRPCConfig const &config, ProcessMonitor *serve
|
||||
}
|
||||
|
||||
log_->info("gRPC token was validated");
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception const &e) {
|
||||
outError = e.qwhat();
|
||||
return false;
|
||||
throw Exception("Cannot connect to Go backend via gRPC: " + e.qwhat(), e.details());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -59,7 +59,7 @@ public: // member functions.
|
||||
GRPCClient &operator=(GRPCClient const &) = delete; ///< Disabled assignment operator.
|
||||
GRPCClient &operator=(GRPCClient &&) = delete; ///< Disabled move assignment operator.
|
||||
void setLog(Log *log); ///< Set the log for the client.
|
||||
bool connectToServer(GRPCConfig const &config, class ProcessMonitor *serverProcess, QString &outError); ///< Establish connection to the gRPC server.
|
||||
void connectToServer(GRPCConfig const &config, class ProcessMonitor *serverProcess); ///< Establish connection to the gRPC server.
|
||||
|
||||
grpc::Status checkTokens(QString const &clientConfigPath, QString &outReturnedClientToken); ///< Performs a token check.
|
||||
grpc::Status addLogEntry(Log::Level level, QString const &package, QString const &message); ///< Performs the "AddLogEntry" gRPC call.
|
||||
|
||||
@ -25,8 +25,7 @@ using namespace bridgepp;
|
||||
|
||||
namespace {
|
||||
|
||||
Exception const invalidFileException("The service configuration file is invalid"); // Exception for invalid config.
|
||||
Exception const couldNotSaveException("The service configuration file could not be saved"); ///< Exception for write errors.
|
||||
Exception const invalidFileException("The content of the service configuration file is invalid"); // Exception for invalid config.
|
||||
QString const keyPort = "port"; ///< The JSON key for the port.
|
||||
QString const keyCert = "cert"; ///< The JSON key for the TLS certificate.
|
||||
QString const keyToken = "token"; ///< The JSON key for the identification token.
|
||||
@ -78,8 +77,14 @@ qint32 jsonIntValue(QJsonObject const &object, QString const &key) {
|
||||
bool GRPCConfig::load(QString const &path, QString *outError) {
|
||||
try {
|
||||
QFile file(path);
|
||||
if (!file.exists())
|
||||
throw Exception("The gRPC service configuration file does not exist.");
|
||||
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
throw Exception("Could not open gRPC service config file.");
|
||||
QThread::msleep(500); // we wait a bit and retry once, just in case server is not done writing/moving the config file.
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
throw Exception("The gRPC service configuration file exists but cannot be opened.");
|
||||
}
|
||||
}
|
||||
|
||||
QJsonDocument const doc = QJsonDocument::fromJson(file.readAll());
|
||||
@ -93,7 +98,7 @@ bool GRPCConfig::load(QString const &path, QString *outError) {
|
||||
}
|
||||
catch (Exception const &e) {
|
||||
if (outError) {
|
||||
*outError = e.qwhat();
|
||||
*outError = QString("Error loading gRPC service configuration file '%1'.\n%2").arg(QFileInfo(path).absoluteFilePath(), e.qwhat());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -115,19 +120,19 @@ bool GRPCConfig::save(QString const &path, QString *outError) {
|
||||
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||
throw couldNotSaveException;
|
||||
throw Exception("The file could not be opened for writing.");
|
||||
}
|
||||
|
||||
QByteArray const array = QJsonDocument(object).toJson();
|
||||
if (array.size() != file.write(array)) {
|
||||
throw couldNotSaveException;
|
||||
throw Exception("An error occurred while writing to the file.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception const &e) {
|
||||
if (outError) {
|
||||
*outError = e.qwhat();
|
||||
*outError = QString("Error saving gRPC service configuration file '%1'.\n%2").arg(QFileInfo(path).absoluteFilePath(), e.qwhat());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -76,10 +76,12 @@ QString grpcClientConfigBasePath() {
|
||||
|
||||
//****************************************************************************************************************************************************
|
||||
/// \param[in] token The token to put in the file.
|
||||
/// \param[out] outError if the function returns an empty string and this pointer is not null, the pointer variable holds a description of the error
|
||||
/// on exit.
|
||||
/// \return The path of the created file.
|
||||
/// \return A null string if the file could not be saved..
|
||||
/// \return A null string if the file could not be saved.
|
||||
//****************************************************************************************************************************************************
|
||||
QString createClientConfigFile(QString const &token) {
|
||||
QString createClientConfigFile(QString const &token, QString *outError) {
|
||||
QString const basePath = grpcClientConfigBasePath();
|
||||
QString path, error;
|
||||
for (qint32 i = 0; i < 1000; ++i) // we try a decent amount of times
|
||||
@ -88,13 +90,16 @@ QString createClientConfigFile(QString const &token) {
|
||||
if (!QFileInfo(path).exists()) {
|
||||
GRPCConfig config;
|
||||
config.token = token;
|
||||
if (!config.save(path)) {
|
||||
|
||||
if (!config.save(path, outError)) {
|
||||
return QString();
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
if (outError)
|
||||
*outError = "no usable client configuration file name could be found.";
|
||||
return QString();
|
||||
}
|
||||
|
||||
|
||||
@ -36,7 +36,7 @@ typedef std::shared_ptr<grpc::StreamEvent> SPStreamEvent; ///< Type definition f
|
||||
|
||||
QString grpcServerConfigPath(); ///< Return the path of the gRPC server config file.
|
||||
QString grpcClientConfigBasePath(); ///< Return the path of the gRPC client config file.
|
||||
QString createClientConfigFile(QString const &token); ///< Create the client config file the server will retrieve and return its path.
|
||||
QString createClientConfigFile(QString const &token, QString *outError); ///< Create the client config file the server will retrieve and return its path.
|
||||
grpc::LogLevel logLevelToGRPC(Log::Level level); ///< Convert a Log::Level to gRPC enum value.
|
||||
Log::Level logLevelFromGRPC(grpc::LogLevel level); ///< Convert a grpc::LogLevel to a Log::Level.
|
||||
grpc::UserState userStateToGRPC(UserState state); ///< Convert a bridgepp::UserState to a grpc::UserState.
|
||||
|
||||
@ -96,9 +96,11 @@ func (s *Service) GuiReady(ctx context.Context, _ *emptypb.Empty) (*GuiReadyResp
|
||||
|
||||
s.initializationDone.Do(s.initializing.Done)
|
||||
|
||||
// Splash screen should be displayed only to users who start v3 for the first time after upgrading from v2.
|
||||
// Splash screen should be displayed only to users who start v3.0.17 or later for the first time after upgrading from v2.
|
||||
return &GuiReadyResponse{
|
||||
ShowSplashScreen: (!s.bridge.GetFirstStart()) && s.bridge.GetLastVersion().LessThan(semver.MustParse("3.0.0")),
|
||||
ShowSplashScreen: (!s.bridge.GetFirstStart()) &&
|
||||
s.bridge.GetLastVersion().LessThan(semver.MustParse("3.0.0")) &&
|
||||
s.bridge.GetCurrentVersion().GreaterThan(semver.MustParse("3.0.16")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
package sentry
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@ -25,6 +26,7 @@ import (
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/gluon/reporter"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
|
||||
"github.com/ProtonMail/proton-bridge/v3/pkg/restarter"
|
||||
"github.com/getsentry/sentry-go"
|
||||
@ -62,12 +64,21 @@ type Reporter struct {
|
||||
appVersion string
|
||||
identifier Identifier
|
||||
hostArch string
|
||||
serverName string
|
||||
}
|
||||
|
||||
type Identifier interface {
|
||||
GetUserAgent() string
|
||||
}
|
||||
|
||||
func getProtectedHostname() string {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return "Unknown"
|
||||
}
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(hostname)))
|
||||
}
|
||||
|
||||
// NewReporter creates new sentry reporter with appName and appVersion to report.
|
||||
func NewReporter(appName, appVersion string, identifier Identifier) *Reporter {
|
||||
return &Reporter{
|
||||
@ -75,6 +86,7 @@ func NewReporter(appName, appVersion string, identifier Identifier) *Reporter {
|
||||
appVersion: appVersion,
|
||||
identifier: identifier,
|
||||
hostArch: getHostArch(),
|
||||
serverName: getProtectedHostname(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -126,11 +138,12 @@ func (r *Reporter) scopedReport(context map[string]interface{}, doReport func())
|
||||
}
|
||||
|
||||
tags := map[string]string{
|
||||
"OS": runtime.GOOS,
|
||||
"Client": r.appName,
|
||||
"Version": r.appVersion,
|
||||
"UserAgent": r.identifier.GetUserAgent(),
|
||||
"HostArch": r.hostArch,
|
||||
"OS": runtime.GOOS,
|
||||
"Client": r.appName,
|
||||
"Version": r.appVersion,
|
||||
"UserAgent": r.identifier.GetUserAgent(),
|
||||
"HostArch": r.hostArch,
|
||||
"server_name": r.serverName,
|
||||
}
|
||||
|
||||
sentry.WithScope(func(scope *sentry.Scope) {
|
||||
@ -151,6 +164,14 @@ func (r *Reporter) scopedReport(context map[string]interface{}, doReport func())
|
||||
return nil
|
||||
}
|
||||
|
||||
func ReportError(r reporter.Reporter, msg string, err error) {
|
||||
if rerr := r.ReportMessageWithContext(msg, reporter.Context{
|
||||
"error": err.Error(),
|
||||
}); rerr != nil {
|
||||
logrus.WithError(rerr).WithField("msg", msg).Error("Failed to send report")
|
||||
}
|
||||
}
|
||||
|
||||
// SkipDuringUnwind removes caller from the traceback.
|
||||
func SkipDuringUnwind() {
|
||||
pcs := make([]uintptr, 2)
|
||||
|
||||
@ -23,6 +23,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/ProtonMail/gluon"
|
||||
"github.com/ProtonMail/gluon/imap"
|
||||
"github.com/ProtonMail/gluon/queue"
|
||||
"github.com/ProtonMail/gluon/reporter"
|
||||
@ -85,8 +86,10 @@ func (user *User) handleRefreshEvent(ctx context.Context, refresh proton.Refresh
|
||||
l.WithError(err).Error("Failed to report refresh to sentry")
|
||||
}
|
||||
|
||||
// Cancel and restart ongoing syncs.
|
||||
user.abortable.Abort()
|
||||
// Cancel the event stream once this refresh is done.
|
||||
defer user.pollAbort.Abort()
|
||||
|
||||
// Resync after the refresh.
|
||||
defer user.goSync()
|
||||
|
||||
return safe.LockRet(func() error {
|
||||
@ -113,11 +116,8 @@ func (user *User) handleRefreshEvent(ctx context.Context, refresh proton.Refresh
|
||||
user.apiAddrs = groupBy(apiAddrs, func(addr proton.Address) string { return addr.ID })
|
||||
user.apiLabels = groupBy(apiLabels, func(label proton.Label) string { return label.ID })
|
||||
|
||||
// Reinitialize the update channels.
|
||||
user.initUpdateCh(user.vault.AddressMode())
|
||||
|
||||
// Clear sync status; we want to sync everything again.
|
||||
if err := user.vault.ClearSyncStatus(); err != nil {
|
||||
if err := user.clearSyncStatus(); err != nil {
|
||||
return fmt.Errorf("failed to clear sync status: %w", err)
|
||||
}
|
||||
|
||||
@ -416,9 +416,7 @@ func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proto
|
||||
|
||||
switch event.Action {
|
||||
case proton.EventCreate:
|
||||
updates, err := user.handleCreateMessageEvent(
|
||||
logging.WithLogrusField(ctx, "action", "create message"),
|
||||
event)
|
||||
updates, err := user.handleCreateMessageEvent(logging.WithLogrusField(ctx, "action", "create message"), event.Message)
|
||||
if err != nil {
|
||||
if rerr := user.reporter.ReportMessageWithContext("Failed to apply create message event", reporter.Context{
|
||||
"error": err,
|
||||
@ -447,6 +445,7 @@ func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proto
|
||||
}); rerr != nil {
|
||||
user.log.WithError(err).Error("Failed to report update draft message event error")
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to handle update draft event: %w", err)
|
||||
}
|
||||
|
||||
@ -454,7 +453,7 @@ func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proto
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
continue
|
||||
}
|
||||
|
||||
// GODT-2028 - Use better events here. It should be possible to have 3 separate events that refrain to
|
||||
@ -463,7 +462,7 @@ func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proto
|
||||
// Issue regular update to handle mailboxes and flag changes.
|
||||
updates, err := user.handleUpdateMessageEvent(
|
||||
logging.WithLogrusField(ctx, "action", "update message"),
|
||||
event,
|
||||
event.Message,
|
||||
)
|
||||
if err != nil {
|
||||
if rerr := user.reporter.ReportMessageWithContext("Failed to apply update message event", reporter.Context{
|
||||
@ -471,10 +470,23 @@ func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proto
|
||||
}); rerr != nil {
|
||||
user.log.WithError(err).Error("Failed to report update message event error")
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to handle update message event: %w", err)
|
||||
}
|
||||
|
||||
if err := waitOnIMAPUpdates(ctx, updates); err != nil {
|
||||
// If the update fails on the gluon side because it doesn't exist, we try to create the message instead.
|
||||
if err := waitOnIMAPUpdates(ctx, updates); gluon.IsNoSuchMessage(err) {
|
||||
user.log.WithError(err).Error("Failed to handle update message event in gluon, will try creating it")
|
||||
|
||||
updates, err := user.handleCreateMessageEvent(ctx, event.Message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to handle update message event as create: %w", err)
|
||||
}
|
||||
|
||||
if err := waitOnIMAPUpdates(ctx, updates); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -489,6 +501,7 @@ func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proto
|
||||
}); rerr != nil {
|
||||
user.log.WithError(err).Error("Failed to report delete message event error")
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to handle delete message event: %w", err)
|
||||
}
|
||||
|
||||
@ -501,12 +514,17 @@ func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proto
|
||||
return nil
|
||||
}
|
||||
|
||||
func (user *User) handleCreateMessageEvent(ctx context.Context, event proton.MessageEvent) ([]imap.Update, error) {
|
||||
full, err := user.client.GetFullMessage(ctx, event.Message.ID)
|
||||
func (user *User) handleCreateMessageEvent(ctx context.Context, message proton.MessageMetadata) ([]imap.Update, error) {
|
||||
user.log.WithFields(logrus.Fields{
|
||||
"messageID": message.ID,
|
||||
"subject": logging.Sensitive(message.Subject),
|
||||
}).Info("Handling message created event")
|
||||
|
||||
full, err := user.client.GetFullMessage(ctx, message.ID)
|
||||
if err != nil {
|
||||
// If the message is not found, it means that it has been deleted before we could fetch it.
|
||||
if apiErr := new(proton.APIError); errors.As(err, &apiErr) && apiErr.Status == http.StatusUnprocessableEntity {
|
||||
user.log.WithField("messageID", event.Message.ID).Warn("Cannot add new message: full message is missing on API")
|
||||
user.log.WithField("messageID", message.ID).Warn("Cannot create new message: full message is missing on API")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@ -514,19 +532,15 @@ func (user *User) handleCreateMessageEvent(ctx context.Context, event proton.Mes
|
||||
}
|
||||
|
||||
return safe.RLockRetErr(func() ([]imap.Update, error) {
|
||||
user.log.WithFields(logrus.Fields{
|
||||
"messageID": event.ID,
|
||||
"subject": logging.Sensitive(event.Message.Subject),
|
||||
}).Info("Handling message created event")
|
||||
|
||||
var update imap.Update
|
||||
if err := withAddrKR(user.apiUser, user.apiAddrs[event.Message.AddressID], user.vault.KeyPass(), func(_, addrKR *crypto.KeyRing) error {
|
||||
|
||||
if err := withAddrKR(user.apiUser, user.apiAddrs[message.AddressID], user.vault.KeyPass(), func(_, addrKR *crypto.KeyRing) error {
|
||||
res := buildRFC822(user.apiLabels, full, addrKR)
|
||||
|
||||
if res.err != nil {
|
||||
user.log.WithError(err).Error("Failed to build RFC822 message")
|
||||
|
||||
if err := user.vault.AddFailedMessageID(event.ID); err != nil {
|
||||
if err := user.vault.AddFailedMessageID(message.ID); err != nil {
|
||||
user.log.WithError(err).Error("Failed to add failed message ID to vault")
|
||||
}
|
||||
|
||||
@ -540,7 +554,7 @@ func (user *User) handleCreateMessageEvent(ctx context.Context, event proton.Mes
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := user.vault.RemFailedMessageID(event.ID); err != nil {
|
||||
if err := user.vault.RemFailedMessageID(message.ID); err != nil {
|
||||
user.log.WithError(err).Error("Failed to remove failed message ID from vault")
|
||||
}
|
||||
|
||||
@ -556,21 +570,21 @@ func (user *User) handleCreateMessageEvent(ctx context.Context, event proton.Mes
|
||||
}, user.apiUserLock, user.apiAddrsLock, user.apiLabelsLock, user.updateChLock)
|
||||
}
|
||||
|
||||
func (user *User) handleUpdateMessageEvent(ctx context.Context, event proton.MessageEvent) ([]imap.Update, error) { //nolint:unparam
|
||||
func (user *User) handleUpdateMessageEvent(ctx context.Context, message proton.MessageMetadata) ([]imap.Update, error) { //nolint:unparam
|
||||
return safe.RLockRetErr(func() ([]imap.Update, error) {
|
||||
user.log.WithFields(logrus.Fields{
|
||||
"messageID": event.ID,
|
||||
"subject": logging.Sensitive(event.Message.Subject),
|
||||
"messageID": message.ID,
|
||||
"subject": logging.Sensitive(message.Subject),
|
||||
}).Info("Handling message updated event")
|
||||
|
||||
update := imap.NewMessageMailboxesUpdated(
|
||||
imap.MessageID(event.ID),
|
||||
mapTo[string, imap.MailboxID](wantLabels(user.apiLabels, event.Message.LabelIDs)),
|
||||
event.Message.Seen(),
|
||||
event.Message.Starred(),
|
||||
imap.MessageID(message.ID),
|
||||
mapTo[string, imap.MailboxID](wantLabels(user.apiLabels, message.LabelIDs)),
|
||||
message.Seen(),
|
||||
message.Starred(),
|
||||
)
|
||||
|
||||
user.updateCh[event.Message.AddressID].Enqueue(update)
|
||||
user.updateCh[message.AddressID].Enqueue(update)
|
||||
|
||||
return []imap.Update{update}, nil
|
||||
}, user.apiLabelsLock, user.updateChLock)
|
||||
@ -603,7 +617,7 @@ func (user *User) handleUpdateDraftEvent(ctx context.Context, event proton.Messa
|
||||
if err != nil {
|
||||
// If the message is not found, it means that it has been deleted before we could fetch it.
|
||||
if apiErr := new(proton.APIError); errors.As(err, &apiErr) && apiErr.Status == http.StatusUnprocessableEntity {
|
||||
user.log.WithField("messageID", event.Message.ID).Warn("Cannot add new draft: full message is missing on API")
|
||||
user.log.WithField("messageID", event.Message.ID).Warn("Cannot update draft: full message is missing on API")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@ -641,6 +655,7 @@ func (user *User) handleUpdateDraftEvent(ctx context.Context, event proton.Messa
|
||||
res.update.Literal,
|
||||
res.update.MailboxIDs,
|
||||
res.update.ParsedMessage,
|
||||
true, // Is the message doesn't exist, silently create it.
|
||||
)
|
||||
|
||||
user.updateCh[full.AddressID].Enqueue(update)
|
||||
|
||||
@ -287,12 +287,28 @@ func (conn *imapConnector) CreateMessage(
|
||||
} else if ok {
|
||||
conn.log.WithField("messageID", messageID).Warn("Message already sent")
|
||||
|
||||
message, err := conn.client.GetMessage(ctx, messageID)
|
||||
// Query the server-side message.
|
||||
full, err := conn.client.GetFullMessage(ctx, messageID)
|
||||
if err != nil {
|
||||
return imap.Message{}, nil, fmt.Errorf("failed to fetch message: %w", err)
|
||||
}
|
||||
|
||||
return toIMAPMessage(message.MessageMetadata), nil, nil
|
||||
// Build the message as it is on the server.
|
||||
if err := safe.RLockRet(func() error {
|
||||
return withAddrKR(conn.apiUser, conn.apiAddrs[full.AddressID], conn.vault.KeyPass(), func(_, addrKR *crypto.KeyRing) error {
|
||||
var err error
|
||||
|
||||
if literal, err = message.BuildRFC822(addrKR, full.Message, full.AttData, defaultJobOpts()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}, conn.apiUserLock, conn.apiAddrsLock); err != nil {
|
||||
return imap.Message{}, nil, fmt.Errorf("failed to build message: %w", err)
|
||||
}
|
||||
|
||||
return toIMAPMessage(full.MessageMetadata), literal, nil
|
||||
}
|
||||
|
||||
wantLabelIDs := []string{string(mailboxID)}
|
||||
@ -427,7 +443,7 @@ func (conn *imapConnector) MoveMessages(ctx context.Context, messageIDs []imap.M
|
||||
result = result || true
|
||||
}
|
||||
|
||||
if v, ok := conn.apiLabels[string(labelToID)]; ok && v.Type == proton.LabelTypeFolder {
|
||||
if v, ok := conn.apiLabels[string(labelToID)]; ok && (v.Type == proton.LabelTypeFolder || v.Type == proton.LabelTypeSystem) {
|
||||
result = result || true
|
||||
}
|
||||
|
||||
|
||||
@ -23,6 +23,7 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/mail"
|
||||
"runtime"
|
||||
"strings"
|
||||
@ -119,7 +120,7 @@ func (user *User) sendMail(authID string, from string, to []string, r io.Reader)
|
||||
}
|
||||
|
||||
// If we have to attach the public key, do it now.
|
||||
if settings.AttachPublicKey == proton.AttachPublicKeyEnabled {
|
||||
if settings.AttachPublicKey {
|
||||
key, err := addrKR.GetKey(0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get sending key: %w", err)
|
||||
@ -419,7 +420,7 @@ func createAttachments(
|
||||
attachment, err := client.UploadAttachment(ctx, addrKR, proton.CreateAttachmentReq{
|
||||
Filename: att.Name,
|
||||
MessageID: draftID,
|
||||
MIMEType: rfc822.MIMEType(att.MIMEType),
|
||||
MIMEType: rfc822.MIMEType(mime.FormatMediaType(att.MIMEType, att.MIMEParams)),
|
||||
Disposition: att.Disposition,
|
||||
ContentID: att.ContentID,
|
||||
Body: att.Data,
|
||||
|
||||
@ -463,6 +463,11 @@ func wantLabel(label proton.Label) bool {
|
||||
|
||||
func wantLabels(apiLabels map[string]proton.Label, labelIDs []string) []string {
|
||||
return xslices.Filter(labelIDs, func(labelID string) bool {
|
||||
return wantLabel(apiLabels[labelID])
|
||||
apiLabel, ok := apiLabels[labelID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return wantLabel(apiLabel)
|
||||
})
|
||||
}
|
||||
|
||||
@ -20,9 +20,11 @@ package user
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@ -32,6 +34,7 @@ import (
|
||||
"github.com/ProtonMail/gluon/queue"
|
||||
"github.com/ProtonMail/gluon/reporter"
|
||||
"github.com/ProtonMail/go-proton-api"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/async"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/events"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/logging"
|
||||
@ -78,7 +81,8 @@ type User struct {
|
||||
updateChLock safe.RWMutex
|
||||
|
||||
tasks *async.Group
|
||||
abortable async.Abortable
|
||||
syncAbort async.Abortable
|
||||
pollAbort async.Abortable
|
||||
goSync func()
|
||||
|
||||
pollAPIEventsCh chan chan struct{}
|
||||
@ -171,42 +175,6 @@ func New(
|
||||
return nil
|
||||
})
|
||||
|
||||
// Stream events from the API, logging any errors that occur.
|
||||
// This does nothing until the sync has been marked as complete.
|
||||
// When we receive an API event, we attempt to handle it.
|
||||
// If successful, we update the event ID in the vault.
|
||||
user.tasks.Once(func(ctx context.Context) {
|
||||
ticker := proton.NewTicker(EventPeriod, EventJitter)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
var doneCh chan struct{}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case doneCh = <-user.pollAPIEventsCh:
|
||||
// ...
|
||||
|
||||
case <-ticker.C:
|
||||
// ...
|
||||
}
|
||||
|
||||
user.log.Debug("Event poll triggered")
|
||||
|
||||
if !user.vault.SyncStatus().IsComplete() {
|
||||
user.log.Debug("Sync is incomplete, skipping event poll")
|
||||
} else if err := user.doEventPoll(ctx); err != nil {
|
||||
user.log.WithError(err).Error("Failed to poll events")
|
||||
}
|
||||
|
||||
if doneCh != nil {
|
||||
close(doneCh)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// When triggered, poll the API for events, optionally blocking until the poll is complete.
|
||||
user.goPollAPIEvents = func(wait bool) {
|
||||
doneCh := make(chan struct{})
|
||||
@ -218,26 +186,46 @@ func New(
|
||||
}
|
||||
}
|
||||
|
||||
// When triggered, attempt to sync the user.
|
||||
// When triggered, sync the user and then begin streaming API events.
|
||||
user.goSync = user.tasks.Trigger(func(ctx context.Context) {
|
||||
user.log.Debug("Sync triggered")
|
||||
user.log.Info("Sync triggered")
|
||||
|
||||
user.abortable.Do(ctx, func(ctx context.Context) {
|
||||
// Sync the user.
|
||||
user.syncAbort.Do(ctx, func(ctx context.Context) {
|
||||
if user.vault.SyncStatus().IsComplete() {
|
||||
user.log.Debug("Sync is already complete, skipping")
|
||||
} else if err := user.doSync(ctx); err != nil {
|
||||
user.log.WithError(err).Error("Failed to sync, will retry later")
|
||||
time.AfterFunc(SyncRetryCooldown, user.goSync)
|
||||
user.log.Info("Sync already complete, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
user.log.WithError(err).Error("Sync aborted")
|
||||
return
|
||||
} else if err := user.doSync(ctx); err != nil {
|
||||
user.log.WithError(err).Error("Failed to sync, will retry later")
|
||||
sleepCtx(ctx, SyncRetryCooldown)
|
||||
} else {
|
||||
user.log.Info("Sync complete, starting API event stream")
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Once we know the sync has completed, we can start polling for API events.
|
||||
if user.vault.SyncStatus().IsComplete() {
|
||||
user.pollAbort.Do(ctx, func(ctx context.Context) {
|
||||
user.startEvents(ctx)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Trigger an initial sync (if necessary).
|
||||
user.goSync()
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (user *User) TriggerSync() {
|
||||
user.goSync()
|
||||
}
|
||||
|
||||
// ID returns the user's ID.
|
||||
func (user *User) ID() string {
|
||||
return safe.RLockRet(func() string {
|
||||
@ -294,22 +282,21 @@ func (user *User) GetAddressMode() vault.AddressMode {
|
||||
func (user *User) SetAddressMode(_ context.Context, mode vault.AddressMode) error {
|
||||
user.log.WithField("mode", mode).Info("Setting address mode")
|
||||
|
||||
user.abortable.Abort()
|
||||
user.syncAbort.Abort()
|
||||
user.pollAbort.Abort()
|
||||
defer user.goSync()
|
||||
|
||||
return safe.LockRet(func() error {
|
||||
user.initUpdateCh(mode)
|
||||
|
||||
if err := user.vault.SetAddressMode(mode); err != nil {
|
||||
return fmt.Errorf("failed to set address mode: %w", err)
|
||||
}
|
||||
|
||||
if err := user.vault.ClearSyncStatus(); err != nil {
|
||||
if err := user.clearSyncStatus(); err != nil {
|
||||
return fmt.Errorf("failed to clear sync status: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, user.apiAddrsLock, user.updateChLock)
|
||||
}, user.eventLock, user.apiAddrsLock, user.updateChLock)
|
||||
}
|
||||
|
||||
// SetShowAllMail sets whether to show the All Mail mailbox.
|
||||
@ -326,12 +313,25 @@ func (user *User) GetGluonIDs() map[string]string {
|
||||
|
||||
// GetGluonID returns the gluon ID for the given address, if present.
|
||||
func (user *User) GetGluonID(addrID string) (string, bool) {
|
||||
gluonID, ok := user.vault.GetGluonIDs()[addrID]
|
||||
if !ok {
|
||||
if gluonID, ok := user.vault.GetGluonIDs()[addrID]; ok {
|
||||
return gluonID, true
|
||||
}
|
||||
|
||||
if user.vault.AddressMode() != vault.CombinedMode {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return gluonID, true
|
||||
// If there is only one address, return its gluon ID.
|
||||
// This can happen if we are in combined mode and the primary address ID has changed.
|
||||
if gluonIDs := maps.Values(user.vault.GetGluonIDs()); len(gluonIDs) == 1 {
|
||||
if err := user.vault.SetGluonID(addrID, gluonIDs[0]); err != nil {
|
||||
user.log.WithError(err).Error("Failed to set gluon ID for updated primary address")
|
||||
}
|
||||
|
||||
return gluonIDs[0], true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// SetGluonID sets the gluon ID for the given address.
|
||||
@ -419,7 +419,9 @@ func (user *User) NewIMAPConnectors() (map[string]connector.Connector, error) {
|
||||
//
|
||||
// nolint:funlen
|
||||
func (user *User) SendMail(authID string, from string, to []string, r io.Reader) error {
|
||||
defer user.goPollAPIEvents(true)
|
||||
if user.vault.SyncStatus().IsComplete() {
|
||||
defer user.goPollAPIEvents(true)
|
||||
}
|
||||
|
||||
if len(to) == 0 {
|
||||
return ErrInvalidRecipient
|
||||
@ -472,15 +474,40 @@ func (user *User) OnStatusUp(context.Context) {
|
||||
func (user *User) OnStatusDown(context.Context) {
|
||||
user.log.Info("Connection is down")
|
||||
|
||||
user.abortable.Abort()
|
||||
user.syncAbort.Abort()
|
||||
user.pollAbort.Abort()
|
||||
}
|
||||
|
||||
// ClearSyncStatus clears the sync status of the user. This triggers a resync.
|
||||
func (user *User) ClearSyncStatus() error {
|
||||
user.abortable.Abort()
|
||||
defer user.goSync()
|
||||
// GetSyncStatus returns the sync status of the user.
|
||||
func (user *User) GetSyncStatus() vault.SyncStatus {
|
||||
return user.vault.GetSyncStatus()
|
||||
}
|
||||
|
||||
return user.vault.ClearSyncStatus()
|
||||
// ClearSyncStatus clears the sync status of the user.
|
||||
// This also drops any updates in the update channel(s).
|
||||
// Warning: the gluon user must be removed and re-added if this happens!
|
||||
func (user *User) ClearSyncStatus() error {
|
||||
user.log.Info("Clearing sync status")
|
||||
|
||||
return safe.LockRet(func() error {
|
||||
return user.clearSyncStatus()
|
||||
}, user.eventLock, user.apiAddrsLock, user.updateChLock)
|
||||
}
|
||||
|
||||
// clearSyncStatus clears the sync status of the user.
|
||||
// This also drops any updates in the update channel(s).
|
||||
// Warning: the gluon user must be removed and re-added if this happens!
|
||||
// It is assumed that the eventLock, apiAddrsLock and updateChLock are already locked.
|
||||
func (user *User) clearSyncStatus() error {
|
||||
user.log.Info("Clearing sync status")
|
||||
|
||||
user.initUpdateCh(user.vault.AddressMode())
|
||||
|
||||
if err := user.vault.ClearSyncStatus(); err != nil {
|
||||
return fmt.Errorf("failed to clear sync status: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logout logs the user out from the API.
|
||||
@ -558,14 +585,50 @@ func (user *User) initUpdateCh(mode vault.AddressMode) {
|
||||
}
|
||||
}
|
||||
|
||||
// startEvents streams events from the API, logging any errors that occur.
|
||||
// This does nothing until the sync has been marked as complete.
|
||||
// When we receive an API event, we attempt to handle it.
|
||||
// If successful, we update the event ID in the vault.
|
||||
func (user *User) startEvents(ctx context.Context) {
|
||||
ticker := proton.NewTicker(EventPeriod, EventJitter)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
var doneCh chan struct{}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case doneCh = <-user.pollAPIEventsCh:
|
||||
// ...
|
||||
|
||||
case <-ticker.C:
|
||||
// ...
|
||||
}
|
||||
|
||||
user.log.Debug("Event poll triggered")
|
||||
|
||||
if err := user.doEventPoll(ctx); err != nil {
|
||||
user.log.WithError(err).Error("Failed to poll events")
|
||||
}
|
||||
|
||||
if doneCh != nil {
|
||||
close(doneCh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// doEventPoll is called whenever API events should be polled.
|
||||
//
|
||||
//nolint:funlen
|
||||
func (user *User) doEventPoll(ctx context.Context) error {
|
||||
user.eventLock.Lock()
|
||||
defer user.eventLock.Unlock()
|
||||
|
||||
event, err := user.client.GetEvent(ctx, user.vault.EventID())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get event: %w", err)
|
||||
return fmt.Errorf("failed to get event (caused by %T): %w", internal.ErrCause(err), err)
|
||||
}
|
||||
|
||||
// If the event ID hasn't changed, there are no new events.
|
||||
@ -581,11 +644,46 @@ func (user *User) doEventPoll(ctx context.Context) error {
|
||||
|
||||
// Handle the event.
|
||||
if err := user.handleAPIEvent(ctx, event); err != nil {
|
||||
// If the error is a context cancellation, return error to retry later.
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return fmt.Errorf("failed to handle event due to context cancellation: %w", err)
|
||||
}
|
||||
|
||||
// If the error is a network error, return error to retry later.
|
||||
if netErr := new(proton.NetError); errors.As(err, &netErr) {
|
||||
return fmt.Errorf("failed to handle event due to network issue: %w", err)
|
||||
}
|
||||
|
||||
// Catch all for uncategorized net errors that may slip through.
|
||||
if netErr := new(net.OpError); errors.As(err, &netErr) {
|
||||
user.eventCh.Enqueue(events.UncategorizedEventError{
|
||||
UserID: user.ID(),
|
||||
Error: err,
|
||||
})
|
||||
|
||||
return fmt.Errorf("failed to handle event due to network issues (uncategorized): %w", err)
|
||||
}
|
||||
|
||||
// In case a json decode error slips through.
|
||||
if jsonErr := new(json.UnmarshalTypeError); errors.As(err, &jsonErr) {
|
||||
user.eventCh.Enqueue(events.UncategorizedEventError{
|
||||
UserID: user.ID(),
|
||||
Error: err,
|
||||
})
|
||||
|
||||
return fmt.Errorf("failed to handle event due to JSON issue: %w", err)
|
||||
}
|
||||
|
||||
// If the error is an unexpected EOF, return error to retry later.
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
user.eventCh.Enqueue(events.UncategorizedEventError{
|
||||
UserID: user.ID(),
|
||||
Error: err,
|
||||
})
|
||||
|
||||
return fmt.Errorf("failed to handle event due to EOF: %w", err)
|
||||
}
|
||||
|
||||
// If the error is a server-side issue, return error to retry later.
|
||||
if apiErr := new(proton.APIError); errors.As(err, &apiErr) && apiErr.Status >= 500 {
|
||||
return fmt.Errorf("failed to handle event due to server error: %w", err)
|
||||
@ -627,3 +725,11 @@ func b32(b bool) uint32 {
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// sleepCtx sleeps for the given duration, or until the context is canceled.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(d):
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,16 +22,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/gluon/connector"
|
||||
"github.com/ProtonMail/go-proton-api"
|
||||
"github.com/ProtonMail/go-proton-api/server"
|
||||
"github.com/ProtonMail/go-proton-api/server/backend"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/bridge/mocks"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/certs"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/events"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
|
||||
"github.com/ProtonMail/proton-bridge/v3/tests"
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
@ -70,101 +66,15 @@ func TestUser_Info(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_Sync(t *testing.T) {
|
||||
withAPI(t, context.Background(), func(ctx context.Context, s *server.Server, m *proton.Manager) {
|
||||
withAccount(t, s, "username", "password", []string{}, func(string, []string) {
|
||||
withUser(t, ctx, s, m, "username", "password", func(user *User) {
|
||||
// User starts a sync at startup.
|
||||
require.IsType(t, events.SyncStarted{}, <-user.GetEventCh())
|
||||
|
||||
// User sends sync progress.
|
||||
require.IsType(t, events.SyncProgress{}, <-user.GetEventCh())
|
||||
|
||||
// User finishes a sync at startup.
|
||||
require.IsType(t, events.SyncFinished{}, <-user.GetEventCh())
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_AddressMode(t *testing.T) {
|
||||
withAPI(t, context.Background(), func(ctx context.Context, s *server.Server, m *proton.Manager) {
|
||||
withAccount(t, s, "username", "password", []string{}, func(string, []string) {
|
||||
withUser(t, ctx, s, m, "username", "password", func(user *User) {
|
||||
// User finishes syncing at startup.
|
||||
require.IsType(t, events.SyncStarted{}, <-user.GetEventCh())
|
||||
require.IsType(t, events.SyncProgress{}, <-user.GetEventCh())
|
||||
require.IsType(t, events.SyncFinished{}, <-user.GetEventCh())
|
||||
|
||||
// By default, user should be in combined mode.
|
||||
require.Equal(t, vault.CombinedMode, user.GetAddressMode())
|
||||
|
||||
// User should be able to switch to split mode.
|
||||
require.NoError(t, user.SetAddressMode(ctx, vault.SplitMode))
|
||||
|
||||
// Create a new set of IMAP connectors (after switching to split mode).
|
||||
imapConn, err := user.NewIMAPConnectors()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Process updates from the new set of IMAP connectors.
|
||||
for _, imapConn := range imapConn {
|
||||
go func(imapConn connector.Connector) {
|
||||
for update := range imapConn.GetUpdates() {
|
||||
update.Done(nil)
|
||||
}
|
||||
}(imapConn)
|
||||
}
|
||||
|
||||
// User finishes syncing after switching to split mode.
|
||||
require.IsType(t, events.SyncStarted{}, <-user.GetEventCh())
|
||||
require.IsType(t, events.SyncProgress{}, <-user.GetEventCh())
|
||||
require.IsType(t, events.SyncFinished{}, <-user.GetEventCh())
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_Deauth(t *testing.T) {
|
||||
withAPI(t, context.Background(), func(ctx context.Context, s *server.Server, m *proton.Manager) {
|
||||
withAccount(t, s, "username", "password", []string{}, func(string, []string) {
|
||||
withUser(t, ctx, s, m, "username", "password", func(user *User) {
|
||||
require.IsType(t, events.SyncStarted{}, <-user.GetEventCh())
|
||||
require.IsType(t, events.SyncProgress{}, <-user.GetEventCh())
|
||||
require.IsType(t, events.SyncFinished{}, <-user.GetEventCh())
|
||||
|
||||
// Revoke the user's auth token.
|
||||
require.NoError(t, s.RevokeUser(user.ID()))
|
||||
|
||||
// The user should eventually be logged out.
|
||||
require.Eventually(t, func() bool { _, ok := (<-user.GetEventCh()).(events.UserDeauth); return ok }, 500*time.Second, 100*time.Millisecond)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_Refresh(t *testing.T) {
|
||||
ctl := gomock.NewController(t)
|
||||
mockReporter := mocks.NewMockReporter(ctl)
|
||||
|
||||
withAPI(t, context.Background(), func(ctx context.Context, s *server.Server, m *proton.Manager) {
|
||||
withAccount(t, s, "username", "password", []string{}, func(string, []string) {
|
||||
withUser(t, ctx, s, m, "username", "password", func(user *User) {
|
||||
require.IsType(t, events.SyncStarted{}, <-user.GetEventCh())
|
||||
require.IsType(t, events.SyncProgress{}, <-user.GetEventCh())
|
||||
require.IsType(t, events.SyncFinished{}, <-user.GetEventCh())
|
||||
|
||||
user.reporter = mockReporter
|
||||
|
||||
mockReporter.EXPECT().ReportMessageWithContext(
|
||||
gomock.Eq("Warning: refresh occurred"),
|
||||
mocks.NewRefreshContextMatcher(proton.RefreshAll),
|
||||
).Return(nil)
|
||||
|
||||
// Send refresh event
|
||||
require.NoError(t, s.RefreshUser(user.ID(), proton.RefreshAll))
|
||||
|
||||
// The user should eventually be re-synced.
|
||||
require.Eventually(t, func() bool { _, ok := (<-user.GetEventCh()).(events.UserRefreshed); return ok }, 5*time.Second, 100*time.Millisecond)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -220,16 +130,5 @@ func withUser(tb testing.TB, ctx context.Context, _ *server.Server, m *proton.Ma
|
||||
require.NoError(tb, err)
|
||||
defer user.Close()
|
||||
|
||||
imapConn, err := user.NewIMAPConnectors()
|
||||
require.NoError(tb, err)
|
||||
|
||||
for _, imapConn := range imapConn {
|
||||
go func(imapConn connector.Connector) {
|
||||
for update := range imapConn.GetUpdates() {
|
||||
update.Done(nil)
|
||||
}
|
||||
}(imapConn)
|
||||
}
|
||||
|
||||
fn(user)
|
||||
}
|
||||
|
||||
@ -18,8 +18,16 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
ForbiddenRollout = 0.6046602879796196
|
||||
)
|
||||
|
||||
// GetIMAPPort sets the port that the IMAP server should listen on.
|
||||
@ -96,7 +104,17 @@ func (vault *Vault) SetUpdateChannel(channel updater.Channel) error {
|
||||
|
||||
// GetUpdateRollout sets the update rollout.
|
||||
func (vault *Vault) GetUpdateRollout() float64 {
|
||||
return vault.get().Settings.UpdateRollout
|
||||
// The rollout value 0.6046602879796196 is forbidden. The RNG was not seeded when it was picked (GODT-2319).
|
||||
rollout := vault.get().Settings.UpdateRollout
|
||||
if math.Abs(rollout-ForbiddenRollout) >= 0.00000001 {
|
||||
return rollout
|
||||
}
|
||||
|
||||
rollout = rand.Float64() //nolint:gosec
|
||||
if err := vault.SetUpdateRollout(rollout); err != nil {
|
||||
logrus.WithError(err).Warning("Failed writing updateRollout value in vault")
|
||||
}
|
||||
return rollout
|
||||
}
|
||||
|
||||
// SetUpdateRollout sets the update rollout.
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
package vault_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
@ -103,6 +104,10 @@ func TestVault_Settings_UpdateRollout(t *testing.T) {
|
||||
|
||||
// Check the new update rollout.
|
||||
require.Equal(t, float64(0.5), s.GetUpdateRollout())
|
||||
|
||||
// Since GODT-2319 0.6046602879796196 is not allowed as a rollout value (RNG was not seeded)
|
||||
require.NoError(t, s.SetUpdateRollout(vault.ForbiddenRollout))
|
||||
require.GreaterOrEqual(t, math.Abs(s.GetUpdateRollout()-vault.ForbiddenRollout), 0.00000001)
|
||||
}
|
||||
|
||||
func TestVault_Settings_ColorScheme(t *testing.T) {
|
||||
|
||||
@ -197,6 +197,11 @@ func (user *User) RemFailedMessageID(messageID string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// GetSyncStatus returns the user's sync status.
|
||||
func (user *User) GetSyncStatus() SyncStatus {
|
||||
return user.vault.getUser(user.userID).SyncStatus
|
||||
}
|
||||
|
||||
// ClearSyncStatus clears the user's sync status.
|
||||
func (user *User) ClearSyncStatus() error {
|
||||
return user.vault.modUser(user.userID, func(data *UserData) {
|
||||
|
||||
@ -66,6 +66,7 @@ type Attachment struct {
|
||||
Name string
|
||||
ContentID string
|
||||
MIMEType string
|
||||
MIMEParams map[string]string
|
||||
Disposition proton.Disposition
|
||||
Data []byte
|
||||
}
|
||||
@ -523,6 +524,7 @@ func parseAttachment(h message.Header, body []byte) (Attachment, error) {
|
||||
return Attachment{}, err
|
||||
}
|
||||
att.MIMEType = mimeType
|
||||
att.MIMEParams = mimeTypeParams
|
||||
|
||||
// Prefer attachment name from filename param in content disposition.
|
||||
// If not available, try to get it from name param in content type.
|
||||
|
||||
@ -186,9 +186,17 @@ func (p *Part) isMultipartMixed() bool {
|
||||
func getContentHeaders(header message.Header) message.Header {
|
||||
var res message.Header
|
||||
|
||||
res.Set("Content-Type", header.Get("Content-Type"))
|
||||
res.Set("Content-Disposition", header.Get("Content-Disposition"))
|
||||
res.Set("Content-Transfer-Encoding", header.Get("Content-Transfer-Encoding"))
|
||||
if contentType := header.Get("Content-Type"); contentType != "" {
|
||||
res.Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
if contentDisposition := header.Get("Content-Disposition"); contentDisposition != "" {
|
||||
res.Set("Content-Disposition", contentDisposition)
|
||||
}
|
||||
|
||||
if contentTransferEncoding := header.Get("Content-Transfer-Encoding"); contentTransferEncoding != "" {
|
||||
res.Set("Content-Transfer-Encoding", contentTransferEncoding)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
@ -64,6 +64,29 @@ Feature: IMAP move messages
|
||||
| john.doe@mail.com | [user:user]@[domain] | baz | false |
|
||||
And IMAP client "1" sees 0 messages in "Labels/label2"
|
||||
|
||||
Scenario: Move message from system label to system label
|
||||
When IMAP client "1" moves the message with subject "foo" from "INBOX" to "Trash"
|
||||
And it succeeds
|
||||
And IMAP client "1" sees the following messages in "INBOX":
|
||||
| from | to | subject | unread |
|
||||
| jane.doe@mail.com | name@[domain] | bar | true |
|
||||
And IMAP client "1" sees the following messages in "Trash":
|
||||
| from | to | subject | unread |
|
||||
| john.doe@mail.com | [user:user]@[domain] | foo | false |
|
||||
|
||||
Scenario: Move message from folder to system label
|
||||
When IMAP client "1" moves the message with subject "baz" from "Labels/label2" to "Folders/mbox"
|
||||
And it succeeds
|
||||
And IMAP client "1" sees the following messages in "Folders/mbox":
|
||||
| from | to | subject | unread |
|
||||
| john.doe@mail.com | [user:user]@[domain] | baz | false |
|
||||
When IMAP client "1" moves the message with subject "baz" from "Folders/mbox" to "Trash"
|
||||
And it succeeds
|
||||
And IMAP client "1" sees 0 messages in "Folders/mbox"
|
||||
And IMAP client "1" sees the following messages in "Trash":
|
||||
| from | to | subject | unread |
|
||||
| john.doe@mail.com | [user:user]@[domain] | baz | false |
|
||||
|
||||
Scenario: Move message from All Mail is not possible
|
||||
When IMAP client "1" moves the message with subject "baz" from "All Mail" to "Folders/folder"
|
||||
Then it fails
|
||||
|
||||
Reference in New Issue
Block a user