mirror of
https://github.com/ProtonMail/proton-bridge.git
synced 2025-12-10 04:36:43 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3210709810 | |||
| 8fd988d7c5 | |||
| bf89d548d3 | |||
| 51229cbb68 | |||
| 36c5c37dac | |||
| 5a434fafbc | |||
| ea1c2534df | |||
| 1cafbfcaaa | |||
| 2d44ccaee0 | |||
| 96517b7fb1 | |||
| bc381407a7 | |||
| ddc5e775b9 | |||
| ea26188dc0 | |||
| 159e1cee7d | |||
| 4394ad0e9b | |||
| 856bdd1321 | |||
| ff288145df | |||
| 83bbdbd63e | |||
| fa430ee0fb | |||
| 0303ba38e8 | |||
| 2a78b5c144 | |||
| a00b3cdb92 | |||
| 8d3e04679f | |||
| 21ff7b4b97 | |||
| 4ea161f7ad | |||
| dc584ea29b | |||
| 4a01c46aed | |||
| e8d9534b9c | |||
| 96904b160f | |||
| b535be72f8 |
@ -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 -->
|
||||
|
||||
34
Changelog.md
34
Changelog.md
@ -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
|
||||
|
||||
2
Makefile
2
Makefile
@ -11,7 +11,7 @@ ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
|
||||
.PHONY: build build-gui build-nogui build-launcher versioner hasher
|
||||
|
||||
# Keep version hardcoded so app build works also without Git repository.
|
||||
BRIDGE_APP_VERSION?=3.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
5
go.mod
@ -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
12
go.sum
@ -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=
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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{})
|
||||
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
24
internal/bridge/keychain.go
Normal file
24
internal/bridge/keychain.go
Normal 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())
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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.
|
||||
}
|
||||
|
||||
@ -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?
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -68,6 +68,10 @@ func BuildFlagSetFromMessageMetadata(message proton.MessageMetadata) imap.FlagSe
|
||||
flags.AddToSelf(imap.FlagAnswered)
|
||||
}
|
||||
|
||||
if message.IsForwarded {
|
||||
flags.AddToSelf(imap.ForwardFlagList...)
|
||||
}
|
||||
|
||||
return flags
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
46
internal/services/smtp/accounts_test.go
Normal file
46
internal/services/smtp/accounts_test.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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",
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
68
internal/user/user_check.go
Normal file
68
internal/user/user_check.go
Normal 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"),
|
||||
}
|
||||
}
|
||||
104
internal/user/user_check_test.go
Normal file
104
internal/user/user_check_test.go
Normal 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
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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, ",") {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 = ""
|
||||
|
||||
@ -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")
|
||||
|
||||
7
pkg/message/testdata/text_plain_utf8_reply_to_and_x_forward.eml
vendored
Normal file
7
pkg/message/testdata/text_plain_utf8_reply_to_and_x_forward.eml
vendored
Normal 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
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
32
tests/features/imap/message/store.feature
Normal file
32
tests/features/imap/message/store.feature
Normal 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
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
@ -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]":
|
||||
"""
|
||||
|
||||
1290
tests/features/smtp/send/plain_to_internal.feature
Normal file
1290
tests/features/smtp/send/plain_to_internal.feature
Normal file
File diff suppressed because one or more lines are too long
@ -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> |
|
||||
@ -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"
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user