Compare commits

..

30 Commits

Author SHA1 Message Date
3210709810 chore: Wakato Bridge 3.7.1 changelog. 2023-11-20 11:56:03 +01:00
8fd988d7c5 fix(GODT-3054): Only delete drafts after message has been Sent
When editing a draft created by Apple Mail on the web client and then
later sending the draft with Apple Mail, we need to delete the draft
ourselves, or it will remain in the Draft folder.

This patch makes sure that the deletion of said draft only occurs after
the message was successfully sent.
2023-11-20 10:37:04 +01:00
bf89d548d3 fix(GODT-2576): Correctly handle Forwarded messages from Thunderbird
Thunderbird uses `In-Reply-To` with `X-Forwarded-Message-Id` to signal
to the SMTP server that it is forwarding a message.
2023-11-16 16:17:54 +01:00
51229cbb68 feat(GODT-3122): added test, changed interface for accessing display name. 2023-11-16 10:44:59 +00:00
36c5c37dac fix(GODT-3122): use display name as 'Email Account Name' in macOS profile. 2023-11-16 10:44:59 +00:00
5a434fafbc fix(GODT-3125): Heartbeat crash on exit
Ensure that the heartbeat background task is stopped before we close
the users as it accesses data within these instances.

Additionally, we also make sure that when telemetry is disabled, we stop
the background task.

Finally, `HeartbeatManager` now specifies what the desired interval is
so we can better configure the test cases.
2023-11-16 11:05:40 +01:00
ea1c2534df fix(GODT-2617): Validate user can send from the SMTP sender address
https://github.com/ProtonMail/go-proton-api/pull/126
2023-11-15 14:13:21 +01:00
1cafbfcaaa chore: Wakato Bridge 3.7.1 changelog. 2023-11-15 12:54:18 +01:00
2d44ccaee0 fix(GODT-3123): Trigger bad event on empty EventID on existing accounts
See `checkIrrecoverableEventID` for more details.
2023-11-15 11:06:51 +01:00
96517b7fb1 chore: Remove debug prints 2023-11-15 09:09:07 +01:00
bc381407a7 feat(GODT-2576): Forward and $Forward Flag Support
When an IMAP client stores the `Forward` or `$Forward` flags on a
message, the forwarded state is now correctly represented on the Proton
servers.

https://github.com/ProtonMail/go-proton-api/pull/125
https://github.com/ProtonMail/gluon/pull/400
2023-11-15 07:51:00 +01:00
ddc5e775b9 fix(GODT-3118): Do not reset EventID when migrating sync settings 2023-11-14 07:03:28 +00:00
ea26188dc0 fix(GODT-2277): Fix keychains initialisation in vault-editor (for write as well). 2023-11-13 15:37:32 +01:00
159e1cee7d fix(GODT-2277): Fix keychains initialisation in vault-editor. 2023-11-13 13:58:03 +00:00
4394ad0e9b feat(GODT-3053): use smaller bridge window on small screens. 2023-11-10 14:23:41 +00:00
856bdd1321 fix(GODT-3116): Panic on closed channel
If sync finishes during shutdown, check if there is a context error in
the deferred go routine before rewinding the event.
2023-11-10 14:47:03 +01:00
ff288145df fix(GODT-1623): Throttle SMTP failed requests
If a SMPT client keeps hammering bridge and triggers multiple successive
errors in quick succession, force that client to wait 20 seconds before
trying again.
2023-11-10 12:54:38 +00:00
83bbdbd63e feat(GODT-3113): Only force UTF-8 charset for HTML part when needed. 2023-11-10 12:50:15 +00:00
fa430ee0fb fix(GODT-3047): fixed 'disk full' error message. 2023-11-10 08:57:53 +00:00
0303ba38e8 feat(GODT-3113): Do not render HTML for attachment. 2023-11-10 08:36:46 +00:00
2a78b5c144 feat(GODT-3112): replaced error message when bridge exists prematurely. Added a link to support form. 2023-11-09 12:52:31 +00:00
a00b3cdb92 fix(GODT-3054): Delete draft create from reply
If an IMAP client creates a new message as a reply/forward from an
existing draft, that draft will be deleted once the message has been
sent.

Other than not being the correct behavior, the original reason for which
this line of code was added (carried over from v2), seems to be no longer
necessary as in all tests, the message is correctly removed from the
drafts folder after sent.
2023-11-09 13:24:38 +01:00
8d3e04679f feat(GODT-3010): Do not log error when no MimeType provided to lower the noise. 2023-11-09 09:45:40 +00:00
21ff7b4b97 feat(GODT-2947): Remove 'blame it on the weather' error part from go-smtp. 2023-11-09 09:45:02 +00:00
4ea161f7ad chore(GODT-3010): Log MimeType parsing issue. 2023-11-08 16:21:19 +00:00
dc584ea29b feat(GODT-3104): added log entry for cert install status on startup on macOS. 2023-11-08 16:30:50 +01:00
4a01c46aed fix(GODT-3048): WKD Policy behavior
Ensure Bridge respects the no encrypt setting on a contact which has a
WKD key.
2023-11-08 14:23:36 +01:00
e8d9534b9c feat(GODT-2277): Move Keychain helpers creation in main. 2023-11-08 13:05:57 +00:00
96904b160f test(GODT-2740): Sending Plain text messages to internal recipient 2023-11-07 10:02:26 +00:00
b535be72f8 test(GODT-2892): Create fake log file 2023-11-07 07:21:26 +00:00
79 changed files with 2820 additions and 554 deletions

View File

@ -133,6 +133,7 @@ Proton Mail Bridge includes the following 3rd party software:
* [yaml](https://gopkg.in/yaml.v3) available under [license](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE)
* [docker-credential-helpers](https://github.com/ProtonMail/docker-credential-helpers) available under [license](https://github.com/ProtonMail/docker-credential-helpers/blob/master/LICENSE)
* [go-message](https://github.com/ProtonMail/go-message) available under [license](https://github.com/ProtonMail/go-message/blob/master/LICENSE)
* [go-smtp](https://github.com/ProtonMail/go-smtp) available under [license](https://github.com/ProtonMail/go-smtp/blob/master/LICENSE)
* [resty](https://github.com/LBeernaertProton/resty/v2) available under [license](https://github.com/LBeernaertProton/resty/v2/blob/master/LICENSE)
* [go-keychain](https://github.com/cuthix/go-keychain) available under [license](https://github.com/cuthix/go-keychain/blob/master/LICENSE)
<!-- END AUTOGEN -->

View File

@ -3,6 +3,40 @@
Changelog [format](http://keepachangelog.com/en/1.0.0/)
## Wakato Bridge 3.7.1
### Added
* Test(GODT-2740): Sending Plain text messages to internal recipient.
* Test(GODT-2892): Create fake log file.
* GODT-3122: Added test, changed interface for accessing display name.
### Changed
* Remove debug prints.
* GODT-2576: Forward and $Forward Flag Support.
* GODT-3053: Use smaller bridge window on small screens.
* GODT-3113: Only force UTF-8 charset for HTML part when needed.
* GODT-3113: Do not render HTML for attachment.
* GODT-3112: Replaced error message when bridge exists prematurely. Added a link to support form.
* GODT-2947: Remove 'blame it on the weather' error part from go-smtp.
* GODT-3010: Log MimeType parsing issue.
* GODT-3104: Added log entry for cert install status on startup on macOS.
* GODT-2277: Move Keychain helpers creation in main.
### Fixed
* GODT-3054: Only delete drafts after message has been Sent.
* GODT-2576: Correctly handle Forwarded messages from Thunderbird.
* GODT-3122: Use display name as 'Email Account Name' in macOS profile.
* GODT-3125: Heartbeat crash on exit.
* GODT-2617: Validate user can send from the SMTP sender address.
* GODT-3123: Trigger bad event on empty EventID on existing accounts.
* GODT-3118: Do not reset EventID when migrating sync settings.
* GODT-3116: Panic on closed channel.
* GODT-1623: Throttle SMTP failed requests.
* GODT-3047: Fixed 'disk full' error message.
* GODT-3054: Delete draft create from reply.
* GODT-3048: WKD Policy behavior.
## Wakato Bridge 3.7.0
### Added

View File

@ -11,7 +11,7 @@ ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
.PHONY: build build-gui build-nogui build-launcher versioner hasher
# Keep version hardcoded so app build works also without Git repository.
BRIDGE_APP_VERSION?=3.7.0+git
BRIDGE_APP_VERSION?=3.7.1+git
APP_VERSION:=${BRIDGE_APP_VERSION}
APP_FULL_NAME:=Proton Mail Bridge
APP_VENDOR:=Proton AG

5
go.mod
View File

@ -5,9 +5,9 @@ go 1.20
require (
github.com/0xAX/notificator v0.0.0-20220220101646-ee9b8921e557
github.com/Masterminds/semver/v3 v3.2.0
github.com/ProtonMail/gluon v0.17.1-0.20231025125916-5c7941465df8
github.com/ProtonMail/gluon v0.17.1-0.20231114153341-2ecbdd2739f7
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/ProtonMail/go-proton-api v0.4.1-0.20231106093533-5f248dfc820d
github.com/ProtonMail/go-proton-api v0.4.1-0.20231116144214-8a47c8d92fbc
github.com/ProtonMail/gopenpgp/v2 v2.7.4-proton
github.com/PuerkitoBio/goquery v1.8.1
github.com/abiosoft/ishell v2.0.0+incompatible
@ -121,6 +121,7 @@ require (
replace (
github.com/docker/docker-credential-helpers => github.com/ProtonMail/docker-credential-helpers v1.1.0
github.com/emersion/go-message => github.com/ProtonMail/go-message v0.13.1-0.20230526094639-b62c999c85b7
github.com/emersion/go-smtp => github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865
github.com/go-resty/resty/v2 => github.com/LBeernaertProton/resty/v2 v2.0.0-20231030122409-92db8bee3605
github.com/keybase/go-keychain => github.com/cuthix/go-keychain v0.0.0-20230517073537-fc1740a83768
)

12
go.sum
View File

@ -25,8 +25,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.17.1-0.20231025125916-5c7941465df8 h1:sG0o5pEoS2z2jNR9zK7Juq5Tr3X+GfHmQ8L99RPowaE=
github.com/ProtonMail/gluon v0.17.1-0.20231025125916-5c7941465df8/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo=
github.com/ProtonMail/gluon v0.17.1-0.20231114153341-2ecbdd2739f7 h1:w+VoSAq9FQvKMm3DlH1MIEZ1KGe7LJ+81EJFVwSV4VU=
github.com/ProtonMail/gluon v0.17.1-0.20231114153341-2ecbdd2739f7/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo=
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-20230321155629-9a39f2531310/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
@ -36,8 +36,10 @@ github.com/ProtonMail/go-message v0.13.1-0.20230526094639-b62c999c85b7 h1:+j+Kd/
github.com/ProtonMail/go-message v0.13.1-0.20230526094639-b62c999c85b7/go.mod h1:NBAn21zgCJ/52WLDyed18YvYFm5tEoeDauubFqLokM4=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/go-proton-api v0.4.1-0.20231106093533-5f248dfc820d h1:LI2kvxBisX19f7lyMh0H6NcAHHg/Y7/x/xZWtxVrXOc=
github.com/ProtonMail/go-proton-api v0.4.1-0.20231106093533-5f248dfc820d/go.mod h1:WEXJqj5DSc2YI77SgXdpMY0nk33Qy92Vu2r4tOEazA8=
github.com/ProtonMail/go-proton-api v0.4.1-0.20231116144214-8a47c8d92fbc h1:GBRKoFAldApEMkMrsFN1ZxG0eG797w6LTv/dFMDcsqQ=
github.com/ProtonMail/go-proton-api v0.4.1-0.20231116144214-8a47c8d92fbc/go.mod h1:WEXJqj5DSc2YI77SgXdpMY0nk33Qy92Vu2r4tOEazA8=
github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865 h1:EP1gnxLL5Z7xBSymE9nSTM27nRYINuvssAtDmG0suD8=
github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=
github.com/ProtonMail/go-srp v0.0.7/go.mod h1:giCp+7qRnMIcCvI6V6U3S1lDDXDQYx2ewJ6F/9wdlJk=
github.com/ProtonMail/gopenpgp/v2 v2.7.4-proton h1:8tqHYM6IGsdEc6Vxf1TWiwpHNj8yIEQNACPhxsDagrk=
@ -122,8 +124,6 @@ github.com/emersion/go-imap-id v0.0.0-20190926060100-f94a56b9ecde/go.mod h1:sPwp
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.15.1-0.20221021114529-49b17434419d h1:hFRM6zCBSc+Xa0rBOqSlG6Qe9dKC/2vLhGAuZlWxTsc=
github.com/emersion/go-smtp v0.15.1-0.20221021114529-49b17434419d/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/emersion/go-vcard v0.0.0-20230331202150-f3d26859ccd3 h1:hQ1wTMaKcGfobYRT88RM8NFNyX+IQHvagkm/tqViU98=

View File

@ -41,6 +41,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/sentry"
"github.com/ProtonMail/proton-bridge/v3/internal/useragent"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/ProtonMail/proton-bridge/v3/pkg/restarter"
"github.com/pkg/profile"
"github.com/sirupsen/logrus"
@ -234,56 +235,56 @@ func run(c *cli.Context) error {
}
return withSingleInstance(settings, locations.GetLockFile(), version, func() error {
// Unlock the encrypted vault.
return WithVault(locations, crashHandler, func(v *vault.Vault, insecure, corrupt bool) error {
if !v.Migrated() {
// Migrate old settings into the vault.
if err := migrateOldSettings(v); err != nil {
logrus.WithError(err).Error("Failed to migrate old settings")
}
// Migrate old accounts into the vault.
if err := migrateOldAccounts(locations, v); err != nil {
logrus.WithError(err).Error("Failed to migrate old accounts")
}
// The vault has been migrated.
if err := v.SetMigrated(); err != nil {
logrus.WithError(err).Error("Failed to mark vault as migrated")
}
}
logrus.WithFields(logrus.Fields{
"lastVersion": v.GetLastVersion().String(),
"showAllMail": v.GetShowAllMail(),
"updateCh": v.GetUpdateChannel(),
"autoUpdate": v.GetAutoUpdate(),
"rollout": v.GetUpdateRollout(),
"DoH": v.GetProxyAllowed(),
}).Info("Vault loaded")
// Load the cookies from the vault.
return withCookieJar(v, func(cookieJar http.CookieJar) error {
// Create a new bridge instance.
return withBridge(c, exe, locations, version, identifier, crashHandler, reporter, v, cookieJar, func(b *bridge.Bridge, eventCh <-chan events.Event) error {
if insecure {
logrus.Warn("The vault key could not be retrieved; the vault will not be encrypted")
b.PushError(bridge.ErrVaultInsecure)
// Look for available keychains
return WithKeychainList(func(keychains *keychain.List) error {
// Unlock the encrypted vault.
return WithVault(locations, keychains, crashHandler, func(v *vault.Vault, insecure, corrupt bool) error {
if !v.Migrated() {
// Migrate old settings into the vault.
if err := migrateOldSettings(v); err != nil {
logrus.WithError(err).Error("Failed to migrate old settings")
}
if corrupt {
logrus.Warn("The vault is corrupt and has been wiped")
b.PushError(bridge.ErrVaultCorrupt)
// Migrate old accounts into the vault.
if err := migrateOldAccounts(locations, keychains, v); err != nil {
logrus.WithError(err).Error("Failed to migrate old accounts")
}
// Remove old updates files
b.RemoveOldUpdates()
// The vault has been migrated.
if err := v.SetMigrated(); err != nil {
logrus.WithError(err).Error("Failed to mark vault as migrated")
}
}
// Start telemetry heartbeat process
b.StartHeartbeat(b)
logrus.WithFields(logrus.Fields{
"lastVersion": v.GetLastVersion().String(),
"showAllMail": v.GetShowAllMail(),
"updateCh": v.GetUpdateChannel(),
"autoUpdate": v.GetAutoUpdate(),
"rollout": v.GetUpdateRollout(),
"DoH": v.GetProxyAllowed(),
}).Info("Vault loaded")
// Run the frontend.
return runFrontend(c, crashHandler, restarter, locations, b, eventCh, quitCh, c.Int(flagParentPID))
// Load the cookies from the vault.
return withCookieJar(v, func(cookieJar http.CookieJar) error {
// Create a new bridge instance.
return withBridge(c, exe, locations, version, identifier, crashHandler, reporter, v, cookieJar, keychains, func(b *bridge.Bridge, eventCh <-chan events.Event) error {
if insecure {
logrus.Warn("The vault key could not be retrieved; the vault will not be encrypted")
b.PushError(bridge.ErrVaultInsecure)
}
if corrupt {
logrus.Warn("The vault is corrupt and has been wiped")
b.PushError(bridge.ErrVaultCorrupt)
}
// Remove old updates files
b.RemoveOldUpdates()
// Run the frontend.
return runFrontend(c, crashHandler, restarter, locations, b, eventCh, quitCh, c.Int(flagParentPID))
})
})
})
})
@ -480,6 +481,13 @@ func withCookieJar(vault *vault.Vault, fn func(http.CookieJar) error) error {
return fn(persister)
}
// WithKeychainList init the list of usable keychains.
func WithKeychainList(fn func(*keychain.List) error) error {
logrus.Debug("Creating keychain list")
defer logrus.Debug("Keychain list stop")
return fn(keychain.NewList())
}
func setDeviceCookies(jar *cookies.Jar) error {
url, err := url.Parse(constants.APIHost)
if err != nil {

View File

@ -37,6 +37,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/useragent"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/internal/versioner"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
@ -55,6 +56,7 @@ func withBridge(
reporter *sentry.Reporter,
vault *vault.Vault,
cookieJar http.CookieJar,
keychains *keychain.List,
fn func(*bridge.Bridge, <-chan events.Event) error,
) error {
logrus.Debug("Creating bridge")
@ -97,6 +99,7 @@ func withBridge(
autostarter,
updater,
version,
keychains,
// The API stuff.
constants.APIHost,
@ -110,6 +113,7 @@ func withBridge(
crashHandler,
reporter,
imap.DefaultEpochUIDValidityGenerator(),
nil,
// The logging stuff.
c.String(flagLogIMAP) == "client" || c.String(flagLogIMAP) == "all",

View File

@ -122,7 +122,7 @@ func migrateOldSettingsWithDir(configDir string, v *vault.Vault) error {
return v.SetBridgeTLSCertKey(certPEM, keyPEM)
}
func migrateOldAccounts(locations *locations.Locations, v *vault.Vault) error {
func migrateOldAccounts(locations *locations.Locations, keychains *keychain.List, v *vault.Vault) error {
logrus.Info("Migrating accounts")
settings, err := locations.ProvideSettingsPath()
@ -134,8 +134,7 @@ func migrateOldAccounts(locations *locations.Locations, v *vault.Vault) error {
if err != nil {
return fmt.Errorf("failed to get helper: %w", err)
}
keychain, err := keychain.NewKeychain(helper, "bridge")
keychain, err := keychain.NewKeychain(helper, "bridge", keychains.GetHelpers(), keychains.GetDefaultHelper())
if err != nil {
return fmt.Errorf("failed to create keychain: %w", err)
}

View File

@ -35,7 +35,6 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/algo"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
dockerCredentials "github.com/docker/docker-credential-helpers/credentials"
"github.com/stretchr/testify/require"
)
@ -133,11 +132,9 @@ func TestKeychainMigration(t *testing.T) {
}
func TestUserMigration(t *testing.T) {
keychainHelper := keychain.NewTestHelper()
kcl := keychain.NewTestKeychainsList()
keychain.Helpers["mock"] = func(string) (dockerCredentials.Helper, error) { return keychainHelper, nil }
kc, err := keychain.NewKeychain("mock", "bridge")
kc, err := keychain.NewKeychain("mock", "bridge", kcl.GetHelpers(), kcl.GetDefaultHelper())
require.NoError(t, err)
require.NoError(t, kc.Put("brokenID", "broken"))
@ -178,7 +175,7 @@ func TestUserMigration(t *testing.T) {
require.NoError(t, err)
require.False(t, corrupt)
require.NoError(t, migrateOldAccounts(locations, v))
require.NoError(t, migrateOldAccounts(locations, kcl, v))
require.Equal(t, []string{wantCredentials.UserID}, v.GetUserIDs())
require.NoError(t, v.GetUser(wantCredentials.UserID, func(u *vault.User) {

View File

@ -22,6 +22,7 @@ import (
"path"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/proton-bridge/v3/internal/certs"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/locations"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
@ -29,12 +30,12 @@ import (
"github.com/sirupsen/logrus"
)
func WithVault(locations *locations.Locations, panicHandler async.PanicHandler, fn func(*vault.Vault, bool, bool) error) error {
func WithVault(locations *locations.Locations, keychains *keychain.List, panicHandler async.PanicHandler, fn func(*vault.Vault, bool, bool) error) error {
logrus.Debug("Creating vault")
defer logrus.Debug("Vault stopped")
// Create the encVault.
encVault, insecure, corrupt, err := newVault(locations, panicHandler)
encVault, insecure, corrupt, err := newVault(locations, keychains, panicHandler)
if err != nil {
return fmt.Errorf("could not create vault: %w", err)
}
@ -44,12 +45,15 @@ func WithVault(locations *locations.Locations, panicHandler async.PanicHandler,
"corrupt": corrupt,
}).Debug("Vault created")
cert, _ := encVault.GetBridgeTLSCert()
certs.NewInstaller().LogCertInstallStatus(cert)
// GODT-1950: Add teardown actions (e.g. to close the vault).
return fn(encVault, insecure, corrupt)
}
func newVault(locations *locations.Locations, panicHandler async.PanicHandler) (*vault.Vault, bool, bool, error) {
func newVault(locations *locations.Locations, keychains *keychain.List, panicHandler async.PanicHandler) (*vault.Vault, bool, bool, error) {
vaultDir, err := locations.ProvideSettingsPath()
if err != nil {
return nil, false, false, fmt.Errorf("could not get vault dir: %w", err)
@ -62,7 +66,7 @@ func newVault(locations *locations.Locations, panicHandler async.PanicHandler) (
insecure bool
)
if key, err := loadVaultKey(vaultDir); err != nil {
if key, err := loadVaultKey(vaultDir, keychains); err != nil {
logrus.WithError(err).Error("Could not load/create vault key")
insecure = true
@ -85,13 +89,13 @@ func newVault(locations *locations.Locations, panicHandler async.PanicHandler) (
return vault, insecure, corrupt, nil
}
func loadVaultKey(vaultDir string) ([]byte, error) {
func loadVaultKey(vaultDir string, keychains *keychain.List) ([]byte, error) {
helper, err := vault.GetHelper(vaultDir)
if err != nil {
return nil, fmt.Errorf("could not get keychain helper: %w", err)
}
kc, err := keychain.NewKeychain(helper, constants.KeyChainName)
kc, err := keychain.NewKeychain(helper, constants.KeyChainName, keychains.GetHelpers(), keychains.GetDefaultHelper())
if err != nil {
return nil, fmt.Errorf("could not create keychain: %w", err)
}

View File

@ -45,6 +45,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/telemetry"
"github.com/ProtonMail/proton-bridge/v3/internal/user"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/bradenaw/juniper/xslices"
"github.com/go-resty/resty/v2"
"github.com/sirupsen/logrus"
@ -74,7 +75,7 @@ type Bridge struct {
installCh chan installJob
// heartbeat is the telemetry heartbeat for metrics.
heartbeat telemetry.Heartbeat
heartbeat *heartBeatState
// curVersion is the current version of the bridge,
// newVersion is the version that was installed by the updater.
@ -82,6 +83,9 @@ type Bridge struct {
newVersion *semver.Version
newVersionLock safe.RWMutex
// keychains is the utils that own usable keychains found in the OS.
keychains *keychain.List
// focusService is used to raise the bridge window when needed.
focusService *focus.Service
@ -124,9 +128,6 @@ type Bridge struct {
// goUpdate triggers a check/install of updates.
goUpdate func()
// goHeartbeat triggers a check/sending if heartbeat is needed.
goHeartbeat func()
serverManager *imapsmtpserver.Service
syncService *syncservice.Service
}
@ -138,6 +139,7 @@ func New(
autostarter Autostarter, // the autostarter to manage autostart settings
updater Updater, // the updater to fetch and install updates
curVersion *semver.Version, // the current version of the bridge
keychains *keychain.List, // usable keychains
apiURL string, // the URL of the API to use
cookieJar http.CookieJar, // the cookie jar to use
@ -148,6 +150,7 @@ func New(
panicHandler async.PanicHandler,
reporter reporter.Reporter,
uidValidityGenerator imap.UIDValidityGenerator,
heartBeatManager telemetry.HeartbeatManager,
logIMAPClient, logIMAPServer bool, // whether to log IMAP client/server activity
logSMTP bool, // whether to log SMTP activity
@ -163,6 +166,7 @@ func New(
// bridge is the bridge.
bridge, err := newBridge(
context.Background(),
tasks,
imapEventCh,
@ -171,6 +175,7 @@ func New(
autostarter,
updater,
curVersion,
keychains,
panicHandler,
reporter,
@ -178,6 +183,7 @@ func New(
identifier,
proxyCtl,
uidValidityGenerator,
heartBeatManager,
logIMAPClient, logIMAPServer, logSMTP,
)
if err != nil {
@ -196,6 +202,7 @@ func New(
}
func newBridge(
ctx context.Context,
tasks *async.Group,
imapEventCh chan imapEvents.Event,
@ -204,6 +211,7 @@ func newBridge(
autostarter Autostarter,
updater Updater,
curVersion *semver.Version,
keychains *keychain.List,
panicHandler async.PanicHandler,
reporter reporter.Reporter,
@ -211,6 +219,7 @@ func newBridge(
identifier identifier.Identifier,
proxyCtl ProxyController,
uidValidityGenerator imap.UIDValidityGenerator,
heartbeatManager telemetry.HeartbeatManager,
logIMAPClient, logIMAPServer, logSMTP bool,
) (*Bridge, error) {
@ -256,9 +265,13 @@ func newBridge(
newVersion: curVersion,
newVersionLock: safe.NewRWMutex(),
keychains: keychains,
panicHandler: panicHandler,
reporter: reporter,
heartbeat: newHeartBeatState(ctx, panicHandler),
focusService: focusService,
autostarter: autostarter,
locator: locator,
@ -288,6 +301,12 @@ func newBridge(
return nil, err
}
if heartbeatManager == nil {
bridge.heartbeat.init(bridge, bridge)
} else {
bridge.heartbeat.init(bridge, heartbeatManager)
}
bridge.syncService.Run(bridge.tasks)
return bridge, nil
@ -417,6 +436,9 @@ func (bridge *Bridge) GetErrors() []error {
func (bridge *Bridge) Close(ctx context.Context) {
logrus.Info("Closing bridge")
// Stop heart beat before closing users.
bridge.heartbeat.stop()
// Close all users.
safe.Lock(func() {
for _, user := range bridge.users {

View File

@ -49,6 +49,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/user"
"github.com/ProtonMail/proton-bridge/v3/internal/useragent"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/ProtonMail/proton-bridge/v3/tests"
"github.com/bradenaw/juniper/xslices"
imapid "github.com/emersion/go-imap-id"
@ -950,6 +951,7 @@ func withBridgeNoMocks(
mocks.Autostarter,
mocks.Updater,
v2_3_0,
keychain.NewTestKeychainsList(),
// The API stuff.
apiURL,
@ -961,6 +963,7 @@ func withBridgeNoMocks(
mocks.CrashHandler,
mocks.Reporter,
testUIDValidityGenerator,
mocks.Heartbeat,
// The logging stuff.
os.Getenv("BRIDGE_LOG_IMAP_CLIENT") == "1",
@ -970,9 +973,6 @@ func withBridgeNoMocks(
require.NoError(t, err)
require.Empty(t, bridge.GetErrors())
// Start the Heartbeat process.
bridge.StartHeartbeat(mocks.Heartbeat)
// Wait for bridge to finish loading users.
waitForEvent(t, eventCh, events.AllUsersLoaded{})

View File

@ -19,6 +19,7 @@ package bridge
import (
"context"
"errors"
"strings"
"github.com/ProtonMail/proton-bridge/v3/internal/clientconfig"
@ -30,8 +31,8 @@ import (
"github.com/sirupsen/logrus"
)
// ConfigureAppleMail configures apple mail for the given userID and address.
// If configuring apple mail for Catalina or newer, it ensures Bridge is using SSL.
// ConfigureAppleMail configures Apple Mail for the given userID and address.
// If configuring Apple Mail for Catalina or newer, it ensures Bridge is using SSL.
func (bridge *Bridge) ConfigureAppleMail(ctx context.Context, userID, address string) error {
logrus.WithFields(logrus.Fields{
"userID": userID,
@ -44,16 +45,28 @@ func (bridge *Bridge) ConfigureAppleMail(ctx context.Context, userID, address st
return ErrNoSuchUser
}
if address == "" {
address = user.Emails()[0]
emails := user.Emails()
displayNames := user.DisplayNames()
if (len(emails) == 0) || (len(displayNames) == 0) {
return errors.New("could not retrieve user address info")
}
username := address
addresses := address
if address == "" {
address = emails[0]
}
var username, displayName, addresses string
if user.GetAddressMode() == vault.CombinedMode {
username = user.Emails()[0]
addresses = strings.Join(user.Emails(), ",")
username = address
displayName = displayNames[username]
addresses = strings.Join(emails, ",")
} else {
username = address
addresses = address
displayName = displayNames[address]
if len(displayName) == 0 {
displayName = address
}
}
if useragent.IsCatalinaOrNewer() && !bridge.vault.GetSMTPSSL() {
@ -69,6 +82,7 @@ func (bridge *Bridge) ConfigureAppleMail(ctx context.Context, userID, address st
bridge.vault.GetIMAPSSL(),
bridge.vault.GetSMTPSSL(),
username,
displayName,
addresses,
user.BridgePass(),
)

View File

@ -20,18 +20,100 @@ package bridge
import (
"context"
"encoding/json"
"sync"
"time"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/telemetry"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/sirupsen/logrus"
)
const HeartbeatCheckInterval = time.Hour
type heartBeatState struct {
task *async.Group
telemetry.Heartbeat
taskLock sync.Mutex
taskStarted bool
taskInterval time.Duration
}
func newHeartBeatState(ctx context.Context, panicHandler async.PanicHandler) *heartBeatState {
return &heartBeatState{
task: async.NewGroup(ctx, panicHandler),
}
}
func (h *heartBeatState) init(bridge *Bridge, manager telemetry.HeartbeatManager) {
h.Heartbeat = telemetry.NewHeartbeat(manager, 1143, 1025, bridge.GetGluonCacheDir(), bridge.keychains.GetDefaultHelper())
h.taskInterval = manager.GetHeartbeatPeriodicInterval()
h.SetRollout(bridge.GetUpdateRollout())
h.SetAutoStart(bridge.GetAutostart())
h.SetAutoUpdate(bridge.GetAutoUpdate())
h.SetBeta(bridge.GetUpdateChannel())
h.SetDoh(bridge.GetProxyAllowed())
h.SetShowAllMail(bridge.GetShowAllMail())
h.SetIMAPConnectionMode(bridge.GetIMAPSSL())
h.SetSMTPConnectionMode(bridge.GetSMTPSSL())
h.SetIMAPPort(bridge.GetIMAPPort())
h.SetSMTPPort(bridge.GetSMTPPort())
h.SetCacheLocation(bridge.GetGluonCacheDir())
if val, err := bridge.GetKeychainApp(); err != nil {
h.SetKeyChainPref(val)
} else {
h.SetKeyChainPref(bridge.keychains.GetDefaultHelper())
}
h.SetPrevVersion(bridge.GetLastVersion().String())
safe.RLock(func() {
var splitMode = false
for _, user := range bridge.users {
if user.GetAddressMode() == vault.SplitMode {
splitMode = true
break
}
}
var nbAccount = len(bridge.users)
h.SetNbAccount(nbAccount)
h.SetSplitMode(splitMode)
// Do not try to send if there is no user yet.
if nbAccount > 0 {
defer h.start()
}
}, bridge.usersLock)
}
func (h *heartBeatState) start() {
h.taskLock.Lock()
defer h.taskLock.Unlock()
if h.taskStarted {
return
}
h.taskStarted = true
h.task.PeriodicOrTrigger(h.taskInterval, 0, func(ctx context.Context) {
logrus.Debug("Checking for heartbeat")
h.TrySending(ctx)
})
}
func (h *heartBeatState) stop() {
h.taskLock.Lock()
defer h.taskLock.Unlock()
if !h.taskStarted {
return
}
h.task.CancelAndWait()
h.taskStarted = false
}
func (bridge *Bridge) IsTelemetryAvailable(ctx context.Context) bool {
var flag = true
if bridge.GetTelemetryDisabled() {
@ -80,49 +162,6 @@ func (bridge *Bridge) SetLastHeartbeatSent(timestamp time.Time) error {
return bridge.vault.SetLastHeartbeatSent(timestamp)
}
func (bridge *Bridge) StartHeartbeat(manager telemetry.HeartbeatManager) {
bridge.heartbeat = telemetry.NewHeartbeat(manager, 1143, 1025, bridge.GetGluonCacheDir(), keychain.DefaultHelper)
// Check for heartbeat when triggered.
bridge.goHeartbeat = bridge.tasks.PeriodicOrTrigger(HeartbeatCheckInterval, 0, func(ctx context.Context) {
logrus.Debug("Checking for heartbeat")
bridge.heartbeat.TrySending(ctx)
})
bridge.heartbeat.SetRollout(bridge.GetUpdateRollout())
bridge.heartbeat.SetAutoStart(bridge.GetAutostart())
bridge.heartbeat.SetAutoUpdate(bridge.GetAutoUpdate())
bridge.heartbeat.SetBeta(bridge.GetUpdateChannel())
bridge.heartbeat.SetDoh(bridge.GetProxyAllowed())
bridge.heartbeat.SetShowAllMail(bridge.GetShowAllMail())
bridge.heartbeat.SetIMAPConnectionMode(bridge.GetIMAPSSL())
bridge.heartbeat.SetSMTPConnectionMode(bridge.GetSMTPSSL())
bridge.heartbeat.SetIMAPPort(bridge.GetIMAPPort())
bridge.heartbeat.SetSMTPPort(bridge.GetSMTPPort())
bridge.heartbeat.SetCacheLocation(bridge.GetGluonCacheDir())
if val, err := bridge.GetKeychainApp(); err != nil {
bridge.heartbeat.SetKeyChainPref(val)
} else {
bridge.heartbeat.SetKeyChainPref(keychain.DefaultHelper)
}
bridge.heartbeat.SetPrevVersion(bridge.GetLastVersion().String())
safe.RLock(func() {
var splitMode = false
for _, user := range bridge.users {
if user.GetAddressMode() == vault.SplitMode {
splitMode = true
break
}
}
var nbAccount = len(bridge.users)
bridge.heartbeat.SetNbAccount(nbAccount)
bridge.heartbeat.SetSplitMode(splitMode)
// Do not try to send if there is no user yet.
if nbAccount > 0 {
defer bridge.goHeartbeat()
}
}, bridge.usersLock)
func (bridge *Bridge) GetHeartbeatPeriodicInterval() time.Duration {
return HeartbeatCheckInterval
}

View File

@ -0,0 +1,24 @@
// 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 bridge
import "golang.org/x/exp/maps"
func (bridge *Bridge) GetHelpersNames() []string {
return maps.Keys(bridge.keychains.GetHelpers())
}

View File

@ -7,6 +7,7 @@ import (
"os"
"sync"
"testing"
"time"
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge/mocks"
@ -51,6 +52,7 @@ func NewMocks(tb testing.TB, version, minAuto *semver.Version) *Mocks {
// this is called at start of heartbeat process.
mocks.Heartbeat.EXPECT().IsTelemetryAvailable(gomock.Any()).AnyTimes()
mocks.Heartbeat.EXPECT().GetHeartbeatPeriodicInterval().AnyTimes().Return(500 * time.Millisecond)
return mocks
}

View File

@ -36,6 +36,20 @@ func (m *MockHeartbeatManager) EXPECT() *MockHeartbeatManagerMockRecorder {
return m.recorder
}
// GetHeartbeatPeriodicInterval mocks base method.
func (m *MockHeartbeatManager) GetHeartbeatPeriodicInterval() time.Duration {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetHeartbeatPeriodicInterval")
ret0, _ := ret[0].(time.Duration)
return ret0
}
// GetHeartbeatPeriodicInterval indicates an expected call of GetHeartbeatPeriodicInterval.
func (mr *MockHeartbeatManagerMockRecorder) GetHeartbeatPeriodicInterval() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHeartbeatPeriodicInterval", reflect.TypeOf((*MockHeartbeatManager)(nil).GetHeartbeatPeriodicInterval))
}
// GetLastHeartbeatSent mocks base method.
func (m *MockHeartbeatManager) GetLastHeartbeatSent() time.Time {
m.ctrl.T.Helper()

View File

@ -33,6 +33,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
smtpservice "github.com/ProtonMail/proton-bridge/v3/internal/services/smtp"
"github.com/emersion/go-imap"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
@ -701,3 +702,56 @@ Hello world
})
})
}
func TestBridge_SendAddressDisabled(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
recipientUserID, _, err := s.CreateUser("recipient", password)
require.NoError(t, err)
senderUserID, addrID, err := s.CreateUser("sender", password)
require.NoError(t, err)
require.NoError(t, s.ChangeAddressAllowSend(senderUserID, addrID, false))
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
senderUserID, err := bridge.LoginFull(ctx, "sender", password, nil, nil)
require.NoError(t, err)
_, err = bridge.LoginFull(ctx, "recipient", password, nil, nil)
require.NoError(t, err)
smtpWaiter.Wait()
recipientInfo, err := bridge.GetUserInfo(recipientUserID)
require.NoError(t, err)
senderInfo, err := bridge.GetUserInfo(senderUserID)
require.NoError(t, err)
// Dial the server.
client, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
require.NoError(t, err)
defer client.Close() //nolint:errcheck
// Upgrade to TLS.
require.NoError(t, client.StartTLS(&tls.Config{InsecureSkipVerify: true}))
require.NoError(t, client.Auth(sasl.NewLoginClient(
senderInfo.Addresses[0],
string(senderInfo.BridgePass)),
))
// Send the message.
err = client.SendMail(
senderInfo.Addresses[0],
[]string{recipientInfo.Addresses[0]},
strings.NewReader("Subject: Test 1\r\n\r\nHello world!"),
)
smtpErr := smtpservice.NewErrCanNotSendOnAddress(senderInfo.Addresses[0])
require.Equal(t, fmt.Sprintf("Error: %v", smtpErr.Error()), err.Error())
})
})
}

View File

@ -261,9 +261,12 @@ func (bridge *Bridge) SetTelemetryDisabled(isDisabled bool) error {
return err
}
// If telemetry is re-enabled locally, try to send the heartbeat.
if !isDisabled {
defer bridge.goHeartbeat()
if isDisabled {
bridge.heartbeat.stop()
} else {
bridge.heartbeat.start()
}
return nil
}

View File

@ -494,7 +494,7 @@ func (bridge *Bridge) addUser(
return fmt.Errorf("failed to add vault user: %w", err)
}
if err := bridge.addUserWithVault(ctx, client, apiUser, vaultUser); err != nil {
if err := bridge.addUserWithVault(ctx, client, apiUser, vaultUser, isNew); err != nil {
if _, ok := err.(*resty.ResponseError); ok || isLogin {
logrus.WithError(err).Error("Failed to add user, clearing its secrets from vault")
@ -529,6 +529,7 @@ func (bridge *Bridge) addUserWithVault(
client *proton.Client,
apiUser proton.User,
vault *vault.User,
isNew bool,
) error {
statsPath, err := bridge.locator.ProvideStatsPath()
if err != nil {
@ -556,6 +557,7 @@ func (bridge *Bridge) addUserWithVault(
&bridgeEventSubscription{b: bridge},
bridge.syncService,
syncSettingsPath,
isNew,
)
if err != nil {
return fmt.Errorf("failed to create user: %w", err)
@ -592,7 +594,7 @@ func (bridge *Bridge) addUserWithVault(
}, bridge.usersLock)
// As we need at least one user to send heartbeat, try to send it.
defer bridge.goHeartbeat()
bridge.heartbeat.start()
return nil
}

View File

@ -356,6 +356,10 @@ func removeCertTrustCGo(buffer *C.char, size C.ulonglong) error {
}
}
func osSupportCertInstall() bool {
return true
}
// installCert installs a certificate in the keychain. The certificate is added to the keychain and it is set as trusted.
// This function will trigger a security prompt from the system, unless the certificate is already trusted in the user keychain.
func installCert(certPEM []byte) error {

View File

@ -28,6 +28,7 @@ import (
func TestCertInKeychain(t *testing.T) {
// no trust settings change is performed, so this test will not trigger an OS security prompt.
certPEM := generatePEMCertificate(t)
require.True(t, osSupportCertInstall())
require.False(t, isCertInKeychain(certPEM))
require.NoError(t, addCertToKeychain(certPEM))
require.True(t, isCertInKeychain(certPEM))

View File

@ -17,6 +17,10 @@
package certs
func osSupportCertInstall() bool {
return false
}
func installCert([]byte) error {
return nil // Linux doesn't have a root cert store.
}

View File

@ -17,6 +17,10 @@
package certs
func osSupportCertInstall() bool {
return false
}
func installCert([]byte) error {
return nil // NOTE(GODT-986): Install certs to root cert store?
}

View File

@ -37,6 +37,10 @@ func NewInstaller() *Installer {
}
}
func (installer *Installer) OSSupportCertInstall() bool {
return osSupportCertInstall()
}
func (installer *Installer) InstallCert(certPEM []byte) error {
installer.log.Info("Installing the Bridge TLS certificate in the OS keychain")
@ -64,3 +68,15 @@ func (installer *Installer) UninstallCert(certPEM []byte) error {
func (installer *Installer) IsCertInstalled(certPEM []byte) bool {
return isCertInstalled(certPEM)
}
// LogCertInstallStatus reports the current status of the certificate installation in the log.
// If certificate installation is not supported on the platform, this function does nothing.
func (installer *Installer) LogCertInstallStatus(certPEM []byte) {
if installer.OSSupportCertInstall() {
if installer.IsCertInstalled(certPEM) {
installer.log.Info("The Bridge TLS certificate is installed in the OS keychain")
} else {
installer.log.Info("The Bridge TLS certificate is not installed in the OS keychain")
}
}
}

View File

@ -39,10 +39,10 @@ func (c *AppleMail) Configure(
hostname string,
imapPort, smtpPort int,
imapSSL, smtpSSL bool,
username, addresses string,
username, displayName, addresses string,
password []byte,
) error {
mc := prepareMobileConfig(hostname, imapPort, smtpPort, imapSSL, smtpSSL, username, addresses, password)
mc := prepareMobileConfig(hostname, imapPort, smtpPort, imapSSL, smtpSSL, username, displayName, addresses, password)
confPath, err := saveConfigTemporarily(mc)
if err != nil {
@ -66,13 +66,13 @@ func prepareMobileConfig(
hostname string,
imapPort, smtpPort int,
imapSSL, smtpSSL bool,
username, addresses string,
username, displayName, addresses string,
password []byte,
) *mobileconfig.Config {
return &mobileconfig.Config{
DisplayName: username,
EmailAddress: addresses,
AccountName: username,
AccountName: displayName,
AccountDescription: username,
Identifier: "protonmail " + username + strconv.FormatInt(time.Now().Unix(), 10),
IMAP: &mobileconfig.IMAP{

View File

@ -415,7 +415,11 @@ int main(int argc, char *argv[]) {
}
catch (Exception const &e) {
sentry_uuid_s const uuid = reportSentryException("Exception occurred during main", e);
QMessageBox::critical(nullptr, "Error", e.qwhat());
QString message = e.qwhat();
if (e.showSupportLink()) {
message += R"(<br/><br/>If the issue persists, please contact our <a href="https://proton.me/support/contact">customer support</a>.)";
}
QMessageBox::critical(nullptr, "Error", message);
QTextStream(stderr) << "reportID: " << QByteArray(uuid.bytes, 16).toHex() << " Captured exception :" << e.detailedWhat() << "\n";
return EXIT_FAILURE;
}

View File

@ -69,8 +69,8 @@ ApplicationWindow {
}
colorScheme: ProtonStyle.currentStyle
height: ProtonStyle.window_default_height
minimumHeight:ProtonStyle.window_minimum_height
height: screen.height < ProtonStyle.window_default_height + 100 ? ProtonStyle.window_minimum_height : ProtonStyle.window_default_height
minimumHeight: ProtonStyle.window_minimum_height
minimumWidth: ProtonStyle.window_minimum_width
visible: true
width: ProtonStyle.window_default_width

View File

@ -380,7 +380,7 @@ QtObject {
}
property Notification diskFull: Notification {
brief: title
description: qsTr("Quit Bridge and free disk space or disable the local cache (not recommended).")
description: qsTr("Quit Bridge and free disk space or move the local cache to another disk.")
group: Notifications.Group.Configuration | Notifications.Group.Dialogs
icon: "./icons/ic-exclamation-circle-filled.svg"
title: qsTr("Your disk is almost full")

View File

@ -26,14 +26,16 @@ namespace bridgepp {
/// \param[in] what A description of the exception.
/// \param[in] details The optional details for the exception.
/// \param[in] function The name of the calling function.
/// \param[in] showSupportLink Should a link to the support web form be included in GUI message.
//****************************************************************************************************************************************************
Exception::Exception(QString qwhat, QString details, QString function, QByteArray attachment) noexcept
Exception::Exception(QString qwhat, QString details, QString function, QByteArray attachment, bool showSupportLink) noexcept
: std::exception()
, qwhat_(std::move(qwhat))
, what_(qwhat_.toLocal8Bit())
, details_(std::move(details))
, function_(std::move(function))
, attachment_(std::move(attachment)) {
, attachment_(std::move(attachment))
, showSupportLink_(showSupportLink) {
}
@ -46,7 +48,8 @@ Exception::Exception(Exception const &ref) noexcept
, what_(ref.what_)
, details_(ref.details_)
, function_(ref.function_)
, attachment_(ref.attachment_) {
, attachment_(ref.attachment_)
, showSupportLink_(ref.showSupportLink_) {
}
@ -59,7 +62,8 @@ Exception::Exception(Exception &&ref) noexcept
, what_(ref.what_)
, details_(ref.details_)
, function_(ref.function_)
, attachment_(ref.attachment_) {
, attachment_(ref.attachment_)
, showSupportLink_(ref.showSupportLink_) {
}
@ -118,4 +122,12 @@ QString Exception::detailedWhat() const {
}
//****************************************************************************************************************************************************
/// \return true iff A link to the support page should shown in the GUI message box.
//****************************************************************************************************************************************************
bool Exception::showSupportLink() const {
return showSupportLink_;
}
} // namespace bridgepp

View File

@ -33,7 +33,7 @@ namespace bridgepp {
class Exception : public std::exception {
public: // member functions
explicit Exception(QString qwhat = QString(), QString details = QString(), QString function = QString(),
QByteArray attachment = QByteArray()) noexcept; ///< Constructor
QByteArray attachment = QByteArray(), bool showSupportLink = false) noexcept; ///< Constructor
Exception(Exception const &ref) noexcept; ///< copy constructor
Exception(Exception &&ref) noexcept; ///< copy constructor
Exception &operator=(Exception const &) = delete; ///< Disabled assignment operator
@ -45,6 +45,7 @@ public: // member functions
QString function() const noexcept; ///< Return the function that threw the exception.
QByteArray attachment() const noexcept; ///< Return the attachment for the exception.
QString detailedWhat() const; ///< Return the detailed description of the message (i.e. including the function name and the details).
bool showSupportLink() const; ///< Return the value for the 'Show support link' option.
public: // static data members
static qsizetype const attachmentMaxLength {25 * 1024}; ///< The maximum length text attachment sent in Sentry reports, in bytes.
@ -55,6 +56,7 @@ private: // data members
QString const details_; ///< The optional details for the exception.
QString const function_; ///< The name of the function that created the exception.
QByteArray const attachment_; ///< The attachment to add to the exception.
bool const showSupportLink_; ///< Should the GUI feedback include a link to support.
};

View File

@ -72,8 +72,8 @@ GRPCConfig GRPCClient::waitAndRetrieveServiceConfig(QString const & sessionID, Q
bool found = false;
while (true) {
if (serverProcess && serverProcess->getStatus().ended) {
throw Exception("Bridge application exited before providing a gRPC service configuration file.", QString(), __FUNCTION__,
tailOfLatestBridgeLog(sessionID));
throw Exception("Bridge failed to start.", "Bridge application exited before providing a gRPC service configuration file", __FUNCTION__,
tailOfLatestBridgeLog(sessionID), true);
}
if (file.exists()) {

View File

@ -33,10 +33,8 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/service"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/ProtonMail/proton-bridge/v3/pkg/ports"
"github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/runtime/protoimpl"
@ -712,7 +710,7 @@ func (s *Service) IsPortFree(_ context.Context, port *wrapperspb.Int32Value) (*w
func (s *Service) AvailableKeychains(_ context.Context, _ *emptypb.Empty) (*AvailableKeychainsResponse, error) {
s.log.Debug("AvailableKeychains")
return &AvailableKeychainsResponse{Keychains: maps.Keys(keychain.Helpers)}, nil
return &AvailableKeychainsResponse{Keychains: s.bridge.GetHelpersNames()}, nil
}
func (s *Service) SetCurrentKeychain(ctx context.Context, keychain *wrapperspb.StringValue) (*emptypb.Empty, error) {

View File

@ -48,4 +48,6 @@ type APIClient interface {
DeleteMessage(ctx context.Context, messageIDs ...string) error
MarkMessagesRead(ctx context.Context, messageIDs ...string) error
MarkMessagesUnread(ctx context.Context, messageIDs ...string) error
MarkMessagesForwarded(ctx context.Context, messageIDs ...string) error
MarkMessagesUnForwarded(ctx context.Context, messageIDs ...string) error
}

View File

@ -84,9 +84,9 @@ func NewConnector(
identityState: identityState,
addrID: addrID,
showAllMail: b32(showAllMail),
flags: defaultFlags,
permFlags: defaultPermanentFlags,
attrs: defaultAttributes,
flags: defaultMailboxFlags(),
permFlags: defaultMailboxPermanentFlags(),
attrs: defaultMailboxAttributes(),
client: apiClient,
telemetry: telemetry,
@ -144,6 +144,18 @@ func (s *Connector) Init(ctx context.Context, cache connector.IMAPState) error {
}
}
}
// Retroactively apply the forwarded flags to existing mailboxes so that the IMAP clients can recognize
// that they can store these flags now.
if err := write.AddFlagsToAllMailboxes(ctx, imap.ForwardFlagList...); err != nil {
return fmt.Errorf("failed to add \\Forward flag to all mailboxes:%w", err)
}
// Add forwarded flag as perm flags to all mailboxes.
if err := write.AddPermFlagsToAllMailboxes(ctx, imap.ForwardFlagList...); err != nil {
return fmt.Errorf("failed to add \\Forward permanent flag to all mailboxes:%w", err)
}
return nil
})
}
@ -487,6 +499,14 @@ func (s *Connector) MarkMessagesFlagged(ctx context.Context, _ connector.IMAPSta
return s.client.UnlabelMessages(ctx, usertypes.MapTo[imap.MessageID, string](messageIDs), proton.StarredLabel)
}
func (s *Connector) MarkMessagesForwarded(ctx context.Context, _ connector.IMAPStateWrite, messageIDs []imap.MessageID, flagged bool) error {
if flagged {
return s.client.MarkMessagesForwarded(ctx, usertypes.MapTo[imap.MessageID, string](messageIDs)...)
}
return s.client.MarkMessagesUnForwarded(ctx, usertypes.MapTo[imap.MessageID, string](messageIDs)...)
}
func (s *Connector) GetUpdates() <-chan imap.Update {
return s.updateCh.GetChannel()
}
@ -501,12 +521,6 @@ func (s *Connector) ShowAllMail(v bool) {
atomic.StoreUint32(&s.showAllMail, b32(v))
}
var (
defaultFlags = imap.NewFlagSet(imap.FlagSeen, imap.FlagFlagged, imap.FlagDeleted) // nolint:gochecknoglobals
defaultPermanentFlags = imap.NewFlagSet(imap.FlagSeen, imap.FlagFlagged, imap.FlagDeleted) // nolint:gochecknoglobals
defaultAttributes = imap.NewFlagSet() // nolint:gochecknoglobals
)
const (
folderPrefix = "Folders"
labelPrefix = "Labels"
@ -812,3 +826,18 @@ func fixGODT3003Labels(
return applied, nil
}
func defaultMailboxFlags() imap.FlagSet {
f := imap.NewFlagSet(imap.FlagSeen, imap.FlagFlagged, imap.FlagDeleted)
f.AddToSelf(imap.ForwardFlagList...)
return f
}
func defaultMailboxPermanentFlags() imap.FlagSet {
return defaultMailboxFlags()
}
func defaultMailboxAttributes() imap.FlagSet {
return imap.NewFlagSet()
}

View File

@ -68,6 +68,10 @@ func BuildFlagSetFromMessageMetadata(message proton.MessageMetadata) imap.FlagSe
flags.AddToSelf(imap.FlagAnswered)
}
if message.IsForwarded {
flags.AddToSelf(imap.ForwardFlagList...)
}
return flags
}

View File

@ -32,8 +32,8 @@ func newSystemMailboxCreatedUpdate(labelID imap.MailboxID, labelName string) *im
}
attrs := imap.NewFlagSet(imap.AttrNoInferiors)
permanentFlags := defaultPermanentFlags
flags := defaultFlags
permanentFlags := defaultMailboxPermanentFlags()
flags := defaultMailboxFlags()
switch labelID {
case proton.TrashLabel:
@ -86,8 +86,8 @@ func newPlaceHolderMailboxCreatedUpdate(labelName string) *imap.MailboxCreated {
return imap.NewMailboxCreated(imap.Mailbox{
ID: imap.MailboxID(labelName),
Name: []string{labelName},
Flags: defaultFlags,
PermanentFlags: defaultPermanentFlags,
Flags: defaultMailboxFlags(),
PermanentFlags: defaultMailboxPermanentFlags(),
Attributes: imap.NewFlagSet(imap.AttrNoSelect),
})
}
@ -96,8 +96,8 @@ func newMailboxCreatedUpdate(labelID imap.MailboxID, labelName []string) *imap.M
return imap.NewMailboxCreated(imap.Mailbox{
ID: labelID,
Name: labelName,
Flags: defaultFlags,
PermanentFlags: defaultPermanentFlags,
Flags: defaultMailboxFlags(),
PermanentFlags: defaultMailboxPermanentFlags(),
Attributes: imap.NewFlagSet(),
})
}

View File

@ -35,6 +35,44 @@ func (m *MockIMAPStateWrite) EXPECT() *MockIMAPStateWriteMockRecorder {
return m.recorder
}
// AddFlagsToAllMailboxes mocks base method.
func (m *MockIMAPStateWrite) AddFlagsToAllMailboxes(arg0 context.Context, arg1 ...string) error {
m.ctrl.T.Helper()
varargs := []interface{}{arg0}
for _, a := range arg1 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "AddFlagsToAllMailboxes", varargs...)
ret0, _ := ret[0].(error)
return ret0
}
// AddFlagsToAllMailboxes indicates an expected call of AddFlagsToAllMailboxes.
func (mr *MockIMAPStateWriteMockRecorder) AddFlagsToAllMailboxes(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0}, arg1...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddFlagsToAllMailboxes", reflect.TypeOf((*MockIMAPStateWrite)(nil).AddFlagsToAllMailboxes), varargs...)
}
// AddPermFlagsToAllMailboxes mocks base method.
func (m *MockIMAPStateWrite) AddPermFlagsToAllMailboxes(arg0 context.Context, arg1 ...string) error {
m.ctrl.T.Helper()
varargs := []interface{}{arg0}
for _, a := range arg1 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "AddPermFlagsToAllMailboxes", varargs...)
ret0, _ := ret[0].(error)
return ret0
}
// AddPermFlagsToAllMailboxes indicates an expected call of AddPermFlagsToAllMailboxes.
func (mr *MockIMAPStateWriteMockRecorder) AddPermFlagsToAllMailboxes(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0}, arg1...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPermFlagsToAllMailboxes", reflect.TypeOf((*MockIMAPStateWrite)(nil).AddPermFlagsToAllMailboxes), varargs...)
}
// CreateMailbox mocks base method.
func (m *MockIMAPStateWrite) CreateMailbox(arg0 context.Context, arg1 imap.Mailbox) error {
m.ctrl.T.Helper()

View File

@ -395,6 +395,11 @@ func (s *Service) run(ctx context.Context) { //nolint gocyclo
// event service is unable to reply to the request until the events have been processed.
s.log.Info("Sync complete, starting API event stream")
go func() {
// If context cancelled do not do anything
if ctx.Err() != nil {
return
}
if err := s.eventProvider.RewindEventID(ctx, s.lastHandledEventID); err != nil {
if errors.Is(err, context.Canceled) {
return

View File

@ -21,16 +21,21 @@ import (
"context"
"io"
"sync"
"time"
)
type Accounts struct {
accountsLock sync.RWMutex
accounts map[string]*Service
accounts map[string]*smtpAccountState
}
const maxFailedCommands = 3
const defaultErrTimeout = 20 * time.Second
const successiveErrInterval = time.Second
func NewAccounts() *Accounts {
return &Accounts{
accounts: make(map[string]*Service),
accounts: make(map[string]*smtpAccountState),
}
}
@ -38,7 +43,10 @@ func (s *Accounts) AddAccount(account *Service) {
s.accountsLock.Lock()
defer s.accountsLock.Unlock()
s.accounts[account.UserID()] = account
s.accounts[account.UserID()] = &smtpAccountState{
service: account,
errTimeout: defaultErrTimeout,
}
}
func (s *Accounts) RemoveAccount(account *Service) {
@ -52,18 +60,18 @@ func (s *Accounts) CheckAuth(user string, password []byte) (string, string, erro
s.accountsLock.RLock()
defer s.accountsLock.RUnlock()
for id, service := range s.accounts {
addrID, err := service.checkAuth(context.Background(), user, password)
for id, account := range s.accounts {
addrID, err := account.service.checkAuth(context.Background(), user, password)
if err != nil {
continue
}
service.telemetry.ReportSMTPAuthSuccess(context.Background())
account.service.telemetry.ReportSMTPAuthSuccess(context.Background())
return id, addrID, nil
}
for _, service := range s.accounts {
service.telemetry.ReportSMTPAuthFailed(user)
service.service.telemetry.ReportSMTPAuthFailed(user)
}
return "", "", ErrNoSuchUser
@ -77,10 +85,57 @@ func (s *Accounts) SendMail(ctx context.Context, userID, addrID, from string, to
s.accountsLock.RLock()
defer s.accountsLock.RUnlock()
service, ok := s.accounts[userID]
requestTime := time.Now()
account, ok := s.accounts[userID]
if !ok {
return ErrNoSuchUser
}
return service.SendMail(ctx, addrID, from, to, r)
if err := account.canMakeRequest(requestTime); err != nil {
return err
}
err := account.service.SendMail(ctx, addrID, from, to, r)
account.handleSMTPErr(requestTime, err)
return err
}
type smtpAccountState struct {
service *Service
errTimeout time.Duration
errLock sync.Mutex
errCounter int
lastRequest time.Time
}
func (s *smtpAccountState) canMakeRequest(requestTime time.Time) error {
s.errLock.Lock()
defer s.errLock.Unlock()
if s.errCounter >= maxFailedCommands {
if requestTime.Sub(s.lastRequest) >= s.errTimeout {
s.errCounter = 0
return nil
}
return ErrTooManyErrors
}
return nil
}
func (s *smtpAccountState) handleSMTPErr(requestTime time.Time, err error) {
s.errLock.Lock()
defer s.errLock.Unlock()
if err == nil || requestTime.Sub(s.lastRequest) > successiveErrInterval {
s.errCounter = 0
} else {
s.errCounter++
}
s.lastRequest = requestTime
}

View File

@ -0,0 +1,46 @@
// 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 smtp
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestAccountTimeout(t *testing.T) {
account := smtpAccountState{errTimeout: 5 * time.Second}
err := errors.New("fail")
for i := 0; i <= maxFailedCommands; i++ {
requestTime := time.Now()
assert.Nil(t, account.canMakeRequest(requestTime))
account.handleSMTPErr(requestTime, err)
}
{
requestTime := time.Now()
assert.ErrorIs(t, account.canMakeRequest(requestTime), ErrTooManyErrors)
}
assert.Eventually(t, func() bool {
requestTime := time.Now()
return account.canMakeRequest(requestTime) == nil
}, 10*time.Second, time.Second)
}

View File

@ -17,8 +17,24 @@
package smtp
import "errors"
import (
"errors"
"fmt"
)
var ErrInvalidRecipient = errors.New("invalid recipient")
var ErrInvalidReturnPath = errors.New("invalid return path")
var ErrNoSuchUser = errors.New("no such user")
var ErrTooManyErrors = errors.New("too many failed requests, please try again later")
type ErrCanNotSendOnAddress struct {
address string
}
func NewErrCanNotSendOnAddress(address string) *ErrCanNotSendOnAddress {
return &ErrCanNotSendOnAddress{address: address}
}
func (e ErrCanNotSendOnAddress) Error() string {
return fmt.Sprintf("can't send on address: %v", e.address)
}

View File

@ -97,11 +97,16 @@ func (s *Service) smtpSendMail(ctx context.Context, authID string, from string,
from = sender
fromAddr, err = s.identityState.GetAddr(from)
if err != nil {
logrus.WithError(err).Errorf("Failed to get identity from sender address %v", sender)
logrus.WithError(err).Errorf("Failed to get identity for from address %v", sender)
return ErrInvalidReturnPath
}
}
if !fromAddr.Send {
s.log.Errorf("Can't send emails on address: %v", fromAddr.Email)
return &ErrCanNotSendOnAddress{address: fromAddr.Email}
}
// Load the user's mail settings.
settings, err := s.client.GetMailSettings(ctx)
if err != nil {
@ -186,7 +191,7 @@ func (s *Service) sendWithKey(
if message.InReplyTo != "" {
references = append(references, message.InReplyTo)
}
parentID, err := getParentID(ctx, s.client, authAddrID, addrMode, references)
parentID, draftsToDelete, err := getParentID(ctx, s.client, authAddrID, addrMode, references)
if err != nil {
if err := s.reporter.ReportMessageWithContext("Failed to get parent ID", reporter.Context{
"error": err,
@ -212,7 +217,7 @@ func (s *Service) sendWithKey(
return proton.Message{}, fmt.Errorf("unsupported MIME type: %v", message.MIMEType)
}
draft, err := s.createDraft(ctx, addrKR, emails, from, to, parentID, message.InReplyTo, proton.DraftTemplate{
draft, err := s.createDraft(ctx, addrKR, emails, from, to, parentID, message.InReplyTo, message.XForward, proton.DraftTemplate{
Subject: message.Subject,
Body: decBody,
MIMEType: message.MIMEType,
@ -248,6 +253,13 @@ func (s *Service) sendWithKey(
return proton.Message{}, fmt.Errorf("failed to send draft: %w", err)
}
// Only delete the drafts, if any, after message was successfully sent.
if len(draftsToDelete) != 0 {
if err := s.client.DeleteMessage(ctx, draftsToDelete...); err != nil {
s.log.WithField("ids", draftsToDelete).WithError(err).Errorf("Failed to delete requested messages from Drafts")
}
}
return res, nil
}
@ -257,11 +269,12 @@ func getParentID(
authAddrID string,
addrMode usertypes.AddressMode,
references []string,
) (string, error) {
) (string, []string, error) {
var (
parentID string
internal []string
external []string
parentID string
internal []string
external []string
draftsToDelete []string
)
// Collect all the internal and external references of the message.
@ -286,14 +299,18 @@ func getParentID(
AddressID: addrID,
})
if err != nil {
return "", fmt.Errorf("failed to get message metadata: %w", err)
return "", nil, fmt.Errorf("failed to get message metadata: %w", err)
}
for _, metadata := range metadata {
if !metadata.IsDraft() {
parentID = metadata.ID
} else if err := client.DeleteMessage(ctx, metadata.ID); err != nil {
return "", fmt.Errorf("failed to delete message: %w", err)
} else {
// We need to record this ID to delete later after the message has been sent successfully. This is
// required for Apple Mail to correctly delete a draft when a draft is created in Apple Mail, then
// edited on the web, edited again in Apple Mail and then Send from Apple Mail. If we don't
// delete the referenced draft it is never deleted from the drafts folder.
draftsToDelete = append(draftsToDelete, metadata.ID)
}
}
}
@ -314,7 +331,7 @@ func getParentID(
AddressID: addrID,
})
if err != nil {
return "", fmt.Errorf("failed to get message metadata: %w", err)
return "", nil, fmt.Errorf("failed to get message metadata: %w", err)
}
switch len(metadata) {
@ -339,7 +356,7 @@ func getParentID(
}
}
return parentID, nil
return parentID, draftsToDelete, nil
}
func (s *Service) createDraft(
@ -350,6 +367,7 @@ func (s *Service) createDraft(
to []string,
parentID string,
replyToID string,
xForwardID string,
template proton.DraftTemplate,
) (proton.Message, error) {
// Check sender: set the sender if it's missing.
@ -385,7 +403,12 @@ func (s *Service) createDraft(
var action proton.CreateDraftAction
if len(replyToID) > 0 {
action = proton.ReplyAction
// Thunderbird fills both ReplyTo and adds an X-Forwarded-Message-Id header when forwarding.
if replyToID == xForwardID {
action = proton.ForwardAction
} else {
action = proton.ReplyAction
}
} else {
action = proton.ForwardAction
}

View File

@ -34,13 +34,14 @@ const (
)
type contactSettings struct {
Email string
Keys []string
Scheme string
Sign bool
SignIsSet bool
Encrypt bool
MIMEType rfc822.MIMEType
Email string
Keys []string
Scheme string
Sign bool
SignIsSet bool
Encrypt bool
EncryptUntrusted bool
MIMEType rfc822.MIMEType
}
// newContactSettings converts the API settings into our local settings.
@ -61,6 +62,12 @@ func newContactSettings(settings proton.ContactSettings) *contactSettings {
metadata.Encrypt = *settings.Encrypt
}
if settings.EncryptUntrusted != nil {
metadata.EncryptUntrusted = *settings.EncryptUntrusted
} else {
metadata.EncryptUntrusted = true
}
if settings.Scheme != nil {
switch *settings.Scheme { // nolint:exhaustive
case proton.PGPMIMEScheme:
@ -426,9 +433,12 @@ func (b *sendPrefsBuilder) setExternalPGPSettingsWithWKDKeys(
return errors.New("an API key is necessary but wasn't provided")
}
// We always encrypt and sign external mail if WKD keys are present.
b.withEncrypt(true)
b.withSign(true)
b.withEncrypt(vCardData.EncryptUntrusted)
if vCardData.EncryptUntrusted {
b.withSign(true)
} else if vCardData.SignIsSet {
b.withSign(vCardData.Sign)
}
// If the contact has a specific Scheme preference, we set it (otherwise we
// leave it unset to allow it to be filled in with the default value later).

View File

@ -110,7 +110,22 @@ func TestPreferencesBuilder(t *testing.T) {
{
name: "wkd-external",
contactMeta: &contactSettings{},
contactMeta: &contactSettings{EncryptUntrusted: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: true,
wantSign: proton.DetachedSignature,
wantScheme: proton.PGPMIMEScheme,
wantMIMEType: "multipart/mixed",
wantPublicKey: testPublicKey,
},
{
name: "wkd-external",
contactMeta: &contactSettings{EncryptUntrusted: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/html"},
@ -125,7 +140,7 @@ func TestPreferencesBuilder(t *testing.T) {
{
name: "wkd-external with contact-specific email format",
contactMeta: &contactSettings{MIMEType: "text/plain"},
contactMeta: &contactSettings{MIMEType: "text/plain", EncryptUntrusted: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/html"},
@ -140,7 +155,7 @@ func TestPreferencesBuilder(t *testing.T) {
{
name: "wkd-external with global pgp-inline scheme",
contactMeta: &contactSettings{},
contactMeta: &contactSettings{EncryptUntrusted: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPInlineScheme, DraftMIMEType: "text/html"},
@ -155,7 +170,7 @@ func TestPreferencesBuilder(t *testing.T) {
{
name: "wkd-external with contact-specific pgp-inline scheme overriding global pgp-mime setting",
contactMeta: &contactSettings{Scheme: pgpInline},
contactMeta: &contactSettings{Scheme: pgpInline, EncryptUntrusted: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/html"},
@ -170,7 +185,7 @@ func TestPreferencesBuilder(t *testing.T) {
{
name: "wkd-external with contact-specific pgp-mime scheme overriding global pgp-inline setting",
contactMeta: &contactSettings{Scheme: pgpMIME},
contactMeta: &contactSettings{Scheme: pgpMIME, EncryptUntrusted: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPInlineScheme, DraftMIMEType: "text/html"},
@ -185,7 +200,7 @@ func TestPreferencesBuilder(t *testing.T) {
{
name: "wkd-external with additional pinned contact public key",
contactMeta: &contactSettings{Keys: []string{testContactKey}},
contactMeta: &contactSettings{Keys: []string{testContactKey}, EncryptUntrusted: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/html"},
@ -201,7 +216,7 @@ func TestPreferencesBuilder(t *testing.T) {
// NOTE: Need to figured out how to test that this calls the frontend to check for user confirmation.
name: "wkd-external with additional conflicting contact public key",
contactMeta: &contactSettings{Keys: []string{testOtherContactKey}},
contactMeta: &contactSettings{Keys: []string{testOtherContactKey}, EncryptUntrusted: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/html"},
@ -213,6 +228,51 @@ func TestPreferencesBuilder(t *testing.T) {
wantPublicKey: testPublicKey,
},
{
name: "wkd-external-with-encrypt-and-sign-disabled",
contactMeta: &contactSettings{EncryptUntrusted: false},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: false,
wantSign: proton.NoSignature,
wantScheme: proton.ClearScheme,
wantMIMEType: "text/html",
wantPublicKey: testPublicKey,
},
{
name: "wkd-external-with-encrypt-and-sign-disabled-plain-text",
contactMeta: &contactSettings{EncryptUntrusted: false},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/plain"},
wantEncrypt: false,
wantSign: proton.NoSignature,
wantScheme: proton.ClearScheme,
wantMIMEType: "text/plain",
wantPublicKey: testPublicKey,
},
{
name: "wkd-external-with-encrypt-disabled-sign-enabled",
contactMeta: &contactSettings{EncryptUntrusted: false, Sign: true, SignIsSet: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: false,
wantSign: proton.DetachedSignature,
wantScheme: proton.ClearMIMEScheme,
wantMIMEType: "multipart/mixed",
wantPublicKey: testPublicKey,
},
{
name: "external",

View File

@ -58,6 +58,10 @@ func (s Status) IsComplete() bool {
return s.HasLabels && s.HasMessages
}
func (s Status) InProgress() bool {
return s.HasLabels || s.HasMessageCount
}
// Regulator is an abstraction for the sync service, since it regulates the number of concurrent sync activities.
type Regulator interface {
Sync(ctx context.Context, stage *Job)

View File

@ -36,6 +36,20 @@ func (m *MockHeartbeatManager) EXPECT() *MockHeartbeatManagerMockRecorder {
return m.recorder
}
// GetHeartbeatPeriodicInterval mocks base method.
func (m *MockHeartbeatManager) GetHeartbeatPeriodicInterval() time.Duration {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetHeartbeatPeriodicInterval")
ret0, _ := ret[0].(time.Duration)
return ret0
}
// GetHeartbeatPeriodicInterval indicates an expected call of GetHeartbeatPeriodicInterval.
func (mr *MockHeartbeatManagerMockRecorder) GetHeartbeatPeriodicInterval() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHeartbeatPeriodicInterval", reflect.TypeOf((*MockHeartbeatManager)(nil).GetHeartbeatPeriodicInterval))
}
// GetLastHeartbeatSent mocks base method.
func (m *MockHeartbeatManager) GetLastHeartbeatSent() time.Time {
m.ctrl.T.Helper()

View File

@ -42,6 +42,7 @@ type HeartbeatManager interface {
SendHeartbeat(ctx context.Context, heartbeat *HeartbeatData) bool
GetLastHeartbeatSent() time.Time
SetLastHeartbeatSent(time.Time) error
GetHeartbeatPeriodicInterval() time.Duration
}
type HeartbeatValues struct {

View File

@ -33,7 +33,7 @@ func migrateSyncStatusFromVault(encVault *vault.User, syncConfigDir string, user
}
if migrated {
if err := encVault.ClearSyncStatus(); err != nil {
if err := encVault.ClearSyncStatusWithoutEventID(); err != nil {
return fmt.Errorf("failed to clear sync settings from vault: %w", err)
}
}

View File

@ -105,6 +105,7 @@ func New(
eventSubscription events.Subscription,
syncService syncservice.Regulator,
syncConfigDir string,
isNew bool,
) (*User, error) {
user, err := newImpl(
ctx,
@ -122,6 +123,7 @@ func New(
eventSubscription,
syncService,
syncConfigDir,
isNew,
)
if err != nil {
// Cleanup any pending resources on error
@ -152,6 +154,7 @@ func newImpl(
eventSubscription events.Subscription,
syncService syncservice.Regulator,
syncConfigDir string,
isNew bool,
) (*User, error) {
logrus.WithField("userID", apiUser.ID).Info("Creating new user")
@ -295,6 +298,14 @@ func newImpl(
return nil
})
// If it's not a fresh user check the eventID and evaluate whether it is valid. If it's a new user, we don't
// need to perform this check.
if !isNew {
if err := checkIrrecoverableEventID(ctx, encVault.EventID(), apiUser.ID, syncConfigDir, user); err != nil {
return nil, err
}
}
// Start Event Service
lastEventID, err := user.eventService.Start(ctx, user.serviceGroup)
if err != nil {
@ -368,24 +379,28 @@ func (user *User) Match(query string) bool {
return false
}
// Emails returns all the user's active email addresses.
// It returns them in sorted order; the user's primary address is first.
func (user *User) Emails() []string {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Minute))
defer cancel()
apiAddrs, err := user.identityService.GetAddresses(ctx)
if err != nil {
// DisplayNames returns a map of the email addresses and their associated display names.
func (user *User) DisplayNames() map[string]string {
addresses := user.protonAddresses()
if addresses == nil {
return nil
}
addresses := xslices.Filter(maps.Values(apiAddrs), func(addr proton.Address) bool {
return addr.Status == proton.AddressStatusEnabled && addr.Type != proton.AddressTypeExternal
})
result := make(map[string]string)
for _, address := range addresses {
result[address.Email] = address.DisplayName
}
slices.SortFunc(addresses, func(a, b proton.Address) bool {
return a.Order < b.Order
})
return result
}
// Emails returns all the user's active email addresses.
// It returns them in sorted order; the user's primary address is first.
func (user *User) Emails() []string {
addresses := user.protonAddresses()
if addresses == nil {
return nil
}
return xslices.Map(addresses, func(addr proton.Address) string {
return addr.Email
@ -682,3 +697,23 @@ func (user *User) PauseEventLoopWithWaiter() *userevents.EventPollWaiter {
func (user *User) ResumeEventLoop() {
user.eventService.Resume()
}
func (user *User) protonAddresses() []proton.Address {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Minute))
defer cancel()
apiAddrs, err := user.identityService.GetAddresses(ctx)
if err != nil {
return nil
}
addresses := xslices.Filter(maps.Values(apiAddrs), func(addr proton.Address) bool {
return addr.Status == proton.AddressStatusEnabled && addr.Type != proton.AddressTypeExternal
})
slices.SortFunc(addresses, func(a, b proton.Address) bool {
return a.Order < b.Order
})
return addresses
}

View File

@ -0,0 +1,68 @@
// 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 user
import (
"context"
"fmt"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice"
)
func checkIrrecoverableEventID(
ctx context.Context,
lastEventID,
userID,
syncConfigDir string,
publisher events.EventPublisher,
) error {
// If we detect that the event ID stored in the vault got reset, the user is not a new account and
// we have started or finished syncing: this is an irrecoverable state and we should produce a bad event.
if lastEventID != "" {
return nil
}
syncConfigPath := imapservice.GetSyncConfigPath(syncConfigDir, userID)
syncState, err := imapservice.NewSyncState(syncConfigPath)
if err != nil {
return fmt.Errorf("failed to read imap sync state: %w", err)
}
syncStatus, err := syncState.GetSyncStatus(ctx)
if err != nil {
return fmt.Errorf("failed to imap sync status: %w", err)
}
if syncStatus.IsComplete() || syncStatus.InProgress() {
publisher.PublishEvent(ctx, newEmptyEventIDBadEvent(userID))
}
return nil
}
func newEmptyEventIDBadEvent(userID string) events.UserBadEvent {
return events.UserBadEvent{
UserID: userID,
OldEventID: "",
NewEventID: "",
EventInfo: "EventID missing from vault",
Error: fmt.Errorf("eventID in vault is empty, when it shouldn't be"),
}
}

View File

@ -0,0 +1,104 @@
// 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 user
import (
"context"
"testing"
"github.com/ProtonMail/proton-bridge/v3/internal/events/mocks"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
)
func TestCheckIrrecoverableEventID_EventIDIsEmptyButNoSyncStarted(t *testing.T) {
tmpDir := t.TempDir()
userID := "foo"
mockCtrl := gomock.NewController(t)
publisher := mocks.NewMockEventPublisher(mockCtrl)
require.NoError(t, checkIrrecoverableEventID(context.Background(), "", userID, tmpDir, publisher))
}
func TestCheckIrrecoverableEventID_EventIDIsNotEmptyButNoSyncStarted(t *testing.T) {
tmpDir := t.TempDir()
userID := "foo"
mockCtrl := gomock.NewController(t)
publisher := mocks.NewMockEventPublisher(mockCtrl)
require.NoError(t, checkIrrecoverableEventID(context.Background(), "ffoofo", userID, tmpDir, publisher))
}
func TestCheckIrrecoverableEventID_EventIDIsEmptyButSyncStarted(t *testing.T) {
tmpDir := t.TempDir()
userID := "foo"
mockCtrl := gomock.NewController(t)
publisher := mocks.NewMockEventPublisher(mockCtrl)
publisher.EXPECT().PublishEvent(gomock.Any(), gomock.Eq(newEmptyEventIDBadEvent(userID)))
require.NoError(t, genSyncState(context.Background(), userID, tmpDir, false))
require.NoError(t, checkIrrecoverableEventID(context.Background(), "", userID, tmpDir, publisher))
}
func TestCheckIrrecoverableEventID_EventIDIsEmptyButSyncFinished(t *testing.T) {
tmpDir := t.TempDir()
userID := "foo"
mockCtrl := gomock.NewController(t)
publisher := mocks.NewMockEventPublisher(mockCtrl)
publisher.EXPECT().PublishEvent(gomock.Any(), gomock.Eq(newEmptyEventIDBadEvent(userID)))
require.NoError(t, genSyncState(context.Background(), userID, tmpDir, true))
require.NoError(t, checkIrrecoverableEventID(context.Background(), "", userID, tmpDir, publisher))
}
func TestCheckIrrecoverableEventID_EventIDIsNotEmptyButSyncFinished(t *testing.T) {
tmpDir := t.TempDir()
userID := "foo"
mockCtrl := gomock.NewController(t)
publisher := mocks.NewMockEventPublisher(mockCtrl)
require.NoError(t, genSyncState(context.Background(), userID, tmpDir, true))
require.NoError(t, checkIrrecoverableEventID(context.Background(), "some event", userID, tmpDir, publisher))
}
func genSyncState(ctx context.Context, userID, dir string, finished bool) error {
s, err := imapservice.NewSyncState(imapservice.GetSyncConfigPath(dir, userID))
if err != nil {
return err
}
if finished {
if err := s.SetHasLabels(ctx, true); err != nil {
return err
}
if err := s.SetHasMessages(ctx, true); err != nil {
return err
}
if err := s.SetMessageCount(ctx, 10); err != nil {
return err
}
} else {
if err := s.SetHasLabels(ctx, true); err != nil {
return err
}
}
return nil
}

View File

@ -19,6 +19,7 @@ package user
import (
"context"
"reflect"
"testing"
"time"
@ -58,8 +59,12 @@ func TestUser_Info(t *testing.T) {
// User's name should be correct.
require.Equal(t, "username", user.Name())
// User's email should be correct.
// User's emails should be correct and their associated display names should be correct
require.ElementsMatch(t, []string{"username@" + s.GetDomain(), "alias@pm.me"}, user.Emails())
require.True(t, reflect.DeepEqual(map[string]string{
"username@" + s.GetDomain(): "username" + " (Display Name)",
"alias@pm.me": "alias@pm.me (Display Name)",
}, user.DisplayNames()))
// By default, user should be in combined mode.
require.Equal(t, vault.CombinedMode, user.GetAddressMode())
@ -98,12 +103,14 @@ func withAPI(_ testing.TB, ctx context.Context, fn func(context.Context, *server
func withAccount(tb testing.TB, s *server.Server, username, password string, aliases []string, fn func(string, []string)) { //nolint:unparam
userID, addrID, err := s.CreateUser(username, []byte(password))
require.NoError(tb, err)
require.NoError(tb, s.ChangeAddressDisplayName(userID, addrID, username+" (Display Name)"))
addrIDs := []string{addrID}
for _, email := range aliases {
addrID, err := s.CreateAddress(userID, email, []byte(password))
require.NoError(tb, err)
require.NoError(tb, s.ChangeAddressDisplayName(userID, addrID, email+" (Display Name)"))
addrIDs = append(addrIDs, addrID)
}
@ -158,6 +165,7 @@ func withUser(tb testing.TB, ctx context.Context, _ *server.Server, m *proton.Ma
nullEventSubscription,
nil,
"",
true,
)
require.NoError(tb, err)
defer user.Close()

View File

@ -186,13 +186,13 @@ func (user *User) RemFailedMessageID(messageID string) error {
})
}
// GetSyncStatus returns the user's sync status.
func (user *User) GetSyncStatus() SyncStatus {
// GetSyncStatusDeprecated returns the user's sync status.
func (user *User) GetSyncStatusDeprecated() SyncStatus {
return user.vault.getUser(user.userID).SyncStatus
}
// ClearSyncStatus clears the user's sync status.
func (user *User) ClearSyncStatus() error {
// ClearSyncStatusDeprecated clears the user's sync status.
func (user *User) ClearSyncStatusDeprecated() error {
return user.vault.modUser(user.userID, func(data *UserData) {
data.SyncStatus = SyncStatus{}
@ -200,6 +200,13 @@ func (user *User) ClearSyncStatus() error {
})
}
// ClearSyncStatusWithoutEventID clears the user's sync status without modifying EventID.
func (user *User) ClearSyncStatusWithoutEventID() error {
return user.vault.modUser(user.userID, func(data *UserData) {
data.SyncStatus = SyncStatus{}
})
}
// EventID returns the last processed event ID of the user.
func (user *User) EventID() string {
return user.vault.getUser(user.userID).EventID

View File

@ -137,7 +137,7 @@ func TestUser_SyncStatus(t *testing.T) {
require.True(t, user.SyncStatus().HasMessages)
// Clear the sync status.
require.NoError(t, user.ClearSyncStatus())
require.NoError(t, user.ClearSyncStatusDeprecated())
// Check the user's cleared sync status.
require.False(t, user.SyncStatus().HasLabels)
@ -145,6 +145,31 @@ func TestUser_SyncStatus(t *testing.T) {
require.Empty(t, user.SyncStatus().LastMessageID)
}
func TestUser_ClearSyncStatusWithoutEventID(t *testing.T) {
// Create a new test vault.
s := newVault(t)
// Create a new user.
user, err := s.AddUser("userID", "username", "username@pm.me", "authUID", "authRef", []byte("keyPass"))
require.NoError(t, err)
// Simulate finishing the sync.
require.NoError(t, user.SetHasLabels(true))
require.NoError(t, user.SetHasMessages(true))
require.True(t, user.SyncStatus().HasLabels)
require.True(t, user.SyncStatus().HasMessages)
require.NoError(t, user.SetEventID("foo"))
// Clear the sync status.
require.NoError(t, user.ClearSyncStatusWithoutEventID())
// Check the user's cleared sync status.
require.False(t, user.SyncStatus().HasLabels)
require.False(t, user.SyncStatus().HasMessages)
require.Empty(t, user.SyncStatus().LastMessageID)
require.Equal(t, "foo", user.EventID())
}
func TestUser_PrimaryEmail(t *testing.T) {
// Create a new test vault.
s := newVault(t)

View File

@ -31,14 +31,18 @@ const (
MacOSKeychain = "macos-keychain"
)
func init() { //nolint:gochecknoinits
Helpers = make(map[string]helperConstructor)
func listHelpers() (Helpers, string) {
helpers := make(Helpers)
// MacOS always provides a keychain.
Helpers[MacOSKeychain] = newMacOSHelper
if isUsable(newMacOSHelper("")) {
helpers[MacOSKeychain] = newMacOSHelper
} else {
logrus.WithField("keychain", "MacOSKeychain").Warn("Keychain is not available.")
}
// Use MacOSKeychain by default.
DefaultHelper = MacOSKeychain
return helpers, MacOSKeychain
}
func parseError(original error) error {

View File

@ -18,8 +18,6 @@
package keychain
import (
"reflect"
"github.com/docker/docker-credential-helpers/credentials"
"github.com/docker/docker-credential-helpers/pass"
"github.com/docker/docker-credential-helpers/secretservice"
@ -33,30 +31,37 @@ const (
SecretServiceDBus = "secret-service-dbus"
)
func init() { //nolint:gochecknoinits
Helpers = make(map[string]helperConstructor)
func listHelpers() (Helpers, string) {
helpers := make(Helpers)
if isUsable(newDBusHelper("")) {
Helpers[SecretServiceDBus] = newDBusHelper
helpers[SecretServiceDBus] = newDBusHelper
} else {
logrus.WithField("keychain", "SecretServiceDBus").Warn("Keychain is not available.")
}
if _, err := execabs.LookPath("gnome-keyring"); err == nil && isUsable(newSecretServiceHelper("")) {
Helpers[SecretService] = newSecretServiceHelper
helpers[SecretService] = newSecretServiceHelper
} else {
logrus.WithField("keychain", "SecretService").Warn("Keychain is not available.")
}
if _, err := execabs.LookPath("pass"); err == nil && isUsable(newPassHelper("")) {
Helpers[Pass] = newPassHelper
helpers[Pass] = newPassHelper
} else {
logrus.WithField("keychain", "Pass").Warn("Keychain is not available.")
}
DefaultHelper = SecretServiceDBus
defaultHelper := SecretServiceDBus
// If Pass is available, use it by default.
// Otherwise, if SecretService is available, use it by default.
if _, ok := Helpers[Pass]; ok {
DefaultHelper = Pass
} else if _, ok := Helpers[SecretService]; ok {
DefaultHelper = SecretService
if _, ok := helpers[Pass]; ok {
defaultHelper = Pass
} else if _, ok := helpers[SecretService]; ok {
defaultHelper = SecretService
}
return helpers, defaultHelper
}
func newDBusHelper(string) (credentials.Helper, error) {
@ -70,36 +75,3 @@ func newPassHelper(string) (credentials.Helper, error) {
func newSecretServiceHelper(string) (credentials.Helper, error) {
return &secretservice.Secretservice{}, nil
}
// isUsable returns whether the credentials helper is usable.
func isUsable(helper credentials.Helper, err error) bool {
l := logrus.WithField("helper", reflect.TypeOf(helper))
if err != nil {
l.WithError(err).Warn("Keychain helper couldn't be created")
return false
}
creds := &credentials.Credentials{
ServerURL: "bridge/check",
Username: "check",
Secret: "check",
}
if err := helper.Add(creds); err != nil {
l.WithError(err).Warn("Failed to add test credentials to keychain")
return false
}
if _, _, err := helper.Get(creds.ServerURL); err != nil {
l.WithError(err).Warn("Failed to get test credentials from keychain")
return false
}
if err := helper.Delete(creds.ServerURL); err != nil {
l.WithError(err).Warn("Failed to delete test credentials from keychain")
return false
}
return true
}

View File

@ -20,18 +20,21 @@ package keychain
import (
"github.com/docker/docker-credential-helpers/credentials"
"github.com/docker/docker-credential-helpers/wincred"
"github.com/sirupsen/logrus"
)
const WindowsCredentials = "windows-credentials"
func init() { //nolint:gochecknoinits
Helpers = make(map[string]helperConstructor)
func listHelpers() (Helpers, string) {
helpers := make(Helpers)
// Windows always provides a keychain.
Helpers[WindowsCredentials] = newWinCredHelper
if isUsable(newWinCredHelper("")) {
helpers[WindowsCredentials] = newWinCredHelper
} else {
logrus.WithField("keychain", "WindowsCredentials").Warn("Keychain is not available.")
}
// Use WindowsCredentials by default.
DefaultHelper = WindowsCredentials
return helpers, WindowsCredentials
}
func newWinCredHelper(string) (credentials.Helper, error) {

View File

@ -21,9 +21,12 @@ package keychain
import (
"errors"
"fmt"
"reflect"
"sync"
"time"
"github.com/docker/docker-credential-helpers/credentials"
"github.com/sirupsen/logrus"
)
// helperConstructor constructs a keychain helperConstructor.
@ -38,28 +41,53 @@ var (
// ErrMacKeychainRebuild is returned on macOS with blocked or corrupted keychain.
ErrMacKeychainRebuild = errors.New("keychain error -25293")
// Helpers holds all discovered keychain helpers. It is populated in init().
Helpers map[string]helperConstructor //nolint:gochecknoglobals
// DefaultHelper is the default helper to use if the user hasn't yet set a preference.
DefaultHelper string //nolint:gochecknoglobals
)
type Helpers map[string]helperConstructor
type List struct {
helpers Helpers
defaultHelper string
locker sync.Locker
}
// NewList checks availability of every keychains detected on the User Operating System
// This will ask the user to unlock keychain(s) to check their usability.
// This should only be called once.
func NewList() *List {
var list = List{locker: &sync.Mutex{}}
list.helpers, list.defaultHelper = listHelpers()
return &list
}
func (kcl *List) GetHelpers() Helpers {
kcl.locker.Lock()
defer kcl.locker.Unlock()
return kcl.helpers
}
func (kcl *List) GetDefaultHelper() string {
kcl.locker.Lock()
defer kcl.locker.Unlock()
return kcl.defaultHelper
}
// NewKeychain creates a new native keychain.
func NewKeychain(preferred, keychainName string) (*Keychain, error) {
func NewKeychain(preferred, keychainName string, helpers Helpers, defaultHelper string) (*Keychain, error) {
// There must be at least one keychain helper available.
if len(Helpers) < 1 {
if len(helpers) < 1 {
return nil, ErrNoKeychain
}
// If the preferred keychain is unsupported, fallback to the default one.
if _, ok := Helpers[preferred]; !ok {
preferred = DefaultHelper
if _, ok := helpers[preferred]; !ok {
preferred = defaultHelper
}
// Load the user's preferred keychain helper.
helperConstructor, ok := Helpers[preferred]
helperConstructor, ok := helpers[preferred]
if !ok {
return nil, ErrNoKeychain
}
@ -163,3 +191,49 @@ func (kc *Keychain) Put(userID, secret string) error {
func (kc *Keychain) secretURL(userID string) string {
return fmt.Sprintf("%v/%v", kc.url, userID)
}
// isUsable returns whether the credentials helper is usable.
func isUsable(helper credentials.Helper, err error) bool {
l := logrus.WithField("helper", reflect.TypeOf(helper))
if err != nil {
l.WithError(err).Warn("Keychain helper couldn't be created")
return false
}
creds := &credentials.Credentials{
ServerURL: "bridge/check",
Username: "check",
Secret: "check",
}
if err := retry(func() error {
return helper.Add(creds)
}); err != nil {
l.WithError(err).Warn("Failed to add test credentials to keychain")
return false
}
if _, _, err := helper.Get(creds.ServerURL); err != nil {
l.WithError(err).Warn("Failed to get test credentials from keychain")
return false
}
if err := helper.Delete(creds.ServerURL); err != nil {
l.WithError(err).Warn("Failed to delete test credentials from keychain")
return false
}
return true
}
func retry(condition func() error) error {
var maxRetry = 5
for r := 0; ; r++ {
err := condition()
if err == nil || r >= maxRetry {
return err
}
time.Sleep(200 * time.Millisecond)
}
}

View File

@ -17,10 +17,22 @@
package keychain
import "github.com/docker/docker-credential-helpers/credentials"
import (
"sync"
"github.com/docker/docker-credential-helpers/credentials"
)
type TestHelper map[string]*credentials.Credentials
func NewTestKeychainsList() *List {
keychainHelper := NewTestHelper()
helpers := make(Helpers)
helpers["mock"] = func(string) (credentials.Helper, error) { return keychainHelper, nil }
var list = List{helpers: helpers, defaultHelper: "mock", locker: &sync.Mutex{}}
return &list
}
func NewTestHelper() TestHelper {
return make(TestHelper)
}

View File

@ -60,6 +60,7 @@ type Message struct {
References []string
ExternalID string
InReplyTo string
XForward string
}
type Attachment struct {
@ -313,24 +314,14 @@ func collectBodyParts(p *parser.Parser, preferredContentType string) (parser.Par
return bestChoice(childParts, preferredContentType), nil
}).
RegisterRule("text/plain", func(p *parser.Part, visit parser.Visit) (interface{}, error) {
disp, _, err := p.ContentDisposition()
if err != nil {
disp = ""
}
if disp == "attachment" {
if p.IsAttachment() {
return parser.Parts{}, nil
}
return parser.Parts{p}, nil
}).
RegisterRule("text/html", func(p *parser.Part, visit parser.Visit) (interface{}, error) {
disp, _, err := p.ContentDisposition()
if err != nil {
disp = ""
}
if disp == "attachment" {
if p.IsAttachment() {
return parser.Parts{}, nil
}
@ -530,6 +521,9 @@ func parseMessageHeader(h message.Header, allowInvalidAddressLists bool) (Messag
case "in-reply-to":
m.InReplyTo = regexp.MustCompile("<(.*)>").ReplaceAllString(fields.Value(), "$1")
case "x-forwarded-message-id":
m.XForward = regexp.MustCompile("<(.*)>").ReplaceAllString(fields.Value(), "$1")
case "references":
for _, ref := range strings.Fields(fields.Value()) {
for _, ref := range strings.Split(ref, ",") {

View File

@ -33,7 +33,7 @@ func (h *handler) matchPart(p *Part) bool {
}
func (h *handler) matchPartSkipAttachment(p *Part) bool {
return !p.isAttachment() && h.matchPart(p)
return !p.IsAttachment() && h.matchPart(p)
}
func (h *handler) matchType(p *Part) bool {

View File

@ -41,6 +41,8 @@ type Part struct {
children Parts
}
const utf8Charset = "UTF-8"
func (p *Part) ContentType() (string, map[string]string, error) {
t, params, err := p.Header.ContentType()
if err != nil {
@ -116,7 +118,7 @@ func (p *Part) ConvertToUTF8() error {
params = make(map[string]string)
}
params["charset"] = "UTF-8"
params["charset"] = utf8Charset
p.Header.SetContentType(t, params)
@ -129,6 +131,8 @@ func (p *Part) ConvertMetaCharset() error {
return err
}
// Override charset to UTF-8 in meta headers only if needed.
var metaModified = false
goquery.NewDocumentFromNode(doc).Find("meta").Each(func(n int, sel *goquery.Selection) {
if val, ok := sel.Attr("content"); ok {
t, params, err := pmmime.ParseMediaType(val)
@ -136,24 +140,31 @@ func (p *Part) ConvertMetaCharset() error {
return
}
params["charset"] = "UTF-8"
if charset, ok := params["charset"]; ok && charset != utf8Charset {
params["charset"] = utf8Charset
}
sel.SetAttr("content", mime.FormatMediaType(t, params))
metaModified = true
}
if _, ok := sel.Attr("charset"); ok {
sel.SetAttr("charset", "UTF-8")
if charset, ok := sel.Attr("charset"); ok && charset != utf8Charset {
sel.SetAttr("charset", utf8Charset)
metaModified = true
}
})
buf := new(bytes.Buffer)
// Override the body part only if modification was applied
// as html.render will sanitise the html headers.
if metaModified {
buf := new(bytes.Buffer)
if err := html.Render(buf, doc); err != nil {
return err
if err := html.Render(buf, doc); err != nil {
return err
}
p.Body = buf.Bytes()
}
p.Body = buf.Bytes()
return nil
}
@ -209,7 +220,7 @@ func (p *Part) isMultipartMixedOrRelated() bool {
return t == "multipart/mixed" || t == "multipart/related"
}
func (p *Part) isAttachment() bool {
func (p *Part) IsAttachment() bool {
disp, _, err := p.ContentDisposition()
if err != nil {
disp = ""

View File

@ -431,7 +431,7 @@ func TestParseTextHTML(t *testing.T) {
assert.Equal(t, `"Sender" <sender@pm.me>`, m.Sender.String())
assert.Equal(t, `"Receiver" <receiver@pm.me>`, m.ToList[0].String())
assert.Equal(t, "<html><head></head><body>This is body of <b>HTML mail</b> without attachment</body></html>", string(m.RichBody))
assert.Equal(t, "<html><body>This is body of <b>HTML mail</b> without attachment</body></html>", string(m.RichBody))
assert.Equal(t, "This is body of *HTML mail* without attachment", string(m.PlainBody))
assert.Len(t, m.Attachments, 0)
@ -446,7 +446,7 @@ func TestParseTextHTMLAlready7Bit(t *testing.T) {
assert.Equal(t, `"Sender" <sender@pm.me>`, m.Sender.String())
assert.Equal(t, `"Receiver" <receiver@pm.me>`, m.ToList[0].String())
assert.Equal(t, "<html><head></head><body>This is body of <b>HTML mail</b> without attachment</body></html>", string(m.RichBody))
assert.Equal(t, "<html><body>This is body of <b>HTML mail</b> without attachment</body></html>", string(m.RichBody))
assert.Equal(t, "This is body of *HTML mail* without attachment", string(m.PlainBody))
assert.Len(t, m.Attachments, 0)
@ -461,7 +461,7 @@ func TestParseTextHTMLWithOctetAttachment(t *testing.T) {
assert.Equal(t, `"Sender" <sender@pm.me>`, m.Sender.String())
assert.Equal(t, `"Receiver" <receiver@pm.me>`, m.ToList[0].String())
assert.Equal(t, "<html><head></head><body>This is body of <b>HTML mail</b> with attachment</body></html>", string(m.RichBody))
assert.Equal(t, "<html><body>This is body of <b>HTML mail</b> with attachment</body></html>", string(m.RichBody))
assert.Equal(t, "This is body of *HTML mail* with attachment", string(m.PlainBody))
require.Len(t, m.Attachments, 1)
@ -478,7 +478,7 @@ func TestParseTextHTMLWithPlainAttachment(t *testing.T) {
assert.Equal(t, `"Receiver" <receiver@pm.me>`, m.ToList[0].String())
// BAD: plainBody should not be empty!
assert.Equal(t, "<html><head></head><body>This is body of <b>HTML mail</b> with attachment</body></html>", string(m.RichBody))
assert.Equal(t, "<html><body>This is body of <b>HTML mail</b> with attachment</body></html>", string(m.RichBody))
assert.Equal(t, "This is body of *HTML mail* with attachment", string(m.PlainBody))
require.Len(t, m.Attachments, 1)
@ -496,7 +496,7 @@ func TestParseTextHTMLWithImageInline(t *testing.T) {
require.Len(t, m.Attachments, 1)
assert.Equal(t, fmt.Sprintf(`<html><head></head><body>This is body of <b>HTML mail</b> with attachment</body></html><html><body><img src="cid:%v"/></body></html>`, m.Attachments[0].ContentID), string(m.RichBody))
assert.Equal(t, fmt.Sprintf(`<html><body>This is body of <b>HTML mail</b> with attachment</body></html><html><body><img src="cid:%v"/></body></html>`, m.Attachments[0].ContentID), string(m.RichBody))
assert.Equal(t, "This is body of *HTML mail* with attachment", string(m.PlainBody))
// The inline image is an 8x8 mic-dropping gopher.
@ -627,7 +627,7 @@ func TestParseWithTrailingEndOfMailIndicator(t *testing.T) {
assert.Equal(t, `"Sender" <sender@sender.com>`, m.Sender.String())
assert.Equal(t, `"Receiver" <receiver@receiver.com>`, m.ToList[0].String())
assert.Equal(t, "<!DOCTYPE html><html><head></head><body>boo!</body></html>", string(m.RichBody))
assert.Equal(t, "<!DOCTYPE HTML>\n<html><body>boo!</body></html>", string(m.RichBody))
assert.Equal(t, "boo!", string(m.PlainBody))
}
@ -786,6 +786,16 @@ func TestParseTextPlainWithDocxAttachmentCyrillic(t *testing.T) {
assert.Equal(t, "АБВГДЃЕЖЗЅИЈКЛЉМНЊОПРСТЌУФХЧЏЗШ.docx", m.Attachments[0].Name)
}
func TestParseInReplyToAndXForward(t *testing.T) {
f := getFileReader("text_plain_utf8_reply_to_and_x_forward.eml")
m, err := Parse(f)
require.NoError(t, err)
require.Equal(t, "00000@protonmail.com", m.XForward)
require.Equal(t, "00000@protonmail.com", m.InReplyTo)
}
func TestPatchNewLineWithHtmlBreaks(t *testing.T) {
{
input := []byte("\nfoo\nbar\n\n\nzz\nddd")

View File

@ -0,0 +1,7 @@
From: Sender <sender@pm.me>
To: Receiver <receiver@pm.me>
Content-Type: text/plain; charset=utf-8
X-Forwarded-Message-Id: <00000@protonmail.com>
In-Reply-To: <00000@protonmail.com>
body

View File

@ -255,11 +255,20 @@ func DecodeCharset(original []byte, contentType string) ([]byte, error) {
}
// ParseMediaType from MIME doesn't support RFC2231 for non asci / utf8 encodings so we have to pre-parse it.
func ParseMediaType(v string) (mediatype string, params map[string]string, err error) {
func ParseMediaType(v string) (string, map[string]string, error) {
if v == "" {
return "", nil, errors.New("empty media type")
}
decoded, err := DecodeHeader(v)
if err != nil {
logrus.WithField("value", v).WithError(err).Error("Media Type parsing error.")
return "", nil, err
}
v, _ = changeEncodingAndKeepLastParamDefinition(decoded)
return mime.ParseMediaType(v)
mediatype, params, err := mime.ParseMediaType(v)
if err != nil {
logrus.WithField("value", v).WithError(err).Error("Media Type parsing error.")
return "", nil, err
}
return mediatype, params, err
}

View File

@ -24,6 +24,7 @@ import (
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"time"
@ -177,6 +178,18 @@ func newTestBugReport(br *bridge.Bridge) *testBugReport {
}
func (r *testBugReport) report() error {
if r.request.IncludeLogs == true {
data := []byte("Test log file.\n")
logName := "20231031_122940334_bri_000_v3.6.1+qa_br-178.log"
logPath, err := r.bridge.GetLogsPath()
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(logPath, logName), data, 0o600); err != nil {
return err
}
}
return r.bridge.ReportBug(context.Background(), &r.request)
}
@ -202,7 +215,7 @@ func (s *scenario) theUserReportsABugWithSingleHeaderChange(key, value string) e
bugReport.request.Email = value
case "Client":
bugReport.request.EmailClient = value
case "Attachment":
case "IncludeLogs":
att, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("failed to parse bug report attachment preferences: %w", err)

View File

@ -39,6 +39,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/service"
"github.com/ProtonMail/proton-bridge/v3/internal/useragent"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
@ -153,6 +154,7 @@ func (t *testCtx) initBridge() (<-chan events.Event, error) {
t.mocks.Autostarter,
t.mocks.Updater,
t.version,
keychain.NewTestKeychainsList(),
// API stuff
t.api.GetHostURL(),
@ -164,6 +166,7 @@ func (t *testCtx) initBridge() (<-chan events.Event, error) {
t.mocks.CrashHandler,
t.reporter,
imap.DefaultEpochUIDValidityGenerator(),
t.heartbeat,
// Logging stuff
logIMAP,
@ -177,8 +180,6 @@ func (t *testCtx) initBridge() (<-chan events.Event, error) {
t.bridge = bridge
t.heartbeat.setBridge(bridge)
bridge.StartHeartbeat(t.heartbeat)
return t.events.collectFrom(eventCh), nil
}

View File

@ -85,6 +85,10 @@ func (hb *heartbeatRecorder) SetLastHeartbeatSent(timestamp time.Time) error {
return hb.bridge.SetLastHeartbeatSent(timestamp)
}
func (hb *heartbeatRecorder) GetHeartbeatPeriodicInterval() time.Duration {
return 200 * time.Millisecond
}
func (hb *heartbeatRecorder) rejectSend() {
hb.reject = true
}

View File

@ -91,44 +91,71 @@ func (s *scenario) theHeaderInTheRequestToHasSetTo(method, path, key, value stri
}
func (s *scenario) theHeaderInTheMultipartRequestToHasSetTo(method, path, key, value string) error {
req, err := s.getLastCallMultipartForm(method, path)
if err != nil {
return fmt.Errorf("failed to parse multipart form: %w", err)
}
if haveValue := req.FormValue(key); haveValue != value {
return fmt.Errorf("header field %q have %q, want %q", key, haveValue, value)
}
return nil
}
func (s *scenario) checkParsedMultipartFormForFile(method, path, file string, hasFile bool) error {
req, err := s.getLastCallMultipartForm(method, path)
if err != nil {
return fmt.Errorf("failed to parse multipart form: %w", err)
}
if _, ok := req.MultipartForm.File[file]; hasFile != ok {
return fmt.Errorf("Multipart file in bug report is %t, want it to be %t", ok, hasFile)
}
return nil
}
func (s *scenario) theHeaderInTheMultipartRequestToHasFile(method, path, file string) error {
return s.checkParsedMultipartFormForFile(method, path, file, true)
}
func (s *scenario) theHeaderInTheMultipartRequestToHasNoFile(method, path, file string) error {
return s.checkParsedMultipartFormForFile(method, path, file, false)
}
func (s *scenario) getLastCallMultipartForm(method, path string) (*http.Request, error) {
// We have to exclude HTTP-Overrides to avoid race condition with the creating and sending of the draft message.
call, err := s.t.getLastCallExcludingHTTPOverride(method, path)
if err != nil {
return err
return nil, err
}
buf := new(bytes.Buffer)
if _, err := buf.WriteString(fmt.Sprintf("%s %s HTTP/1.1\r\n", call.Method, call.URL.Path)); err != nil {
return fmt.Errorf("failed to write request line: %w", err)
return nil, fmt.Errorf("failed to write request line: %w", err)
}
if err := call.RequestHeader.Write(buf); err != nil {
return fmt.Errorf("failed to write header: %w", err)
return nil, fmt.Errorf("failed to write header: %w", err)
}
if _, err := buf.WriteString("\r\n"); err != nil {
return fmt.Errorf("failed to write header: %w", err)
return nil, fmt.Errorf("failed to write header: %w", err)
}
if _, err := buf.Write(call.RequestBody); err != nil {
return fmt.Errorf("failed to write body: %w", err)
return nil, fmt.Errorf("failed to write body: %w", err)
}
req, err := http.ReadRequest(bufio.NewReader(buf))
if err != nil {
return fmt.Errorf("failed to read request: %w", err)
return nil, fmt.Errorf("failed to read request: %w", err)
}
if err := req.ParseMultipartForm(1 << 10); err != nil {
return fmt.Errorf("failed to parse multipart form: %w", err)
return nil, fmt.Errorf("failed to parse multipart form: %w", err)
}
if haveValue := req.FormValue(key); haveValue != value {
return fmt.Errorf("header field %q have %q, want %q", key, haveValue, value)
}
return nil
return req, nil
}
func (s *scenario) theBodyInTheRequestToIs(method, path string, value *godog.DocString) error {

View File

@ -0,0 +1,32 @@
Feature: IMAP marks messages as forwarded
Background:
Given there exists an account with username "[user:user]" and password "password"
And the account "[user:user]" has the following custom mailboxes:
| name | type |
| mbox | folder |
And the address "[user:user]@[domain]" of account "[user:user]" has 1 messages in "Folders/mbox"
Then it succeeds
When bridge starts
And the user logs in with username "[user:user]" and password "password"
And user "[user:user]" finishes syncing
And user "[user:user]" connects and authenticates IMAP client "1"
Then it succeeds
Scenario: Mark message as forwarded
When IMAP client "1" selects "Folders/mbox"
And IMAP client "1" marks message 1 as "forwarded"
And it succeeds
Then IMAP client "1" eventually sees that message at row 1 has the flag "forwarded"
And it succeeds
@ignore-live
Scenario: Mark message as forwarded and then revert
When IMAP client "1" selects "Folders/mbox"
And IMAP client "1" marks message 1 as "forwarded"
And it succeeds
Then IMAP client "1" eventually sees that message at row 1 has the flag "forwarded"
And it succeeds
And IMAP client "1" marks message 1 as "unforwarded"
And it succeeds
Then IMAP client "1" eventually sees that message at row 1 does not have the flag "forwarded"
And it succeeds

View File

@ -480,170 +480,3 @@ Feature: SMTP sending of plain messages
}
}
"""
Scenario: Forward a message containing various attachments
When SMTP client "1" sends the following message from "[user:user]@[domain]" to "[user:user2]@[domain]":
"""
Content-Type: multipart/mixed; boundary="------------MQ01Z9UM8OaR9z39TvzDfdIq"
Subject: Fwd: Reply to this message, it has various attachments.
References: <something@protonmail.ch>
To: <[user:user2]@[domain]>
From: <[user:user]@[domain]>
In-Reply-To: <something@protonmail.ch>
X-Forwarded-Message-Id: <something@protonmail.ch>
This is a multi-part message in MIME format.
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: text/plain; charset=UTF-8; format=flowed
Content-Transfer-Encoding: 7bit
Forwarding a message with various attachments in it!
-------- Forwarded Message --------
Subject: Reply to this message, it has various attachments.
Date: Thu, 26 Oct 2023 10:41:55 +0000
From: Gjorgji Testing <gorgitesting@protonmail.com>
Reply-To: Gjorgji Testing <gorgitesting@protonmail.com>
To: Gjorgji Test v3 <gorgitesting3@protonmail.com>
For real!
*Gjorgji Testing
TesASID <https://www.youtube.com/watch?v=MifXUbrjYr8>
*
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: text/html; charset=UTF-8; name="index.html"
Content-Disposition: attachment; filename="index.html"
Content-Transfer-Encoding: base64
IDwhRE9DVFlQRSBodG1sPg0KPGh0bWw+DQo8aGVhZD4NCjx0aXRsZT5QYWdlIFRpdGxlPC90
aXRsZT4NCjwvaGVhZD4NCjxib2R5Pg0KDQo8aDE+TXkgRmlyc3QgSGVhZGluZzwvaDE+DQo8
cD5NeSBmaXJzdCBwYXJhZ3JhcGguPC9wPg0KDQo8L2JvZHk+DQo8L2h0bWw+IA==
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: text/xml; charset=UTF-8; name="testxml.xml"
Content-Disposition: attachment; filename="testxml.xml"
Content-Transfer-Encoding: base64
PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCFET0NUWVBFIHN1aXRl
IFNZU1RFTSAiaHR0cDovL3Rlc3RuZy5vcmcvdGVzdG5nLTEuMC5kdGQiID4KCjxzdWl0ZSBu
YW1lPSJBZmZpbGlhdGUgTmV0d29ya3MiPgoKICAgIDx0ZXN0IG5hbWU9IkFmZmlsaWF0ZSBO
ZXR3b3JrcyIgZW5hYmxlZD0idHJ1ZSI+CiAgICAgICAgPGNsYXNzZXM+CiAgICAgICAgICAg
IDxjbGFzcyBuYW1lPSJjb20uY2xpY2tvdXQuYXBpdGVzdGluZy5hZmZOZXR3b3Jrcy5Bd2lu
VUtUZXN0Ii8+CiAgICAgICAgPC9jbGFzc2VzPgogICAgPC90ZXN0PgoKPC9zdWl0ZT4=
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: application/pdf; name="test.pdf"
Content-Disposition: attachment; filename="test.pdf"
Content-Transfer-Encoding: base64
JVBERi0xLjUKJeLjz9MKNyAwIG9iago8PAovVHlwZSAvRm9udERlc2NyaXB0b3IKL0ZvbnRO
MjM0NAolJUVPRgo=
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;
name="test.xlsx"
Content-Disposition: attachment; filename="test.xlsx"
Content-Transfer-Encoding: base64
UEsDBBQABgAIAAAAIQBi7p1oXgEAAJAEAAATAAgCW0NvbnRlbnRfVHlwZXNdLnhtbCCiBAIo
UQIAABEAAAAAAAAAAAAAAAAARBcAAGRvY1Byb3BzL2NvcmUueG1sUEsBAi0AFAAGAAgAAAAh
AGFJCRCJAQAAEQMAABAAAAAAAAAAAAAAAAAAvBkAAGRvY1Byb3BzL2FwcC54bWxQSwUGAAAA
AAoACgCAAgAAexwAAAAA
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document;
name="test.docx"
Content-Disposition: attachment; filename="test.docx"
Content-Transfer-Encoding: base64
UEsDBBQABgAIAAAAIQDfpNJsWgEAACAFAAATAAgCW0NvbnRlbnRfVHlwZXNdLnhtbCCiBAIo
cHAueG1sUEsBAi0AFAAGAAgAAAAhABA0tG9uAQAA4QIAABEAAAAAAAAAAAAAAAAA2xsAAGRv
Y1Byb3BzL2NvcmUueG1sUEsBAi0AFAAGAAgAAAAhAJ/mlBIqCwAAU3AAAA8AAAAAAAAAAAAA
AAAAgB4AAHdvcmQvc3R5bGVzLnhtbFBLBQYAAAAACwALAMECAADXKQAAAAA=
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: text/plain; charset=UTF-8; name="text file.txt"
Content-Disposition: attachment; filename="text file.txt"
Content-Transfer-Encoding: base64
dGV4dCBmaWxl
--------------MQ01Z9UM8OaR9z39TvzDfdIq--
"""
Then it succeeds
When user "[user:user]" connects and authenticates IMAP client "1"
Then IMAP client "1" eventually sees the following messages in "Sent":
| from | to | subject | X-Forwarded-Message-Id |
| [user:user]@[domain] | [user:user2]@[domain] | Fwd: Reply to this message, it has various attachments. | something@protonmail.ch |
And IMAP client "1" eventually sees 1 messages in "Sent"
When the user logs in with username "[user:user2]" and password "password"
And user "[user:user2]" connects and authenticates IMAP client "2"
And user "[user:user2]" finishes syncing
And it succeeds
Then IMAP client "2" eventually sees the following messages in "Inbox":
| from | to | subject | X-Forwarded-Message-Id |
| [user:user]@[domain] | [user:user2]@[domain] | Fwd: Reply to this message, it has various attachments. | something@protonmail.ch |
Then IMAP client "2" eventually sees the following message in "Inbox" with this structure:
"""
{
"from": "[user:user]@[domain]",
"to": "[user:user2]@[domain]",
"subject": "Fwd: Reply to this message, it has various attachments.",
"content": {
"content-type": "multipart/mixed",
"sections":[
{
"content-type": "text/plain",
"content-type-charset": "utf-8",
"transfer-encoding": "quoted-printable",
"body-is": "Forwarding a message with various attachments in it!\r\n\r\n\r\n\r\n-------- Forwarded Message --------\r\nSubject: \tReply to this message, it has various attachments.\r\nDate: \tThu, 26 Oct 2023 10:41:55 +0000\r\nFrom: \tGjorgji Testing <gorgitesting@protonmail.com>\r\nReply-To: \tGjorgji Testing <gorgitesting@protonmail.com>\r\nTo: \tGjorgji Test v3 <gorgitesting3@protonmail.com>\r\n\r\n\r\n\r\n\r\nFor real!\r\n\r\n*Gjorgji Testing\r\nTesASID <https://www.youtube.com/watch?v=3DMifXUbrjYr8>\r\n*"
},
{
"content-type": "text/html",
"content-type-name": "index.html",
"content-disposition": "attachment",
"content-disposition-filename": "index.html",
"transfer-encoding": "base64"
},
{
"content-type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"content-type-name": "test.docx",
"content-disposition": "attachment",
"content-disposition-filename": "test.docx",
"transfer-encoding": "base64"
},
{
"content-type": "application/pdf",
"content-type-name": "test.pdf",
"content-disposition": "attachment",
"content-disposition-filename": "test.pdf",
"transfer-encoding": "base64"
},
{
"content-type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"content-type-name": "test.xlsx",
"content-disposition": "attachment",
"content-disposition-filename": "test.xlsx",
"transfer-encoding": "base64"
},
{
"content-type": "text/xml",
"content-type-name": "testxml.xml",
"content-disposition": "attachment",
"content-disposition-filename": "testxml.xml",
"transfer-encoding": "base64"
},
{
"content-type": "text/plain",
"content-type-name": "text file.txt",
"content-disposition": "attachment",
"content-disposition-filename": "text file.txt",
"transfer-encoding": "base64",
"body-is": "dGV4dCBmaWxl"
}
]
}
}
"""

View File

@ -241,6 +241,7 @@ Feature: SMTP sending of HTMl messages to Internal recipient
{
"content-type": "application/pgp-keys",
"content-disposition": "attachment",
"transfer-encoding": "base64"
}
]
@ -763,7 +764,7 @@ Feature: SMTP sending of HTMl messages to Internal recipient
"content-disposition": "attachment",
"content-disposition-filename": "index.html",
"transfer-encoding": "base64",
"body-is": "PCFET0NUWVBFIGh0bWw+PGh0bWw+PGhlYWQ+Cjx0aXRsZT5QYWdlIFRpdGxlPC90aXRsZT4KPC9o\r\nZWFkPgo8Ym9keT4KCjxoMT5NeSBGaXJzdCBIZWFkaW5nPC9oMT4KPHA+TXkgZmlyc3QgcGFyYWdy\r\nYXBoLjwvcD4KCgogPC9ib2R5PjwvaHRtbD4="
"body-is": "IDwhRE9DVFlQRSBodG1sPg0KPGh0bWw+DQo8aGVhZD4NCjx0aXRsZT5QYWdlIFRpdGxlPC90aXRs\r\nZT4NCjwvaGVhZD4NCjxib2R5Pg0KDQo8aDE+TXkgRmlyc3QgSGVhZGluZzwvaDE+DQo8cD5NeSBm\r\naXJzdCBwYXJhZ3JhcGguPC9wPg0KDQo8L2JvZHk+DQo8L2h0bWw+IA=="
},
{
"content-type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
@ -1070,7 +1071,6 @@ Feature: SMTP sending of HTMl messages to Internal recipient
}
"""
Scenario: Replying to a message after enabling attach public key
When SMTP client "1" sends the following message from "[user:user]@[domain]" to "[user:to]@[domain]":
"""

File diff suppressed because one or more lines are too long

View File

@ -277,4 +277,113 @@ Feature: SMTP send reply
And IMAP client "1" eventually sees the following messages in "INBOX":
| from | subject | in-reply-to | references |
| [user:user2]@[domain] | FW - Please Reply | <something@external.com> | <something@external.com> |
| [user:user2]@[domain] | FW - Please Reply Again | <something@external.com> | <something@external.com> |
| [user:user2]@[domain] | FW - Please Reply Again | <something@external.com> | <something@external.com> |
@long-black
Scenario: Reply with In-Reply-To and X-Forwarded-Message-Id sets forwarded flag
# User1 send the initial message.
When SMTP client "1" sends the following message from "[user:user1]@[domain]" to "[user:user2]@[domain]":
"""
From: Bridge Test <[user:user1]@[domain]>
To: Internal Bridge <[user:user2]@[domain]>
Subject: Please Reply
Message-ID: <something@external.com>
hello
"""
Then it succeeds
Then IMAP client "1" eventually sees the following messages in "Sent":
| from | to | subject | message-id |
| [user:user1]@[domain] | [user:user2]@[domain] | Please Reply | <something@external.com> |
# login user2.
And the user logs in with username "[user:user2]" and password "password"
And user "[user:user2]" connects and authenticates IMAP client "2"
And user "[user:user2]" connects and authenticates SMTP client "2"
And user "[user:user2]" finishes syncing
# User2 receive the message.
Then IMAP client "2" eventually sees the following messages in "INBOX":
| from | subject | message-id | reply-to |
| [user:user1]@[domain] | Please Reply | <something@external.com> | [user:user1]@[domain] |
# User2 reply to it.
When SMTP client "2" sends the following message from "[user:user2]@[domain]" to "[user:user1]@[domain]":
"""
From: Internal Bridge <[user:user2]@[domain]>
To: Bridge Test <[user:user1]@[domain]>
Content-Type: text/plain
Subject: FW - Please Reply
In-Reply-To: <something@external.com>
Message-ID: <something@external.com>
X-Forwarded-Message-Id: <something@external.com>
Heya
"""
Then it succeeds
Then IMAP client "2" eventually sees the following messages in "Sent":
| from | to | subject | in-reply-to | references |
| [user:user2]@[domain] | [user:user1]@[domain] | FW - Please Reply | <something@external.com> | <something@external.com> |
When IMAP client "2" selects "INBOX"
And it succeeds
Then IMAP client "2" eventually sees that message at row 1 has the flag "forwarded"
And it succeeds
Then IMAP client "2" eventually sees that message at row 1 does not have the flag "\Answered"
And it succeeds
# User1 receive the reply.|
And IMAP client "1" eventually sees the following messages in "INBOX":
| from | subject | in-reply-to | references |
| [user:user2]@[domain] | FW - Please Reply | <something@external.com> | <something@external.com> |
@long-black
Scenario: Reply with In-Reply-To sets answered flag
# User1 send the initial message.
When SMTP client "1" sends the following message from "[user:user1]@[domain]" to "[user:user2]@[domain]":
"""
From: Bridge Test <[user:user1]@[domain]>
To: Internal Bridge <[user:user2]@[domain]>
Subject: Please Reply
Message-ID: <something@external.com>
hello
"""
Then it succeeds
Then IMAP client "1" eventually sees the following messages in "Sent":
| from | to | subject | message-id |
| [user:user1]@[domain] | [user:user2]@[domain] | Please Reply | <something@external.com> |
# login user2.
And the user logs in with username "[user:user2]" and password "password"
And user "[user:user2]" connects and authenticates IMAP client "2"
And user "[user:user2]" connects and authenticates SMTP client "2"
And user "[user:user2]" finishes syncing
# User2 receive the message.
Then IMAP client "2" eventually sees the following messages in "INBOX":
| from | subject | message-id | reply-to |
| [user:user1]@[domain] | Please Reply | <something@external.com> | [user:user1]@[domain] |
# User2 reply to it.
When SMTP client "2" sends the following message from "[user:user2]@[domain]" to "[user:user1]@[domain]":
"""
From: Internal Bridge <[user:user2]@[domain]>
To: Bridge Test <[user:user1]@[domain]>
Content-Type: text/plain
Subject: FW - Please Reply
In-Reply-To: <something@external.com>
Message-ID: <something@external.com>
Heya
"""
Then it succeeds
Then IMAP client "2" eventually sees the following messages in "Sent":
| from | to | subject | in-reply-to | references |
| [user:user2]@[domain] | [user:user1]@[domain] | FW - Please Reply | <something@external.com> | <something@external.com> |
When IMAP client "2" selects "INBOX"
And it succeeds
Then IMAP client "2" eventually sees that message at row 1 has the flag "\Answered"
And it succeeds
Then IMAP client "2" eventually sees that message at row 1 does not have the flag "forwarded"
And it succeeds
# User1 receive the reply.|
And IMAP client "1" eventually sees the following messages in "INBOX":
| from | subject | in-reply-to | references |
| [user:user2]@[domain] | FW - Please Reply | <something@external.com> | <something@external.com> |

View File

@ -12,8 +12,17 @@ Feature: The user reports a problem
Then the header in the "POST" multipart request to "/core/v4/reports/bug" has "Title" set to "[Bridge] Bug - title"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Description" set to "description"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Username" set to "[user:user]"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Attachment" set to ""
And the header in the "POST" multipart request to "/core/v4/reports/bug" has no file "logs.zip"
Scenario: User sends a problem report with logs attached
When the user reports a bug with field "IncludeLogs" set to "true"
Then it succeeds
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Title" set to "[Bridge] Bug - title"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Description" set to "description"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Username" set to "[user:user]"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has file "logs.zip"
@regression
Scenario: User sends a problem report while signed out of Bridge
When user "[user:user]" logs out
@ -41,7 +50,8 @@ Feature: The user reports a problem
"Description": "Testing Description",
"Username": "[user:user]",
"Email": "[user:user]@[domain]",
"EmailClient": "Apple Mail"
"EmailClient": "Apple Mail",
"IncludeLogs": true
}
"""
Then the header in the "POST" multipart request to "/core/v4/reports/bug" has "Title" set to "[Bridge] Bug - Testing Title"
@ -51,3 +61,4 @@ Feature: The user reports a problem
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Username" set to "[user:user]"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Email" set to "[user:user]@[domain]"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Client" set to "Apple Mail"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has file "logs.zip"

View File

@ -468,10 +468,12 @@ func (s *scenario) imapClientMarksAllMessagesAsState(clientID, messageState stri
return nil
}
func (s *scenario) imapClientSeesThatMessageHasTheFlag(clientID string, seq int, flag string) error {
_, client := s.t.getIMAPClient(clientID)
func (s *scenario) imapClientEventuallySeesThatMessageHasTheFlag(clientID string, seq int, flag string) error {
return eventually(func() error {
_, client := s.t.getIMAPClient(clientID)
return clientIsFlagApplied(client, seq, flag, true, false)
return clientIsFlagApplied(client, seq, flag, true, false)
})
}
func (s *scenario) imapClientSeesThatMessageDoesNotHaveTheFlag(clientID string, seq int, flag string) error {
@ -480,38 +482,46 @@ func (s *scenario) imapClientSeesThatMessageDoesNotHaveTheFlag(clientID string,
return clientIsFlagApplied(client, seq, flag, false, false)
}
func (s *scenario) imapClientSeesThatTheMessageWithSubjectHasTheFlag(clientID, subject, flag string) error {
_, client := s.t.getIMAPClient(clientID)
func (s *scenario) imapClientEventuallySeesThatTheMessageWithSubjectHasTheFlag(clientID, subject, flag string) error {
return eventually(func() error {
_, client := s.t.getIMAPClient(clientID)
uid, err := clientGetUIDBySubject(client, client.Mailbox().Name, subject)
if err != nil {
return err
}
uid, err := clientGetUIDBySubject(client, client.Mailbox().Name, subject)
if err != nil {
return err
}
return clientIsFlagApplied(client, int(uid), flag, true, false)
return clientIsFlagApplied(client, int(uid), flag, true, false)
})
}
func (s *scenario) imapClientSeesThatTheMessageWithSubjectDoesNotHaveTheFlag(clientID, subject, flag string) error {
_, client := s.t.getIMAPClient(clientID)
func (s *scenario) imapClientEventuallySeesThatTheMessageWithSubjectDoesNotHaveTheFlag(clientID, subject, flag string) error {
return eventually(func() error {
_, client := s.t.getIMAPClient(clientID)
uid, err := clientGetUIDBySubject(client, client.Mailbox().Name, subject)
if err != nil {
return err
}
uid, err := clientGetUIDBySubject(client, client.Mailbox().Name, subject)
if err != nil {
return err
}
return clientIsFlagApplied(client, int(uid), flag, false, false)
return clientIsFlagApplied(client, int(uid), flag, false, false)
})
}
func (s *scenario) imapClientSeesThatAllTheMessagesHaveTheFlag(clientID string, flag string) error {
_, client := s.t.getIMAPClient(clientID)
func (s *scenario) imapClientEventuallySeesThatAllTheMessagesHaveTheFlag(clientID string, flag string) error {
return eventually(func() error {
_, client := s.t.getIMAPClient(clientID)
return clientIsFlagApplied(client, 1, flag, true, true)
return clientIsFlagApplied(client, 1, flag, true, true)
})
}
func (s *scenario) imapClientSeesThatAllTheMessagesDoNotHaveTheFlag(clientID string, flag string) error {
_, client := s.t.getIMAPClient(clientID)
func (s *scenario) imapClientEventuallySeesThatAllTheMessagesDoNotHaveTheFlag(clientID string, flag string) error {
return eventually(func() error {
_, client := s.t.getIMAPClient(clientID)
return clientIsFlagApplied(client, 1, flag, false, true)
return clientIsFlagApplied(client, 1, flag, false, true)
})
}
func (s *scenario) imapClientExpunges(clientID string) error {
@ -916,6 +926,18 @@ func clientChangeMessageState(client *client.Client, seq int, messageState strin
if err != nil {
return err
}
case messageState == "forwarded":
_, err := clientStore(client, seq, seq, isUID, imap.FormatFlagsOp(imap.AddFlags, true), "Forwarded")
if err != nil {
return err
}
case messageState == "unforwarded":
_, err := clientStore(client, seq, seq, isUID, imap.FormatFlagsOp(imap.RemoveFlags, true), "Forwarded")
if err != nil {
return err
}
}
return nil

View File

@ -29,6 +29,8 @@ func (s *scenario) steps(ctx *godog.ScenarioContext) {
ctx.Step(`^the user agent is "([^"]*)"$`, s.theUserAgentIs)
ctx.Step(`^the header in the "([^"]*)" request to "([^"]*)" has "([^"]*)" set to "([^"]*)"$`, s.theHeaderInTheRequestToHasSetTo)
ctx.Step(`^the header in the "([^"]*)" multipart request to "([^"]*)" has "([^"]*)" set to "([^"]*)"$`, s.theHeaderInTheMultipartRequestToHasSetTo)
ctx.Step(`^the header in the "([^"]*)" multipart request to "([^"]*)" has file "([^"]*)"$`, s.theHeaderInTheMultipartRequestToHasFile)
ctx.Step(`^the header in the "([^"]*)" multipart request to "([^"]*)" has no file "([^"]*)"$`, s.theHeaderInTheMultipartRequestToHasNoFile)
ctx.Step(`^the body in the "([^"]*)" request to "([^"]*)" is:$`, s.theBodyInTheRequestToIs)
ctx.Step(`^the body in the "([^"]*)" response to "([^"]*)" is:$`, s.theBodyInTheResponseToIs)
ctx.Step(`^the API requires bridge version at least "([^"]*)"$`, s.theAPIRequiresBridgeVersion)
@ -154,12 +156,12 @@ func (s *scenario) steps(ctx *godog.ScenarioContext) {
ctx.Step(`^IMAP client "([^"]*)" marks message (\d+) as "([^"]*)"$`, s.imapClientMarksMessageAsState)
ctx.Step(`^IMAP client "([^"]*)" marks the message with subject "([^"]*)" as "([^"]*)"$`, s.imapClientMarksTheMessageWithSubjectAsState)
ctx.Step(`^IMAP client "([^"]*)" marks all messages as "([^"]*)"$`, s.imapClientMarksAllMessagesAsState)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that message at row (\d+) has the flag "([^"]*)"$`, s.imapClientSeesThatMessageHasTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that message at row (\d+) has the flag "([^"]*)"$`, s.imapClientEventuallySeesThatMessageHasTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that message at row (\d+) does not have the flag "([^"]*)"$`, s.imapClientSeesThatMessageDoesNotHaveTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that the message with subject "([^"]*)" has the flag "([^"]*)"`, s.imapClientSeesThatTheMessageWithSubjectHasTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that the message with subject "([^"]*)" does not have the flag "([^"]*)"`, s.imapClientSeesThatTheMessageWithSubjectDoesNotHaveTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that all the messages have the flag "([^"]*)"`, s.imapClientSeesThatAllTheMessagesHaveTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that all the messages do not have the flag "([^"]*)"`, s.imapClientSeesThatAllTheMessagesDoNotHaveTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that the message with subject "([^"]*)" has the flag "([^"]*)"`, s.imapClientEventuallySeesThatTheMessageWithSubjectHasTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that the message with subject "([^"]*)" does not have the flag "([^"]*)"`, s.imapClientEventuallySeesThatTheMessageWithSubjectDoesNotHaveTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that all the messages have the flag "([^"]*)"`, s.imapClientEventuallySeesThatAllTheMessagesHaveTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that all the messages do not have the flag "([^"]*)"`, s.imapClientEventuallySeesThatAllTheMessagesDoNotHaveTheFlag)
ctx.Step(`^IMAP client "([^"]*)" appends the following message to "([^"]*)":$`, s.imapClientAppendsTheFollowingMessageToMailbox)
ctx.Step(`^IMAP client "([^"]*)" appends the following messages to "([^"]*)":$`, s.imapClientAppendsTheFollowingMessagesToMailbox)
ctx.Step(`^IMAP client "([^"]*)" appends "([^"]*)" to "([^"]*)"$`, s.imapClientAppendsToMailbox)

View File

@ -28,6 +28,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/app"
"github.com/ProtonMail/proton-bridge/v3/internal/locations"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/urfave/cli/v2"
)
@ -50,27 +51,31 @@ func main() {
func readAction(c *cli.Context) error {
return app.WithLocations(func(locations *locations.Locations) error {
return app.WithVault(locations, async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error {
if _, err := os.Stdout.Write(vault.ExportJSON()); err != nil {
return fmt.Errorf("failed to write vault: %w", err)
}
return app.WithKeychainList(func(keychains *keychain.List) error {
return app.WithVault(locations, keychains, async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error {
if _, err := os.Stdout.Write(vault.ExportJSON()); err != nil {
return fmt.Errorf("failed to write vault: %w", err)
}
return nil
return nil
})
})
})
}
func writeAction(c *cli.Context) error {
return app.WithLocations(func(locations *locations.Locations) error {
return app.WithVault(locations, async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error {
b, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read vault: %w", err)
}
return app.WithKeychainList(func(keychains *keychain.List) error {
return app.WithVault(locations, keychains, async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error {
b, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read vault: %w", err)
}
vault.ImportJSON(b)
vault.ImportJSON(b)
return nil
return nil
})
})
})
}