Compare commits

...

14 Commits

Author SHA1 Message Date
e9ea976773 chore: Kanmon Bridge 3.21.1 changelog. 2025-06-11 16:15:53 +02:00
a00af3a398 feat(BRIDGE-383): Internal mailbox conflict resolution extended; Minor alterations to mailbox conflict pre-checker 2025-06-11 16:11:20 +02:00
50ab740b92 chore: Kanmon Bridge 3.21.0 changelog. 2025-06-05 15:45:27 +02:00
39f2362996 feat(BRIDGE-379): mailbox pre-checker on startup & conflict resolver for bridge internal mailboxes; TODO potentially add this for system mailboxes as well 2025-06-05 14:34:29 +02:00
d2742c81e5 feat(BRIDGE-376): catch gluon errors related to label uniqueness constrainst... 2025-06-05 14:34:29 +02:00
9cb914cf13 fix(BRIDGE-377): Correct label field usage on label update handler 2025-06-05 14:34:29 +02:00
4088cf18c3 feat(BRIDGE-373): extend label conflict resolver logging & report sync errors to sentry 2025-06-05 14:34:29 +02:00
c02bae5eb2 fix(BRIDGE-378): Fix incorrect field usage for system mailbox names 2025-06-03 17:36:58 +02:00
2aa8acfb5b chore: changes to reconcile release/jubilee with dev 2025-05-28 16:56:28 +02:00
8109b384c5 fix(BRIDGE-362): added label conflict reconciliation logic 2025-05-28 16:56:07 +02:00
6d79ad3e41 chore: Jubilee Bridge 3.20.1 changelog. 2025-05-28 16:53:23 +02:00
5d93ee0cfc chore: Jubilee Bridge 3.20.0 changelog. 2025-05-28 16:53:23 +02:00
c3e2201945 feat(BRIDGE-366): Kill switch support for IMAP IDLE 2025-05-28 09:53:45 +02:00
89da7335b6 feat(BRIDGE-363): Observability metrics for IMAP connections; minor unleash service refactor; 2025-05-16 15:28:53 +02:00
49 changed files with 2275 additions and 96 deletions

View File

@ -127,6 +127,7 @@ Proton Mail Bridge includes the following 3rd party software:
* [blackfriday](https://github.com/russross/blackfriday/v2) available under [license](https://github.com/russross/blackfriday/v2/blob/master/LICENSE) * [blackfriday](https://github.com/russross/blackfriday/v2) available under [license](https://github.com/russross/blackfriday/v2/blob/master/LICENSE)
* [pflag](https://github.com/spf13/pflag) available under [license](https://github.com/spf13/pflag/blob/master/LICENSE) * [pflag](https://github.com/spf13/pflag) available under [license](https://github.com/spf13/pflag/blob/master/LICENSE)
* [bom](https://github.com/ssor/bom) available under [license](https://github.com/ssor/bom/blob/master/LICENSE) * [bom](https://github.com/ssor/bom) available under [license](https://github.com/ssor/bom/blob/master/LICENSE)
* [objx](https://github.com/stretchr/objx) available under [license](https://github.com/stretchr/objx/blob/master/LICENSE)
* [golang-asm](https://github.com/twitchyliquid64/golang-asm) available under [license](https://github.com/twitchyliquid64/golang-asm/blob/master/LICENSE) * [golang-asm](https://github.com/twitchyliquid64/golang-asm) available under [license](https://github.com/twitchyliquid64/golang-asm/blob/master/LICENSE)
* [codec](https://github.com/ugorji/go/codec) available under [license](https://github.com/ugorji/go/codec/blob/master/LICENSE) * [codec](https://github.com/ugorji/go/codec) available under [license](https://github.com/ugorji/go/codec/blob/master/LICENSE)
* [tagparser](https://github.com/vmihailenco/tagparser/v2) available under [license](https://github.com/vmihailenco/tagparser/v2/blob/master/LICENSE) * [tagparser](https://github.com/vmihailenco/tagparser/v2) available under [license](https://github.com/vmihailenco/tagparser/v2/blob/master/LICENSE)

View File

@ -3,6 +3,55 @@
Changelog [format](http://keepachangelog.com/en/1.0.0/) Changelog [format](http://keepachangelog.com/en/1.0.0/)
## Kanmon Bridge 3.21.1
### Changed
* BRIDGE-383: Extended internal mailbox conflict resolution logic and minor changes to the mailbox conflict pre-checker.
## Kanmon Bridge 3.21.0
### Added
* BRIDGE-379: Mailbox pre-check on Bridge startup & conflict resolver for Bridge internal mailboxes.
### Changed
* BRIDGE-376: Explicitly catch Gluon DB mailbox name conflicts and report them to Sentry.
* BRIDGE-373: Extend user mailbox conflict resolver logging & report sync errors to Sentry.
* BRIDGE-366: Kill switch support for IMAP IDLE.
* BRIDGE-363: Observability metric support for IMAP connections.
### Fixed
* BRIDGE-377: Correct API label field usage on user label conflict resolver - update handler (event loop).
* BRIDGE-378: Fix incorrect field usage for system mailbox names.
## Jubilee Bridge 3.20.1
### Fixed
* BRIDGE-362: Implemented logic for reconciling label conflicts.
## Jubilee Bridge 3.20.0
### Added
* BRIDGE-348: Enable display of BYOE addresses in Bridge.
* BRIDGE-340: Added additional logging for label operations and related bad events.
* BRIDGE-324: Log a hash of the vault key on Bridge start.
### Changed
* BRIDGE-352: Chore: bump go to 1.24.2.
* BRIDGE-353: Chore: update x/net package to 0.38.0.
### Fixed
* BRIDGE-351: Allow draft creation and import to BYOE addresses in combined mode.
* BRIDGE-301: Prevent imports into non-BYOE external addresses.
* BRIDGE-341: Replaced go-autostart with a fork to support creating autostart shortcuts in directories with Unicode characters on Windows.
* BRIDGE-332: Strip newline characters from username and password fields in the Bridge GUI.
* BRIDGE-336: Ensure all remote labels are verified and created in Gluon at Bridge startup.
* BRIDGE-335: Persist the last successfully used keychain helper as a user preference on Linux.
* BRIDGE-333: Ignore unknown label IDs during Bridge synchronization.
## Infinity Bridge 3.19.0 ## Infinity Bridge 3.19.0
### Changed ### Changed

View File

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

3
go.mod
View File

@ -7,7 +7,7 @@ toolchain go1.24.2
require ( require (
github.com/0xAX/notificator v0.0.0-20220220101646-ee9b8921e557 github.com/0xAX/notificator v0.0.0-20220220101646-ee9b8921e557
github.com/Masterminds/semver/v3 v3.2.0 github.com/Masterminds/semver/v3 v3.2.0
github.com/ProtonMail/gluon v0.17.1-0.20250324123053-2abce471ad71 github.com/ProtonMail/gluon v0.17.1-0.20250611120816-05167d499f8d
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/ProtonMail/go-proton-api v0.4.1-0.20250417134000-e624a080f7ba github.com/ProtonMail/go-proton-api v0.4.1-0.20250417134000-e624a080f7ba
github.com/ProtonMail/gopenpgp/v2 v2.8.2-proton github.com/ProtonMail/gopenpgp/v2 v2.8.2-proton
@ -114,6 +114,7 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect

22
go.sum
View File

@ -36,8 +36,8 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE
github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I= github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug= github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo= github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo=
github.com/ProtonMail/gluon v0.17.1-0.20250324123053-2abce471ad71 h1:UC8SLrS6QbBeOUM8FJugyNoeV5gRGoQCwNePAMxuM20= github.com/ProtonMail/gluon v0.17.1-0.20250611120816-05167d499f8d h1:45W7G+X0w7nzLzeB0eiFkGho5DTK1jNmmNbt3IhN524=
github.com/ProtonMail/gluon v0.17.1-0.20250324123053-2abce471ad71/go.mod h1:0/c03TzZPNiSgY5UDJK1iRDkjlDPwWugxTT6et2qDu8= github.com/ProtonMail/gluon v0.17.1-0.20250611120816-05167d499f8d/go.mod h1:0/c03TzZPNiSgY5UDJK1iRDkjlDPwWugxTT6et2qDu8=
github.com/ProtonMail/go-crypto v0.0.0-20230321155629-9a39f2531310/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE= github.com/ProtonMail/go-crypto v0.0.0-20230321155629-9a39f2531310/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
github.com/ProtonMail/go-crypto v1.1.4-proton h1:KIo9uNlk3vzlwI7o5VjhiEjI4Ld1TDixOMnoNZyfpFE= github.com/ProtonMail/go-crypto v1.1.4-proton h1:KIo9uNlk3vzlwI7o5VjhiEjI4Ld1TDixOMnoNZyfpFE=
github.com/ProtonMail/go-crypto v1.1.4-proton/go.mod h1:zNoyBJW3p/yVWiHNZgfTF9VsjwqYof5YY0M9kt2QaX0= github.com/ProtonMail/go-crypto v1.1.4-proton/go.mod h1:zNoyBJW3p/yVWiHNZgfTF9VsjwqYof5YY0M9kt2QaX0=
@ -45,14 +45,6 @@ github.com/ProtonMail/go-message v0.13.1-0.20240919135104-3bc88e6a9423 h1:p8nBDx
github.com/ProtonMail/go-message v0.13.1-0.20240919135104-3bc88e6a9423/go.mod h1:NBAn21zgCJ/52WLDyed18YvYFm5tEoeDauubFqLokM4= github.com/ProtonMail/go-message v0.13.1-0.20240919135104-3bc88e6a9423/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 h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= 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.20250217140732-2e531f21de4c h1:dxnbB+ov77BDj1LC35fKZ14hLoTpU6OTpZySwxarVx0=
github.com/ProtonMail/go-proton-api v0.4.1-0.20250217140732-2e531f21de4c/go.mod h1:RYgagBFkA3zFrSt7/vviFFwjZxBo6pGzcTwFsLwsnyc=
github.com/ProtonMail/go-proton-api v0.4.1-0.20250409092940-13ddc20a05a1 h1:u3G9UB8prOnzOneOf0JFCIVnMRLiK4QgEpPQVu9Y8Q4=
github.com/ProtonMail/go-proton-api v0.4.1-0.20250409092940-13ddc20a05a1/go.mod h1:RYgagBFkA3zFrSt7/vviFFwjZxBo6pGzcTwFsLwsnyc=
github.com/ProtonMail/go-proton-api v0.4.1-0.20250409131808-0bbc8e7c32db h1:mOtbY5BB2eNr2QmbZhFn5EnsJcimTntPB6akN2r+AuE=
github.com/ProtonMail/go-proton-api v0.4.1-0.20250409131808-0bbc8e7c32db/go.mod h1:RYgagBFkA3zFrSt7/vviFFwjZxBo6pGzcTwFsLwsnyc=
github.com/ProtonMail/go-proton-api v0.4.1-0.20250410050801-92de6e7c8517 h1:70JoDgXxfil4hbDoYGF98rMd47Rld6wXWyFAw4uFOTY=
github.com/ProtonMail/go-proton-api v0.4.1-0.20250410050801-92de6e7c8517/go.mod h1:RYgagBFkA3zFrSt7/vviFFwjZxBo6pGzcTwFsLwsnyc=
github.com/ProtonMail/go-proton-api v0.4.1-0.20250417134000-e624a080f7ba h1:DFBngZ7u/f69flRFzPp6Ipo6PKEyflJlA5OCh52yDB4= github.com/ProtonMail/go-proton-api v0.4.1-0.20250417134000-e624a080f7ba h1:DFBngZ7u/f69flRFzPp6Ipo6PKEyflJlA5OCh52yDB4=
github.com/ProtonMail/go-proton-api v0.4.1-0.20250417134000-e624a080f7ba/go.mod h1:eXIoLyIHxvPo8Kd9e1ygYIrAwbeWJhLi3vgSz2crlK4= github.com/ProtonMail/go-proton-api v0.4.1-0.20250417134000-e624a080f7ba/go.mod h1:eXIoLyIHxvPo8Kd9e1ygYIrAwbeWJhLi3vgSz2crlK4=
github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865 h1:EP1gnxLL5Z7xBSymE9nSTM27nRYINuvssAtDmG0suD8= github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865 h1:EP1gnxLL5Z7xBSymE9nSTM27nRYINuvssAtDmG0suD8=
@ -506,8 +498,6 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -565,8 +555,6 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@ -583,8 +571,6 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -625,8 +611,6 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@ -648,8 +632,6 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=

View File

@ -335,6 +335,7 @@ func newBridge(
uidValidityGenerator, uidValidityGenerator,
&bridgeIMAPSMTPTelemetry{b: bridge}, &bridgeIMAPSMTPTelemetry{b: bridge},
observabilityService, observabilityService,
unleashService,
) )
// Check whether username has changed and correct (macOS only) // Check whether username has changed and correct (macOS only)
@ -746,7 +747,7 @@ func (bridge *Bridge) PushObservabilityMetric(metric proton.ObservabilityMetric)
bridge.observabilityService.AddMetrics(metric) bridge.observabilityService.AddMetrics(metric)
} }
func (bridge *Bridge) PushDistinctObservabilityMetrics(errType observability.DistinctionErrorTypeEnum, metrics ...proton.ObservabilityMetric) { func (bridge *Bridge) PushDistinctObservabilityMetrics(errType observability.DistinctionMetricTypeEnum, metrics ...proton.ObservabilityMetric) {
bridge.observabilityService.AddDistinctMetrics(errType, metrics...) bridge.observabilityService.AddDistinctMetrics(errType, metrics...)
} }

View File

@ -54,6 +54,9 @@ func NewMocks(tb testing.TB, version, minAuto *semver.Version) *Mocks {
mocks.Heartbeat.EXPECT().IsTelemetryAvailable(gomock.Any()).AnyTimes() mocks.Heartbeat.EXPECT().IsTelemetryAvailable(gomock.Any()).AnyTimes()
mocks.Heartbeat.EXPECT().GetHeartbeatPeriodicInterval().AnyTimes().Return(500 * time.Millisecond) mocks.Heartbeat.EXPECT().GetHeartbeatPeriodicInterval().AnyTimes().Return(500 * time.Millisecond)
// It's called whenever a context is cancelled during sync. We should ought to remove this and make it more granular in the future.
mocks.Reporter.EXPECT().ReportMessageWithContext("Failed to sync, will retry later", gomock.Any()).AnyTimes()
return mocks return mocks
} }

View File

@ -88,3 +88,18 @@ func (mr *MockReporterMockRecorder) ReportMessageWithContext(arg0, arg1 interfac
mr.mock.ctrl.T.Helper() mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReportMessageWithContext", reflect.TypeOf((*MockReporter)(nil).ReportMessageWithContext), arg0, arg1) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReportMessageWithContext", reflect.TypeOf((*MockReporter)(nil).ReportMessageWithContext), arg0, arg1)
} }
// ReportWarningWithContext mocks base method.
func (m *MockReporter) ReportWarningWithContext(arg0 string, arg1 map[string]interface{}) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ReportWarningWithContext", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// ReportWarningWithContext indicates an expected call of ReportWarningWithContext.
func (mr *MockReporterMockRecorder) ReportWarningWithContext(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReportWarningWithContext", reflect.TypeOf((*MockReporter)(nil).ReportMessageWithContext), arg0, arg1)
}

View File

@ -25,7 +25,7 @@ func NewMockObservabilitySender(ctrl *gomock.Controller) *MockObservabilitySende
func (m *MockObservabilitySender) EXPECT() *MockObservabilitySenderRecorder { return m.recorder } func (m *MockObservabilitySender) EXPECT() *MockObservabilitySenderRecorder { return m.recorder }
func (m *MockObservabilitySender) AddDistinctMetrics(errType observability.DistinctionErrorTypeEnum, _ ...proton.ObservabilityMetric) { func (m *MockObservabilitySender) AddDistinctMetrics(errType observability.DistinctionMetricTypeEnum, _ ...proton.ObservabilityMetric) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
m.ctrl.Call(m, "AddDistinctMetrics", errType) m.ctrl.Call(m, "AddDistinctMetrics", errType)
} }
@ -35,7 +35,18 @@ func (m *MockObservabilitySender) AddMetrics(metrics ...proton.ObservabilityMetr
m.ctrl.Call(m, "AddMetrics", metrics) m.ctrl.Call(m, "AddMetrics", metrics)
} }
func (mr *MockObservabilitySenderRecorder) AddDistinctMetrics(errType observability.DistinctionErrorTypeEnum, _ ...proton.ObservabilityMetric) *gomock.Call { func (m *MockObservabilitySender) AddTimeLimitedMetric(metricType observability.DistinctionMetricTypeEnum, metric proton.ObservabilityMetric) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "AddTimeLimitedMetric", metricType, metric)
}
func (m *MockObservabilitySender) GetEmailClient() string {
m.ctrl.T.Helper()
m.ctrl.Call(m, "GetEmailClient")
return ""
}
func (mr *MockObservabilitySenderRecorder) AddDistinctMetrics(errType observability.DistinctionMetricTypeEnum, _ ...proton.ObservabilityMetric) *gomock.Call {
mr.mock.ctrl.T.Helper() mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock,
"AddDistinctMetrics", "AddDistinctMetrics",
@ -47,3 +58,13 @@ func (mr *MockObservabilitySenderRecorder) AddMetrics(metrics ...proton.Observab
mr.mock.ctrl.T.Helper() mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddMetrics", reflect.TypeOf((*MockObservabilitySender)(nil).AddMetrics), metrics) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddMetrics", reflect.TypeOf((*MockObservabilitySender)(nil).AddMetrics), metrics)
} }
func (mr *MockObservabilitySenderRecorder) AddTimeLimitedMetric(metricType observability.DistinctionMetricTypeEnum, metric proton.ObservabilityMetric) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddTimeLimitedMetric", reflect.TypeOf((*MockObservabilitySender)(nil).AddTimeLimitedMetric), metricType, metric)
}
func (mr *MockObservabilitySenderRecorder) GetEmailClient() {
mr.mock.ctrl.T.Helper()
mr.mock.ctrl.Call(mr.mock, "GetEmailClient", reflect.TypeOf((*MockObservabilitySender)(nil).GetEmailClient))
}

View File

@ -551,7 +551,7 @@ func (bridge *Bridge) addUserWithVault(
syncSettingsPath, syncSettingsPath,
isNew, isNew,
bridge.notificationStore, bridge.notificationStore,
bridge.unleashService.GetFlagValue, bridge.unleashService,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to create user: %w", err) return fmt.Errorf("failed to create user: %w", err)

View File

@ -23,6 +23,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/mail" "net/mail"
"runtime"
"strings" "strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
@ -76,6 +77,9 @@ func TestBridge_User_RefreshEvent(t *testing.T) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
syncCh, closeCh := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{})) syncCh, closeCh := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
if runtime.GOOS != "windows" {
require.Equal(t, userID, (<-syncCh).UserID)
}
require.Equal(t, userID, (<-syncCh).UserID) require.Equal(t, userID, (<-syncCh).UserID)
closeCh() closeCh()

View File

@ -96,7 +96,7 @@ func TestTLSSignedCertTrustedPublicKey(t *testing.T) {
_, dialer, _, checker, _ := createClientWithPinningDialer("") _, dialer, _, checker, _ := createClientWithPinningDialer("")
copyTrustedPins(checker) copyTrustedPins(checker)
checker.trustedPins = append(checker.trustedPins, `pin-sha256="hgraU1+uoS6kjiJaH5G+BiqQoyiIml1Nat+2FiUAcII="`) checker.trustedPins = append(checker.trustedPins, `pin-sha256="FlvTPG/nIMKtOj9nelnEjujwSZ5EDyfiKYxZgbXREls="`)
_, err := dialer.DialTLSContext(context.Background(), "tcp", "rsa4096.badssl.com:443") _, err := dialer.DialTLSContext(context.Background(), "tcp", "rsa4096.badssl.com:443")
r.NoError(t, err, "expected dial to succeed because public key is known and cert is signed by CA") r.NoError(t, err, "expected dial to succeed because public key is known and cert is signed by CA")
} }

View File

@ -157,7 +157,7 @@ func (r *Reporter) ReportExceptionWithContext(i interface{}, context map[string]
SkipDuringUnwind() SkipDuringUnwind()
err := fmt.Errorf("recover: %v", i) err := fmt.Errorf("recover: %v", i)
return r.scopedReport(context, func() { return r.scopedReport(context, func(_ *sentry.Scope) {
SkipDuringUnwind() SkipDuringUnwind()
if eventID := sentry.CaptureException(err); eventID != nil { if eventID := sentry.CaptureException(err); eventID != nil {
logrus.WithError(err). logrus.WithError(err).
@ -169,7 +169,20 @@ func (r *Reporter) ReportExceptionWithContext(i interface{}, context map[string]
func (r *Reporter) ReportMessageWithContext(msg string, context map[string]interface{}) error { func (r *Reporter) ReportMessageWithContext(msg string, context map[string]interface{}) error {
SkipDuringUnwind() SkipDuringUnwind()
return r.scopedReport(context, func() { return r.scopedReport(context, func(_ *sentry.Scope) {
SkipDuringUnwind()
if eventID := sentry.CaptureMessage(msg); eventID != nil {
logrus.WithField("message", msg).
WithField("reportID", *eventID).
Warn("Captured message")
}
})
}
func (r *Reporter) ReportWarningWithContext(msg string, context map[string]interface{}) error {
SkipDuringUnwind()
return r.scopedReport(context, func(scope *sentry.Scope) {
scope.SetLevel(sentry.LevelWarning)
SkipDuringUnwind() SkipDuringUnwind()
if eventID := sentry.CaptureMessage(msg); eventID != nil { if eventID := sentry.CaptureMessage(msg); eventID != nil {
logrus.WithField("message", msg). logrus.WithField("message", msg).
@ -180,7 +193,7 @@ func (r *Reporter) ReportMessageWithContext(msg string, context map[string]inter
} }
// Report reports a sentry crash with stacktrace from all goroutines. // Report reports a sentry crash with stacktrace from all goroutines.
func (r *Reporter) scopedReport(context map[string]interface{}, doReport func()) error { func (r *Reporter) scopedReport(context map[string]interface{}, doReport func(scope *sentry.Scope)) error {
SkipDuringUnwind() SkipDuringUnwind()
if os.Getenv("PROTONMAIL_ENV") == "dev" { if os.Getenv("PROTONMAIL_ENV") == "dev" {
@ -206,7 +219,7 @@ func (r *Reporter) scopedReport(context map[string]interface{}, doReport func())
map[string]sentry.Context{"bridge": contextToString(context)}, map[string]sentry.Context{"bridge": contextToString(context)},
) )
} }
doReport() doReport(scope)
}) })
if !sentry.Flush(time.Second * 10) { if !sentry.Flush(time.Second * 10) {
@ -287,3 +300,25 @@ func contextToString(context sentry.Context) sentry.Context {
return res return res
} }
type NullSentryReporter struct{}
func (n NullSentryReporter) ReportException(any) error {
return nil
}
func (n NullSentryReporter) ReportMessage(string) error {
return nil
}
func (n NullSentryReporter) ReportMessageWithContext(string, reporter.Context) error {
return nil
}
func (n NullSentryReporter) ReportWarningWithContext(string, reporter.Context) error {
return nil
}
func (n NullSentryReporter) ReportExceptionWithContext(any, reporter.Context) error {
return nil
}

View File

@ -0,0 +1,388 @@
// Copyright (c) 2025 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 imapservice
import (
"context"
"errors"
"fmt"
"strings"
"github.com/ProtonMail/gluon/db"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/ProtonMail/proton-bridge/v3/pkg/algo"
"github.com/sirupsen/logrus"
)
type GluonLabelNameProvider interface {
GetUserMailboxByName(ctx context.Context, addrID string, labelName []string) (imap.MailboxData, error)
}
type gluonIDProvider interface {
GetGluonID(addrID string) (string, bool)
}
type sentryReporter interface {
ReportMessageWithContext(string, reporter.Context) error
ReportWarningWithContext(string, reporter.Context) error
}
type apiClient interface {
GetLabel(ctx context.Context, labelID string, labelTypes ...proton.LabelType) (proton.Label, error)
}
type mailboxFetcherFn func(ctx context.Context, label proton.Label) (imap.MailboxData, error)
type mailboxMessageCountFetcherFn func(ctx context.Context, internalMailboxID imap.InternalMailboxID) (int, error)
type LabelConflictManager struct {
gluonLabelNameProvider GluonLabelNameProvider
gluonIDProvider gluonIDProvider
client apiClient
reporter sentryReporter
featureFlagProvider unleash.FeatureFlagValueProvider
}
func NewLabelConflictManager(
gluonLabelNameProvider GluonLabelNameProvider,
gluonIDProvider gluonIDProvider,
client apiClient,
reporter sentryReporter,
featureFlagProvider unleash.FeatureFlagValueProvider) *LabelConflictManager {
return &LabelConflictManager{
gluonLabelNameProvider: gluonLabelNameProvider,
gluonIDProvider: gluonIDProvider,
client: client,
reporter: reporter,
featureFlagProvider: featureFlagProvider,
}
}
func (m *LabelConflictManager) generateMailboxFetcher(connectors []*Connector) mailboxFetcherFn {
return func(ctx context.Context, label proton.Label) (imap.MailboxData, error) {
for _, updateCh := range connectors {
addrID, ok := m.gluonIDProvider.GetGluonID(updateCh.addrID)
if !ok {
continue
}
return m.gluonLabelNameProvider.GetUserMailboxByName(ctx, addrID, GetMailboxName(label))
}
return imap.MailboxData{}, errors.New("no gluon connectors found")
}
}
func (m *LabelConflictManager) generateMailboxMessageCountFetcher(connectors []*Connector) mailboxMessageCountFetcherFn {
return func(ctx context.Context, id imap.InternalMailboxID) (int, error) {
var countSum int
var errs []error
for _, conn := range connectors {
count, err := conn.GetMailboxMessageCount(ctx, id)
countSum += count
errs = append(errs, err)
}
return countSum, errors.Join(errs...)
}
}
type LabelConflictResolver interface {
ResolveConflict(ctx context.Context, label proton.Label, visited map[string]bool) (func() []imap.Update, error)
}
type labelConflictResolverImpl struct {
mailboxFetch mailboxFetcherFn
client apiClient
reporter sentryReporter
log *logrus.Entry
}
type nullLabelConflictResolverImpl struct {
}
func (r *nullLabelConflictResolverImpl) ResolveConflict(_ context.Context, _ proton.Label, _ map[string]bool) (func() []imap.Update, error) {
return func() []imap.Update {
return []imap.Update{}
}, nil
}
func (m *LabelConflictManager) NewConflictResolver(connectors []*Connector) LabelConflictResolver {
if m.featureFlagProvider.GetFlagValue(unleash.LabelConflictResolverDisabled) {
return &nullLabelConflictResolverImpl{}
}
return &labelConflictResolverImpl{
mailboxFetch: m.generateMailboxFetcher(connectors),
client: m.client,
reporter: m.reporter,
log: logrus.WithFields(logrus.Fields{
"pkg": "imapservice/labelConflictResolver",
"numberOfConnectors": len(connectors),
}),
}
}
func (r *labelConflictResolverImpl) ResolveConflict(ctx context.Context, label proton.Label, visited map[string]bool) (func() []imap.Update, error) {
logger := r.log.WithFields(logrus.Fields{
"labelID": label.ID,
"labelPath": hashLabelPaths(GetMailboxName(label)),
})
// For system type labels we shouldn't care.
var updateFns []func() []imap.Update
// There's a cycle, such as in a label swap operation, we'll need to temporarily rename the label.
// The change will be overwritten by one of the previous recursive calls.
if visited[label.ID] {
logrus.Info("Cycle detected, applying temporary rename")
fn := func() []imap.Update {
return []imap.Update{newMailboxUpdatedOrCreated(imap.MailboxID(label.ID), getMailboxNameWithTempPrefix(label))}
}
updateFns = append(updateFns, fn)
return combineIMAPUpdateFns(updateFns), nil
}
visited[label.ID] = true
// Fetch the gluon mailbox data and verify whether there are conflicts with the name.
mailboxData, err := r.mailboxFetch(ctx, label)
if err != nil {
// Name is free, create the mailbox.
if db.IsErrNotFound(err) {
logger.Info("Label not found in DB, creating mailbox.")
fn := func() []imap.Update {
return []imap.Update{newMailboxUpdatedOrCreated(imap.MailboxID(label.ID), GetMailboxName(label))}
}
updateFns = append(updateFns, fn)
return combineIMAPUpdateFns(updateFns), nil
}
return combineIMAPUpdateFns(updateFns), err
}
// Verify whether the label name corresponds to the same label ID. If true terminate, we don't need to update.
if mailboxData.RemoteID == label.ID {
logger.Info("Mailbox name matches label ID, no conflict.")
return combineIMAPUpdateFns(updateFns), nil
}
// This means we've found a conflict. So let's log it.
logger = logger.WithFields(logrus.Fields{
"conflictingLabelID": mailboxData.RemoteID,
"conflictingLabelPath": hashLabelPaths(mailboxData.BridgeName),
})
logger.Info("Label conflict found")
// If the label name belongs to some other label ID. Fetch it's state from the remote.
conflictingLabel, err := r.client.GetLabel(ctx, mailboxData.RemoteID, proton.LabelTypeFolder, proton.LabelTypeLabel, proton.LabelTypeSystem)
if err != nil {
// If it's not present on the remote we should delete it. And create the new label.
if errors.Is(err, proton.ErrNoSuchLabel) {
logger.Info("Conflicting label does not exist on remote. Deleting.")
fn := func() []imap.Update {
return []imap.Update{
imap.NewMailboxDeleted(imap.MailboxID(mailboxData.RemoteID)), // Should this be with remote ID
newMailboxUpdatedOrCreated(imap.MailboxID(label.ID), GetMailboxName(label)),
}
}
updateFns = append(updateFns, fn)
return combineIMAPUpdateFns(updateFns), nil
}
logger.WithError(err).Error("Failed to fetch conflicting label from remote.")
return combineIMAPUpdateFns(updateFns), err
}
// Check if the conflicting label name has changed. If not, then this is a BE inconsistency.
if compareLabelNames(GetMailboxName(conflictingLabel), mailboxData.BridgeName) {
if err := r.reporter.ReportMessageWithContext("Unexpected label conflict", reporter.Context{
"labelID": label.ID,
"conflictingLabelID": conflictingLabel.ID,
}); err != nil {
logger.WithError(err).Error("Failed to report update error")
}
err := fmt.Errorf("unexpected label conflict: the name of label ID %s is already used by label ID %s", label.ID, conflictingLabel.ID)
return combineIMAPUpdateFns(updateFns), err
}
// The name of the conflicting label has changed on the remote. We need to verify that the new name does not conflict with anything else.
// Thus, a recursive check can be performed.
logger.WithField("conflictingLabelNewPath", hashLabelPaths(conflictingLabel.Path)).
Info("Conflicting label name has changed. Recursively resolving conflict.")
childUpdateFns, err := r.ResolveConflict(ctx, conflictingLabel, visited)
if err != nil {
return combineIMAPUpdateFns(updateFns), err
}
updateFns = append(updateFns, childUpdateFns)
fn := func() []imap.Update {
return []imap.Update{newMailboxUpdatedOrCreated(imap.MailboxID(label.ID), GetMailboxName(label))}
}
updateFns = append(updateFns, fn)
return combineIMAPUpdateFns(updateFns), nil
}
func combineIMAPUpdateFns(updateFunctions []func() []imap.Update) func() []imap.Update {
return func() []imap.Update {
var updates []imap.Update
for _, fn := range updateFunctions {
updates = append(updates, fn()...)
}
return updates
}
}
func compareLabelNames(labelName1, labelName2 []string) bool {
name1 := strings.Join(labelName1, "")
name2 := strings.Join(labelName2, "")
return name1 == name2
}
func hashLabelPaths(path []string) string {
return algo.HashBase64SHA256(strings.Join(path, ""))
}
type InternalLabelConflictResolver interface {
ResolveConflict(ctx context.Context, apiLabels map[string]proton.Label) (func() []imap.Update, error)
}
type internalLabelConflictResolverImpl struct {
mailboxFetch mailboxFetcherFn
mailboxMessageCountFetch mailboxMessageCountFetcherFn
userLabelConflictResolver LabelConflictResolver
allowNonEmptyMailboxDeletion bool
client apiClient
reporter sentryReporter
log *logrus.Entry
}
type nullInternalLabelConflictResolver struct{}
func (r *nullInternalLabelConflictResolver) ResolveConflict(_ context.Context, _ map[string]proton.Label) (func() []imap.Update, error) {
return func() []imap.Update { return []imap.Update{} }, nil
}
func (m *LabelConflictManager) NewInternalLabelConflictResolver(connectors []*Connector) InternalLabelConflictResolver {
if m.featureFlagProvider.GetFlagValue(unleash.InternalLabelConflictResolverDisabled) {
return &nullInternalLabelConflictResolver{}
}
return &internalLabelConflictResolverImpl{
mailboxFetch: m.generateMailboxFetcher(connectors),
mailboxMessageCountFetch: m.generateMailboxMessageCountFetcher(connectors),
userLabelConflictResolver: m.NewConflictResolver(connectors),
allowNonEmptyMailboxDeletion: m.featureFlagProvider.GetFlagValue(unleash.ItnternalLabelConflictNonEmptyMailboxDeletion),
client: m.client,
reporter: m.reporter,
log: logrus.WithFields(logrus.Fields{
"pkg": "imapservice/internalLabelConflictResolver",
"numberOfConnectors": len(connectors),
}),
}
}
func (r *internalLabelConflictResolverImpl) ResolveConflict(ctx context.Context, apiLabels map[string]proton.Label) (func() []imap.Update, error) {
updateFns := []func() []imap.Update{}
for _, prefix := range []string{folderPrefix, labelPrefix} {
internalLabel := proton.Label{
Path: []string{prefix},
ID: prefix,
Name: prefix,
}
mbox, err := r.mailboxFetch(ctx, internalLabel)
if err != nil {
if db.IsErrNotFound(err) {
continue
}
return nil, err
}
// If the ID's match then we don't have a discrepancy.
if mbox.RemoteID == internalLabel.ID {
continue
}
logFields := logrus.Fields{
"internalLabelID": internalLabel.ID,
"internalLabelName": internalLabel.Name,
"conflictingLabelID": mbox.RemoteID,
"conflictingLabelName": strings.Join(mbox.BridgeName, "/"),
}
reporterContext := reporter.Context(logFields)
logger := r.log.WithFields(logFields)
logger.Info("Encountered conflict, resolving.")
// There is a discrepancy, let's see if it comes from API.
apiLabel, ok := apiLabels[mbox.RemoteID]
if !ok {
// Label does not come from API, we should delete it.
// Due diligence, check if there are any messages associated with the mailbox.
msgCount, _ := r.mailboxMessageCountFetch(ctx, mbox.InternalID)
if msgCount != 0 {
logger.WithField("conflictingLabelMessageCount", msgCount).Info("Non-API conflicting label has associated messages")
reporterContext["conflictingLabelMessageCount"] = msgCount
if rerr := r.reporter.ReportWarningWithContext("Internal mailbox name conflict. Conflicting non-API label has messages.",
reporterContext); rerr != nil {
logger.WithError(rerr).Error("Failed to send report to sentry")
}
if !r.allowNonEmptyMailboxDeletion {
return combineIMAPUpdateFns(updateFns), fmt.Errorf("internal mailbox conflicting non-api label has associated messages")
}
}
fn := func() []imap.Update {
return []imap.Update{imap.NewMailboxDeletedSilent(imap.MailboxID(mbox.RemoteID))}
}
updateFns = append(updateFns, fn)
continue
}
reporterContext["conflictingLabelType"] = apiLabel.Type
// Label is indeed from API let's see if it's name has changed.
if compareLabelNames(GetMailboxName(apiLabel), internalLabel.Path) {
logger.Error("Conflict, same-name mailbox is returned by API")
if err := r.reporter.ReportMessageWithContext("Internal mailbox name conflict. Same-name mailbox is returned by API", reporterContext); err != nil {
logger.WithError(err).Error("Could not send report to sentry")
}
return combineIMAPUpdateFns(updateFns), fmt.Errorf("API label %s conflicts with internal label %s",
GetMailboxName(apiLabel),
strings.Join(mbox.BridgeName, "/"),
)
}
// If it's name has changed then we ought to rename it while still taking care of potential conflicts.
labelRenameUpdates, err := r.userLabelConflictResolver.ResolveConflict(ctx, apiLabel, make(map[string]bool))
if err != nil {
reporterContext["err"] = err.Error()
if rerr := r.reporter.ReportMessageWithContext("Failed to resolve internal mailbox conflict", reporterContext); rerr != nil {
logger.WithError(rerr).Error("Could not send report to sentry")
}
return combineIMAPUpdateFns(updateFns),
fmt.Errorf("failed to resolve user label conflict for '%s': %w", apiLabel.Name, err)
}
updateFns = append(updateFns, labelRenameUpdates)
}
return combineIMAPUpdateFns(updateFns), nil
}

View File

@ -0,0 +1,961 @@
// Copyright (c) 2025 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 imapservice_test
import (
"context"
"errors"
"fmt"
"testing"
"github.com/ProtonMail/gluon/db"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
type ffProviderFalse struct{}
type ffProviderTrue struct{}
func (f ffProviderFalse) GetFlagValue(_ string) bool {
return false
}
func (f ffProviderTrue) GetFlagValue(_ string) bool {
return true
}
type mockLabelNameProvider struct {
mock.Mock
}
func (m *mockLabelNameProvider) GetUserMailboxByName(ctx context.Context, addrID string, labelName []string) (imap.MailboxData, error) {
args := m.Called(ctx, addrID, labelName)
v, ok := args.Get(0).(imap.MailboxData)
if !ok {
return imap.MailboxData{}, fmt.Errorf("failed to assert type")
}
return v, args.Error(1)
}
type mockIDProvider struct {
mock.Mock
}
func (m *mockIDProvider) GetGluonID(addrID string) (string, bool) {
args := m.Called(addrID)
return args.String(0), args.Bool(1)
}
type mockAPIClient struct {
mock.Mock
}
func (m *mockAPIClient) GetLabel(ctx context.Context, id string, types ...proton.LabelType) (proton.Label, error) {
args := m.Called(ctx, id, types)
v, ok := args.Get(0).(proton.Label)
if !ok {
return proton.Label{}, fmt.Errorf("failed to assert type")
}
return v, args.Error(1)
}
type mockReporter struct {
mock.Mock
}
func (m *mockReporter) ReportMessageWithContext(msg string, ctx reporter.Context) error {
args := m.Called(msg, ctx)
return args.Error(0)
}
func (m *mockReporter) ReportWarningWithContext(msg string, ctx reporter.Context) error {
args := m.Called(msg, ctx)
return args.Error(0)
}
func TestResolveConflict_UnexpectedLabelConflict(t *testing.T) {
ctx := context.Background()
label := proton.Label{
ID: "label-1",
Path: []string{"Work"},
Type: proton.LabelTypeLabel,
}
conflictingLabel := proton.Label{
ID: "label-2",
Path: []string{"Work"},
Type: proton.LabelTypeLabel,
}
conflictMbox := imap.MailboxData{
RemoteID: "label-2",
BridgeName: []string{"Labels", "Work"},
}
mockLabelProvider := new(mockLabelNameProvider)
mockIDProvider := new(mockIDProvider)
mockClient := new(mockAPIClient)
mockReporter := new(mockReporter)
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id", imapservice.GetMailboxName(label)).
Return(conflictMbox, nil)
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id", true)
mockClient.On("GetLabel", mock.Anything, "label-2", mock.Anything).
Return(conflictingLabel, nil)
mockReporter.On("ReportMessageWithContext", "Unexpected label conflict", mock.Anything).
Return(nil)
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
resolver := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{}).
NewConflictResolver([]*imapservice.Connector{connector})
visited := make(map[string]bool)
_, err := resolver.ResolveConflict(ctx, label, visited)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unexpected label conflict")
}
func TestResolveDiscrepancy_LabelDoesNotExist(t *testing.T) {
ctx := context.Background()
label := proton.Label{
ID: "label-id-1",
Name: "Inbox",
Type: proton.LabelTypeLabel,
}
mockLabelProvider := new(mockLabelNameProvider)
mockIDProvider := new(mockIDProvider)
mockClient := new(mockAPIClient)
mockReporter := new(mockReporter)
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id-1", imapservice.GetMailboxName(label)).
Return(imap.MailboxData{}, db.ErrNotFound)
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id-1", true)
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewConflictResolver(connectors)
visited := make(map[string]bool)
fn, err := resolver.ResolveConflict(ctx, label, visited)
assert.NoError(t, err)
updates := fn()
assert.Len(t, updates, 1)
muc, ok := updates[0].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID(label.ID), muc.Mailbox.ID)
}
func TestResolveConflict_MailboxFetchError(t *testing.T) {
ctx := context.Background()
label := proton.Label{
ID: "111",
Path: []string{"Work"},
Type: proton.LabelTypeLabel,
}
mockLabelProvider := new(mockLabelNameProvider)
mockIDProvider := new(mockIDProvider)
mockClient := new(mockAPIClient)
mockReporter := new(mockReporter)
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id", imapservice.GetMailboxName(label)).
Return(imap.MailboxData{}, errors.New("database connection error"))
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id", true)
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
resolver := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{}).
NewConflictResolver([]*imapservice.Connector{connector})
visited := make(map[string]bool)
_, err := resolver.ResolveConflict(ctx, label, visited)
assert.Error(t, err)
assert.Contains(t, err.Error(), "database connection error")
}
func TestResolveDiscrepancy_ConflictingLabelDeletedRemotely(t *testing.T) {
ctx := context.Background()
label := proton.Label{
ID: "label-new",
Path: []string{"Work"},
Type: proton.LabelTypeLabel,
}
conflictMbox := imap.MailboxData{
RemoteID: "label-old",
BridgeName: []string{"Labels", "Work"},
}
mockLabelProvider := new(mockLabelNameProvider)
mockIDProvider := new(mockIDProvider)
mockClient := new(mockAPIClient)
mockReporter := new(mockReporter)
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id-1", imapservice.GetMailboxName(label)).
Return(conflictMbox, nil)
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id-1", true)
mockClient.On("GetLabel", mock.Anything, "label-old", mock.Anything).
Return(proton.Label{}, proton.ErrNoSuchLabel)
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewConflictResolver(connectors)
visited := make(map[string]bool)
fn, err := resolver.ResolveConflict(ctx, label, visited)
assert.NoError(t, err)
updates := fn()
assert.Len(t, updates, 2)
deleted, ok := updates[0].(*imap.MailboxDeleted)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID("label-old"), deleted.MailboxID)
updated, ok := updates[1].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, "Work", updated.Mailbox.Name[len(updated.Mailbox.Name)-1])
}
func TestResolveDiscrepancy_LabelAlreadyCorrect(t *testing.T) {
ctx := context.Background()
label := proton.Label{
ID: "label-id-1",
Name: "Personal",
Type: proton.LabelTypeLabel,
}
mbox := imap.MailboxData{
RemoteID: "label-id-1",
BridgeName: []string{"Labels", "Personal"},
}
mockLabelProvider := new(mockLabelNameProvider)
mockIDProvider := new(mockIDProvider)
mockClient := new(mockAPIClient)
mockReporter := new(mockReporter)
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id-1", imapservice.GetMailboxName(label)).
Return(mbox, nil)
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id-1", true)
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewConflictResolver(connectors)
visited := make(map[string]bool)
fn, err := resolver.ResolveConflict(ctx, label, visited)
assert.NoError(t, err)
assert.Len(t, fn(), 0)
}
func TestResolveConflict_DeepNestedPath(t *testing.T) {
ctx := context.Background()
label := proton.Label{
ID: "111",
Path: []string{"Level1", "Level2", "Level3", "DeepFolder"},
Type: proton.LabelTypeFolder,
}
mockLabelProvider := new(mockLabelNameProvider)
mockIDProvider := new(mockIDProvider)
mockClient := new(mockAPIClient)
mockReporter := new(mockReporter)
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id", imapservice.GetMailboxName(label)).
Return(imap.MailboxData{}, db.ErrNotFound)
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id", true)
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
resolver := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{}).
NewConflictResolver([]*imapservice.Connector{connector})
visited := make(map[string]bool)
fn, err := resolver.ResolveConflict(ctx, label, visited)
assert.NoError(t, err)
updates := fn()
assert.Len(t, updates, 1)
updated, ok := updates[0].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID("111"), updated.Mailbox.ID)
expectedName := imapservice.GetMailboxName(label)
assert.Equal(t, expectedName, updated.Mailbox.Name)
}
func TestResolveLabelDiscrepancy_LabelSwap(t *testing.T) {
apiLabels := []proton.Label{
{
ID: "111",
Path: []string{"X"},
Type: proton.LabelTypeLabel,
},
{
ID: "222",
Path: []string{"Y"},
Type: proton.LabelTypeLabel,
},
}
gluonLabels := []imap.MailboxData{
{
RemoteID: "111",
BridgeName: []string{"Labels", "Y"},
},
{
RemoteID: "222",
BridgeName: []string{"Labels", "X"},
},
}
mockLabelProvider := new(mockLabelNameProvider)
mockClient := new(mockAPIClient)
mockIDProvider := new(mockIDProvider)
mockReporter := new(mockReporter)
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id-1", true)
for _, mbox := range gluonLabels {
mockLabelProvider.
On("GetUserMailboxByName", mock.Anything, "gluon-id-1", mbox.BridgeName).
Return(mbox, nil)
}
for _, label := range apiLabels {
mockClient.
On("GetLabel", mock.Anything, label.ID, []proton.LabelType{proton.LabelTypeFolder, proton.LabelTypeLabel, proton.LabelTypeSystem}).
Return(label, nil)
}
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewConflictResolver(connectors)
visited := make(map[string]bool)
fn, err := resolver.ResolveConflict(context.Background(), apiLabels[0], visited)
require.NoError(t, err)
updates := fn()
assert.NotEmpty(t, updates)
assert.Equal(t, 3, len(updates)) // We expect three calls to be made for a swap operation.
updateOne, ok := updates[0].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID(apiLabels[0].ID), updateOne.Mailbox.ID)
assert.Equal(t, "tmp_X", updateOne.Mailbox.Name[len(updateOne.Mailbox.Name)-1])
updateTwo, ok := updates[1].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID(apiLabels[1].ID), updateTwo.Mailbox.ID)
assert.Equal(t, "Y", updateTwo.Mailbox.Name[len(updateTwo.Mailbox.Name)-1])
updateThree, ok := updates[2].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID(apiLabels[0].ID), updateThree.Mailbox.ID)
assert.Equal(t, "X", updateThree.Mailbox.Name[len(updateThree.Mailbox.Name)-1])
}
func TestResolveLabelDiscrepancy_LabelSwapExtended(t *testing.T) {
apiLabels := []proton.Label{
{
ID: "111",
Path: []string{"X"},
Type: proton.LabelTypeLabel,
},
{
ID: "222",
Path: []string{"Y"},
Type: proton.LabelTypeLabel,
},
{
ID: "333",
Path: []string{"Z"},
Type: proton.LabelTypeLabel,
},
{
ID: "444",
Path: []string{"D"},
Type: proton.LabelTypeLabel,
},
}
gluonLabels := []imap.MailboxData{
{
RemoteID: "111",
BridgeName: []string{"Labels", "D"},
},
{
RemoteID: "222",
BridgeName: []string{"Labels", "Z"},
},
{
RemoteID: "333",
BridgeName: []string{"Labels", "Y"},
},
{
RemoteID: "444",
BridgeName: []string{"Labels", "X"},
},
}
mockLabelProvider := new(mockLabelNameProvider)
mockClient := new(mockAPIClient)
mockIDProvider := new(mockIDProvider)
mockReporter := new(mockReporter)
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id-1", true)
for _, mbox := range gluonLabels {
mockLabelProvider.
On("GetUserMailboxByName", mock.Anything, "gluon-id-1", mbox.BridgeName).
Return(mbox, nil)
}
for _, label := range apiLabels {
mockClient.
On("GetLabel", mock.Anything, label.ID, []proton.LabelType{proton.LabelTypeFolder, proton.LabelTypeLabel, proton.LabelTypeSystem}).
Return(label, nil)
}
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewConflictResolver(connectors)
fn, err := resolver.ResolveConflict(context.Background(), apiLabels[0], make(map[string]bool))
require.NoError(t, err)
updates := fn()
assert.NotEmpty(t, updates)
// Three calls yet again for a swap operation.
assert.Equal(t, 3, len(updates))
updateOne, ok := updates[0].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID(apiLabels[0].ID), updateOne.Mailbox.ID)
assert.Equal(t, "tmp_X", updateOne.Mailbox.Name[len(updateOne.Mailbox.Name)-1])
updateTwo, ok := updates[1].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID(apiLabels[3].ID), updateTwo.Mailbox.ID)
assert.Equal(t, "D", updateTwo.Mailbox.Name[len(updateTwo.Mailbox.Name)-1])
updateThree, ok := updates[2].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID(apiLabels[0].ID), updateThree.Mailbox.ID)
assert.Equal(t, "X", updateThree.Mailbox.Name[len(updateThree.Mailbox.Name)-1])
// Fix the secondary swap.
fn, err = resolver.ResolveConflict(context.Background(), apiLabels[1], make(map[string]bool))
require.NoError(t, err)
updates = fn()
assert.Equal(t, 3, len(updates))
updateOne, ok = updates[0].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID(apiLabels[1].ID), updateOne.Mailbox.ID)
assert.Equal(t, "tmp_Y", updateOne.Mailbox.Name[len(updateOne.Mailbox.Name)-1])
updateTwo, ok = updates[1].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID(apiLabels[2].ID), updateTwo.Mailbox.ID)
assert.Equal(t, "Z", updateTwo.Mailbox.Name[len(updateTwo.Mailbox.Name)-1])
updateThree, ok = updates[2].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID(apiLabels[1].ID), updateThree.Mailbox.ID)
assert.Equal(t, "Y", updateThree.Mailbox.Name[len(updateThree.Mailbox.Name)-1])
}
func TestResolveLabelDiscrepancy_LabelSwapCyclic(t *testing.T) {
apiLabels := []proton.Label{
{ID: "111", Path: []string{"A"}, Type: proton.LabelTypeLabel},
{ID: "222", Path: []string{"B"}, Type: proton.LabelTypeLabel},
{ID: "333", Path: []string{"C"}, Type: proton.LabelTypeLabel},
{ID: "444", Path: []string{"D"}, Type: proton.LabelTypeLabel},
}
gluonLabels := []imap.MailboxData{
{RemoteID: "111", BridgeName: []string{"Labels", "D"}}, // A <- D
{RemoteID: "222", BridgeName: []string{"Labels", "A"}}, // B <- A
{RemoteID: "333", BridgeName: []string{"Labels", "B"}}, // C <- B
{RemoteID: "444", BridgeName: []string{"Labels", "C"}}, // D <- C
}
mockLabelProvider := new(mockLabelNameProvider)
mockClient := new(mockAPIClient)
mockIDProvider := new(mockIDProvider)
mockReporter := new(mockReporter)
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id-1", true)
for _, mbox := range gluonLabels {
mockLabelProvider.
On("GetUserMailboxByName", mock.Anything, "gluon-id-1", mbox.BridgeName).
Return(mbox, nil)
}
for _, label := range apiLabels {
mockClient.
On("GetLabel", mock.Anything, label.ID, []proton.LabelType{proton.LabelTypeFolder, proton.LabelTypeLabel, proton.LabelTypeSystem}).
Return(label, nil)
}
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewConflictResolver(connectors)
fn, err := resolver.ResolveConflict(context.Background(), apiLabels[0], make(map[string]bool))
require.NoError(t, err)
updates := fn()
assert.NotEmpty(t, updates)
assert.Equal(t, 5, len(updates))
updateOne, ok := updates[0].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID(apiLabels[0].ID), updateOne.Mailbox.ID)
assert.Equal(t, "tmp_A", updateOne.Mailbox.Name[len(updateOne.Mailbox.Name)-1])
updateTwo, ok := updates[1].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID(apiLabels[3].ID), updateTwo.Mailbox.ID)
assert.Equal(t, "D", updateTwo.Mailbox.Name[len(updateTwo.Mailbox.Name)-1])
updateThree, ok := updates[2].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID(apiLabels[2].ID), updateThree.Mailbox.ID)
assert.Equal(t, "C", updateThree.Mailbox.Name[len(updateThree.Mailbox.Name)-1])
updateFour, ok := updates[3].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID(apiLabels[1].ID), updateFour.Mailbox.ID)
assert.Equal(t, "B", updateFour.Mailbox.Name[len(updateFour.Mailbox.Name)-1])
updateFive, ok := updates[4].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID(apiLabels[0].ID), updateFive.Mailbox.ID)
assert.Equal(t, "A", updateFive.Mailbox.Name[len(updateFive.Mailbox.Name)-1])
}
func TestResolveLabelDiscrepancy_LabelSwapCyclicWithDeletedLabel(t *testing.T) {
apiLabels := []proton.Label{
{ID: "111", Path: []string{"A"}, Type: proton.LabelTypeLabel},
{ID: "333", Path: []string{"C"}, Type: proton.LabelTypeLabel},
{ID: "444", Path: []string{"D"}, Type: proton.LabelTypeLabel},
}
gluonLabels := []imap.MailboxData{
{RemoteID: "111", BridgeName: []string{"Labels", "D"}},
{RemoteID: "222", BridgeName: []string{"Labels", "A"}},
{RemoteID: "333", BridgeName: []string{"Labels", "B"}},
{RemoteID: "444", BridgeName: []string{"Labels", "C"}},
}
mockLabelProvider := new(mockLabelNameProvider)
mockClient := new(mockAPIClient)
mockIDProvider := new(mockIDProvider)
mockReporter := new(mockReporter)
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id-1", true)
for _, mbox := range gluonLabels {
mockLabelProvider.
On("GetUserMailboxByName", mock.Anything, "gluon-id-1", mbox.BridgeName).
Return(mbox, nil)
}
for _, label := range apiLabels {
mockClient.
On("GetLabel", mock.Anything, label.ID, []proton.LabelType{proton.LabelTypeFolder, proton.LabelTypeLabel, proton.LabelTypeSystem}).
Return(label, nil)
}
mockClient.On("GetLabel", mock.Anything, "222", []proton.LabelType{proton.LabelTypeFolder, proton.LabelTypeLabel, proton.LabelTypeSystem}).Return(proton.Label{}, proton.ErrNoSuchLabel)
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewConflictResolver(connectors)
fn, err := resolver.ResolveConflict(context.Background(), apiLabels[2], make(map[string]bool))
require.NoError(t, err)
updates := fn()
assert.NotEmpty(t, updates)
assert.Equal(t, 3, len(updates))
updateOne, ok := updates[0].(*imap.MailboxDeleted)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID("222"), updateOne.MailboxID)
updateTwo, ok := updates[1].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID(apiLabels[0].ID), updateTwo.Mailbox.ID)
assert.Equal(t, "A", updateTwo.Mailbox.Name[len(updateTwo.Mailbox.Name)-1])
updateThree, ok := updates[2].(*imap.MailboxUpdatedOrCreated)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID(apiLabels[2].ID), updateThree.Mailbox.ID)
assert.Equal(t, "D", updateThree.Mailbox.Name[len(updateThree.Mailbox.Name)-1])
}
func TestResolveLabelDiscrepancy_LabelSwapCyclicWithDeletedLabel_KillSwitchEnabled(t *testing.T) {
apiLabels := []proton.Label{
{ID: "111", Path: []string{"A"}, Type: proton.LabelTypeLabel},
{ID: "333", Path: []string{"C"}, Type: proton.LabelTypeLabel},
{ID: "444", Path: []string{"D"}, Type: proton.LabelTypeLabel},
}
gluonLabels := []imap.MailboxData{
{RemoteID: "111", BridgeName: []string{"Labels", "D"}},
{RemoteID: "222", BridgeName: []string{"Labels", "A"}},
{RemoteID: "333", BridgeName: []string{"Labels", "B"}},
{RemoteID: "444", BridgeName: []string{"Labels", "C"}},
}
mockLabelProvider := new(mockLabelNameProvider)
mockClient := new(mockAPIClient)
mockIDProvider := new(mockIDProvider)
mockReporter := new(mockReporter)
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id-1", true)
for _, mbox := range gluonLabels {
mockLabelProvider.
On("GetUserMailboxByName", mock.Anything, "gluon-id-1", mbox.BridgeName).
Return(mbox, nil)
}
for _, label := range apiLabels {
mockClient.
On("GetLabel", mock.Anything, label.ID, []proton.LabelType{proton.LabelTypeFolder, proton.LabelTypeLabel, proton.LabelTypeSystem}).
Return(label, nil)
}
mockClient.On("GetLabel", mock.Anything, "222", []proton.LabelType{proton.LabelTypeFolder, proton.LabelTypeLabel, proton.LabelTypeSystem}).Return(proton.Label{}, proton.ErrNoSuchLabel)
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderTrue{})
resolver := manager.NewConflictResolver(connectors)
fn, err := resolver.ResolveConflict(context.Background(), apiLabels[2], make(map[string]bool))
require.NoError(t, err)
updates := fn()
assert.Empty(t, updates)
}
func TestInternalLabelConflictResolver_NoConflicts(t *testing.T) {
ctx := context.Background()
mockLabelProvider := new(mockLabelNameProvider)
mockClient := new(mockAPIClient)
mockIDProvider := new(mockIDProvider)
mockReporter := new(mockReporter)
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id-1", true)
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id-1", []string{"Folders"}).
Return(imap.MailboxData{}, db.ErrNotFound)
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id-1", []string{"Labels"}).
Return(imap.MailboxData{}, db.ErrNotFound)
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewInternalLabelConflictResolver(connectors)
apiLabels := make(map[string]proton.Label)
fn, err := resolver.ResolveConflict(ctx, apiLabels)
assert.NoError(t, err)
updates := fn()
assert.Empty(t, updates)
}
func TestInternalLabelConflictResolver_CorrectIDs(t *testing.T) {
ctx := context.Background()
mockLabelProvider := new(mockLabelNameProvider)
mockClient := new(mockAPIClient)
mockIDProvider := new(mockIDProvider)
mockReporter := new(mockReporter)
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id-1", true)
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id-1", []string{"Folders"}).
Return(imap.MailboxData{RemoteID: "Folders", BridgeName: []string{"Folders"}}, nil)
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id-1", []string{"Labels"}).
Return(imap.MailboxData{RemoteID: "Labels", BridgeName: []string{"Labels"}}, nil)
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewInternalLabelConflictResolver(connectors)
apiLabels := make(map[string]proton.Label)
fn, err := resolver.ResolveConflict(ctx, apiLabels)
assert.NoError(t, err)
updates := fn()
assert.Empty(t, updates)
}
type mockMailboxCountProvider struct {
mock.Mock
}
func (m *mockMailboxCountProvider) GetUserMailboxCountByInternalID(ctx context.Context, addrID string, internalID imap.InternalMailboxID) (int, error) {
args := m.Called(ctx, addrID, internalID)
return args.Int(0), args.Error(1)
}
func TestInternalLabelConflictResolver_ConflictingNonAPILabel_ZeroCount(t *testing.T) {
ctx := context.Background()
mockLabelProvider := new(mockLabelNameProvider)
mockClient := new(mockAPIClient)
mockIDProvider := new(mockIDProvider)
mockReporter := new(mockReporter)
mockCountProvider := new(mockMailboxCountProvider)
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id-1", true)
// Mock mailbox fetch to return conflicting mailbox
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id-1", []string{"Folders"}).
Return(imap.MailboxData{RemoteID: "wrong-id", BridgeName: []string{"Folders"}, InternalID: imap.InternalMailboxID(123)}, nil)
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id-1", []string{"Labels"}).
Return(imap.MailboxData{}, db.ErrNotFound)
// Mock message count fetch to return 0 messages.
mockLabelProvider.On("GetMailboxMessageCount", mock.Anything, "gluon-id-1", imap.InternalMailboxID(123)).
Return(0, nil)
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
mockCountProvider.On("GetUserMailboxCountByInternalID",
mock.Anything,
"addr-1",
imap.InternalMailboxID(123)).
Return(0, nil)
connector.SetMailboxCountProviderTest(mockCountProvider)
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewInternalLabelConflictResolver(connectors)
// API labels don't contain the conflicting label ID
apiLabels := make(map[string]proton.Label)
fn, err := resolver.ResolveConflict(ctx, apiLabels)
assert.NoError(t, err)
updates := fn()
assert.Len(t, updates, 1)
deleted, ok := updates[0].(*imap.MailboxDeletedSilent)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID("wrong-id"), deleted.MailboxID)
}
func TestInternalLabelConflictResolver_ConflictingNonAPILabel_PositiveCount(t *testing.T) {
ctx := context.Background()
mockLabelProvider := new(mockLabelNameProvider)
mockClient := new(mockAPIClient)
mockIDProvider := new(mockIDProvider)
mockReporter := new(mockReporter)
mockCountProvider := new(mockMailboxCountProvider)
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id-1", true)
mockReporter.On("ReportWarningWithContext", mock.Anything, mock.Anything).
Return(nil)
// Mock mailbox fetch to return conflicting mailbox
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id-1", []string{"Folders"}).
Return(imap.MailboxData{RemoteID: "wrong-id", BridgeName: []string{"Folders"}, InternalID: imap.InternalMailboxID(123)}, nil)
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id-1", []string{"Labels"}).
Return(imap.MailboxData{}, db.ErrNotFound)
// Mock message count fetch to return 0 messages.
mockLabelProvider.On("GetMailboxMessageCount", mock.Anything, "gluon-id-1", imap.InternalMailboxID(123)).
Return(0, nil)
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
mockCountProvider.On("GetUserMailboxCountByInternalID",
mock.Anything,
"addr-1",
imap.InternalMailboxID(123)).
Return(10, nil)
connector.SetMailboxCountProviderTest(mockCountProvider)
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewInternalLabelConflictResolver(connectors)
// API labels don't contain the conflicting label ID
apiLabels := make(map[string]proton.Label)
fn, err := resolver.ResolveConflict(ctx, apiLabels)
assert.EqualError(t, err, "internal mailbox conflicting non-api label has associated messages")
updates := fn()
assert.Empty(t, updates, 0)
}
func TestInternalLabelConflictResolver_ConflictingAPILabelSameName(t *testing.T) {
ctx := context.Background()
mockLabelProvider := new(mockLabelNameProvider)
mockClient := new(mockAPIClient)
mockIDProvider := new(mockIDProvider)
mockReporter := new(mockReporter)
mockCountProvider := new(mockMailboxCountProvider)
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id-1", true)
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id-1", []string{"Folders"}).
Return(imap.MailboxData{RemoteID: "api-label-id", BridgeName: []string{"Folders"}}, nil)
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id-1", []string{"Labels"}).
Return(imap.MailboxData{}, db.ErrNotFound)
mockReporter.On("ReportMessageWithContext", "Internal mailbox name conflict. Same-name mailbox is returned by API", mock.Anything).
Return(nil)
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
connector.SetMailboxCountProviderTest(mockCountProvider)
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewInternalLabelConflictResolver(connectors)
// API user label with empty path.
apiLabels := map[string]proton.Label{
"api-label-id": {
ID: "api-label-id",
Name: "Folders",
Path: []string{""},
Type: proton.LabelTypeFolder,
},
}
_, err := resolver.ResolveConflict(ctx, apiLabels)
assert.Error(t, err)
assert.Contains(t, err.Error(), "API label")
assert.Contains(t, err.Error(), "conflicts with internal label")
}
func TestInternalLabelConflictResolver_MailboxFetchError(t *testing.T) {
ctx := context.Background()
mockLabelProvider := new(mockLabelNameProvider)
mockClient := new(mockAPIClient)
mockIDProvider := new(mockIDProvider)
mockReporter := new(mockReporter)
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id-1", true)
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id-1", []string{"Folders"}).
Return(imap.MailboxData{}, errors.New("database connection error"))
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewInternalLabelConflictResolver(connectors)
apiLabels := make(map[string]proton.Label)
_, err := resolver.ResolveConflict(ctx, apiLabels)
assert.Error(t, err)
assert.Contains(t, err.Error(), "database connection error")
}
func TestNewInternalLabelConflictResolver_KillSwitchEnabled(t *testing.T) {
ctx := context.Background()
mockLabelProvider := new(mockLabelNameProvider)
mockClient := new(mockAPIClient)
mockIDProvider := new(mockIDProvider)
mockReporter := new(mockReporter)
mockIDProvider.On("GetGluonID", "addr-1").Return("gluon-id-1", true)
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id-1", []string{"Folders"}).
Return(imap.MailboxData{RemoteID: "wrong-folders-id", BridgeName: []string{"Folders"}}, nil)
mockLabelProvider.On("GetUserMailboxByName", mock.Anything, "gluon-id-1", []string{"Labels"}).
Return(imap.MailboxData{RemoteID: "wrong-labels-id", BridgeName: []string{"Labels"}}, nil)
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderTrue{})
resolver := manager.NewInternalLabelConflictResolver(connectors)
apiLabels := map[string]proton.Label{
"some-api-label": {
ID: "some-api-label",
Name: "SomeLabel",
Path: []string{"SomeLabel"},
Type: proton.LabelTypeLabel,
},
}
fn, err := resolver.ResolveConflict(ctx, apiLabels)
assert.NoError(t, err)
updates := fn()
assert.Empty(t, updates)
}

View File

@ -45,6 +45,10 @@ import (
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
) )
type mailboxCountProvider interface {
GetUserMailboxCountByInternalID(ctx context.Context, addrID string, internalID imap.InternalMailboxID) (int, error)
}
// Connector contains all IMAP state required to satisfy sync and or imap queries. // Connector contains all IMAP state required to satisfy sync and or imap queries.
type Connector struct { type Connector struct {
addrID string addrID string
@ -67,6 +71,8 @@ type Connector struct {
sharedCache *SharedCache sharedCache *SharedCache
syncState *SyncState syncState *SyncState
mailboxCountProvider mailboxCountProvider
} }
var errNoSenderAddressMatch = errors.New("no matching sender found in address list") var errNoSenderAddressMatch = errors.New("no matching sender found in address list")
@ -82,6 +88,7 @@ func NewConnector(
reporter reporter.Reporter, reporter reporter.Reporter,
showAllMail bool, showAllMail bool,
syncState *SyncState, syncState *SyncState,
mailboxCountProvider mailboxCountProvider,
) *Connector { ) *Connector {
userID := identityState.UserID() userID := identityState.UserID()
@ -115,6 +122,8 @@ func NewConnector(
sharedCache: NewSharedCached(), sharedCache: NewSharedCached(),
syncState: syncState, syncState: syncState,
mailboxCountProvider: mailboxCountProvider,
} }
} }
@ -800,8 +809,10 @@ func (s *Connector) createDraftWithParser(ctx context.Context, parser *parser.Pa
return draft, nil return draft, nil
} }
func (s *Connector) publishUpdate(_ context.Context, update imap.Update) { func (s *Connector) publishUpdate(_ context.Context, updates ...imap.Update) {
s.updateCh.Enqueue(update) for _, update := range updates {
s.updateCh.Enqueue(update)
}
} }
func fixGODT3003Labels( func fixGODT3003Labels(
@ -903,3 +914,16 @@ func (s *Connector) getSenderProtonAddress(p *parser.Parser) (proton.Address, er
return addressList[index], nil return addressList[index], nil
} }
func (s *Connector) SetAddrIDTest(addrID string) {
s.addrID = addrID
}
func (s *Connector) GetMailboxMessageCount(ctx context.Context, mailboxInternalID imap.InternalMailboxID) (int, error) {
return s.mailboxCountProvider.GetUserMailboxCountByInternalID(ctx, s.addrID, mailboxInternalID)
}
// SetMailboxCountProviderTest - sets the relevant provider. Should only be used for testing.
func (s *Connector) SetMailboxCountProviderTest(provider mailboxCountProvider) {
s.mailboxCountProvider = provider
}

View File

@ -102,6 +102,16 @@ func newMailboxCreatedUpdate(labelID imap.MailboxID, labelName []string) *imap.M
}) })
} }
func newMailboxUpdatedOrCreated(labelID imap.MailboxID, labelName []string) *imap.MailboxUpdatedOrCreated {
return imap.NewMailboxUpdatedOrCreated(imap.Mailbox{
ID: labelID,
Name: labelName,
Flags: defaultMailboxFlags(),
PermanentFlags: defaultMailboxPermanentFlags(),
Attributes: imap.NewFlagSet(),
})
}
func GetMailboxName(label proton.Label) []string { func GetMailboxName(label proton.Label) []string {
var name []string var name []string
@ -112,9 +122,10 @@ func GetMailboxName(label proton.Label) []string {
case proton.LabelTypeLabel: case proton.LabelTypeLabel:
name = append([]string{labelPrefix}, label.Path...) name = append([]string{labelPrefix}, label.Path...)
case proton.LabelTypeContactGroup:
fallthrough
case proton.LabelTypeSystem: case proton.LabelTypeSystem:
name = []string{label.Name}
case proton.LabelTypeContactGroup:
fallthrough fallthrough
default: default:
name = label.Path name = label.Path
@ -122,3 +133,12 @@ func GetMailboxName(label proton.Label) []string {
return name return name
} }
func nameWithTempPrefix(path []string) []string {
path[len(path)-1] = "tmp_" + path[len(path)-1]
return path
}
func getMailboxNameWithTempPrefix(label proton.Label) []string {
return nameWithTempPrefix(GetMailboxName(label))
}

View File

@ -0,0 +1,223 @@
// Copyright (c) 2025 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 imapservice
import (
"context"
"errors"
"fmt"
"strings"
"github.com/ProtonMail/gluon/db"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/pkg/algo"
"github.com/sirupsen/logrus"
)
type labelDiscrepancyType int
const (
discrepancyInternal labelDiscrepancyType = iota
discrepancySystem
discrepancyUser
)
func (t labelDiscrepancyType) String() string {
switch t {
case discrepancyInternal:
return "internal"
case discrepancySystem:
return "system"
case discrepancyUser:
return "user"
default:
return "unknown"
}
}
type labelDiscrepancy struct {
labelName string
labelPath string
labelPathParsed string
labelID string
conflictingLabelName string
conflictingLabelID string
Type labelDiscrepancyType
}
func joinStrings(input []string) string {
return strings.Join(input, "/")
}
func newLabelDiscrepancy(label proton.Label, mbox imap.MailboxData, dType labelDiscrepancyType) labelDiscrepancy {
discrepancy := labelDiscrepancy{
labelName: label.Name,
labelID: label.ID,
conflictingLabelID: mbox.RemoteID,
Type: dType,
}
if dType == discrepancyUser {
discrepancy.labelName = algo.HashBase64SHA256(label.Name)
discrepancy.labelPath = algo.HashBase64SHA256(joinStrings(label.Path))
discrepancy.labelPathParsed = algo.HashBase64SHA256(joinStrings(GetMailboxName(label)))
discrepancy.conflictingLabelName = algo.HashBase64SHA256(joinStrings(mbox.BridgeName))
} else {
discrepancy.labelName = label.Name
discrepancy.labelPath = joinStrings(label.Path)
discrepancy.labelPathParsed = joinStrings(GetMailboxName(label))
discrepancy.conflictingLabelName = joinStrings(mbox.BridgeName)
}
return discrepancy
}
func discrepanciesToContext(discrepancies []labelDiscrepancy) reporter.Context {
ctx := make(reporter.Context)
for i, d := range discrepancies {
prefix := fmt.Sprintf("discrepancy_%d_", i)
ctx[prefix+"type"] = d.Type.String()
ctx[prefix+"label_id"] = d.labelID
ctx[prefix+"label_name"] = d.labelName
ctx[prefix+"label_path"] = d.labelPath
ctx[prefix+"label_path_parsed"] = d.labelPathParsed
ctx[prefix+"conflicting_label_name"] = d.conflictingLabelName
ctx[prefix+"conflicting_label_id"] = d.conflictingLabelID
}
ctx["discrepancy_count"] = len(discrepancies)
return ctx
}
type ConnectorGetter interface {
getConnectors() []*Connector
}
type LabelConflictChecker struct {
gluonLabelNameProvider GluonLabelNameProvider
gluonIDProvider gluonIDProvider
connectorGetter ConnectorGetter
reporter reporter.Reporter
logger *logrus.Entry
}
func NewConflictChecker(connectorGetter ConnectorGetter, reporter reporter.Reporter, provider gluonIDProvider, nameProvider GluonLabelNameProvider) *LabelConflictChecker {
return &LabelConflictChecker{
gluonLabelNameProvider: nameProvider,
gluonIDProvider: provider,
connectorGetter: connectorGetter,
reporter: reporter,
logger: logrus.WithFields(logrus.Fields{
"pkg": "imapservice/labelConflictChecker",
}),
}
}
func (c *LabelConflictChecker) getFn() mailboxFetcherFn {
connectors := c.connectorGetter.getConnectors()
return func(ctx context.Context, label proton.Label) (imap.MailboxData, error) {
for _, updateCh := range connectors {
addrID, ok := c.gluonIDProvider.GetGluonID(updateCh.addrID)
if !ok {
continue
}
return c.gluonLabelNameProvider.GetUserMailboxByName(ctx, addrID, GetMailboxName(label))
}
return imap.MailboxData{}, errors.New("no gluon connectors found")
}
}
func (c *LabelConflictChecker) CheckAndReportConflicts(ctx context.Context, labels map[string]proton.Label) error {
labelDiscrepancies, err := c.checkConflicts(ctx, labels, c.getFn())
if err != nil {
return err
}
if len(labelDiscrepancies) == 0 {
return nil
}
reporterCtx := discrepanciesToContext(labelDiscrepancies)
if err := c.reporter.ReportMessageWithContext("Found label conflicts on Bridge start", reporterCtx); err != nil {
c.logger.WithError(err).Error("Failed to report label conflicts to Sentry")
}
return nil
}
func (c *LabelConflictChecker) checkConflicts(ctx context.Context, labels map[string]proton.Label, mboxFetch mailboxFetcherFn) ([]labelDiscrepancy, error) {
discrepancies := []labelDiscrepancy{}
// Verify bridge internal mailboxes.
for _, prefix := range []string{folderPrefix, labelPrefix} {
label := proton.Label{
Path: []string{prefix},
ID: prefix,
Name: prefix,
}
mbox, err := mboxFetch(ctx, label)
if err != nil {
if db.IsErrNotFound(err) {
continue
}
return nil, err
}
if mbox.RemoteID != label.ID {
discrepancies = append(discrepancies, newLabelDiscrepancy(label, mbox, discrepancyInternal))
}
}
// Verify system and user mailboxes.
for _, label := range labels {
if !WantLabel(label) {
continue
}
mbox, err := mboxFetch(ctx, label)
if err != nil {
if db.IsErrNotFound(err) {
continue
}
return nil, err
}
if mbox.RemoteID != label.ID {
var dType labelDiscrepancyType
switch label.Type {
case proton.LabelTypeSystem:
dType = discrepancySystem
case proton.LabelTypeFolder, proton.LabelTypeLabel:
dType = discrepancyUser
case proton.LabelTypeContactGroup:
fallthrough
default:
dType = discrepancySystem
}
discrepancies = append(discrepancies, newLabelDiscrepancy(label, mbox, dType))
}
}
return discrepancies, nil
}

View File

@ -21,6 +21,7 @@ import (
"context" "context"
"github.com/ProtonMail/gluon/connector" "github.com/ProtonMail/gluon/connector"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/proton-bridge/v3/internal/services/syncservice" "github.com/ProtonMail/proton-bridge/v3/internal/services/syncservice"
) )
@ -36,6 +37,14 @@ type IMAPServerManager interface {
RemoveIMAPUser(ctx context.Context, deleteData bool, provider GluonIDProvider, addrID ...string) error RemoveIMAPUser(ctx context.Context, deleteData bool, provider GluonIDProvider, addrID ...string) error
LogRemoteLabelIDs(ctx context.Context, provider GluonIDProvider, addrID ...string) error LogRemoteLabelIDs(ctx context.Context, provider GluonIDProvider, addrID ...string) error
GetUserMailboxByName(ctx context.Context, addrID string, mailboxName []string) (imap.MailboxData, error)
GetUserMailboxCountByInternalID(ctx context.Context, addrID string, internalID imap.InternalMailboxID) (int, error)
GetOpenIMAPSessionCount() int
GetRollingIMAPConnectionCount() int
} }
type NullIMAPServerManager struct{} type NullIMAPServerManager struct{}
@ -67,6 +76,22 @@ func (n NullIMAPServerManager) LogRemoteLabelIDs(
return nil return nil
} }
func (n NullIMAPServerManager) GetUserMailboxByName(_ context.Context, _ string, _ []string) (imap.MailboxData, error) {
return imap.MailboxData{}, nil
}
func (n NullIMAPServerManager) GetUserMailboxCountByInternalID(_ context.Context, _ string, _ imap.InternalMailboxID) (int, error) {
return 0, nil
}
func (n NullIMAPServerManager) GetOpenIMAPSessionCount() int {
return 0
}
func (n NullIMAPServerManager) GetRollingIMAPConnectionCount() int {
return 0
}
func NewNullIMAPServerManager() *NullIMAPServerManager { func NewNullIMAPServerManager() *NullIMAPServerManager {
return &NullIMAPServerManager{} return &NullIMAPServerManager{}
} }

View File

@ -36,6 +36,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/services/syncservice" "github.com/ProtonMail/proton-bridge/v3/internal/services/syncservice"
"github.com/ProtonMail/proton-bridge/v3/internal/services/userevents" "github.com/ProtonMail/proton-bridge/v3/internal/services/userevents"
"github.com/ProtonMail/proton-bridge/v3/internal/services/useridentity" "github.com/ProtonMail/proton-bridge/v3/internal/services/useridentity"
"github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/ProtonMail/proton-bridge/v3/internal/usertypes" "github.com/ProtonMail/proton-bridge/v3/internal/usertypes"
"github.com/ProtonMail/proton-bridge/v3/pkg/cpc" "github.com/ProtonMail/proton-bridge/v3/pkg/cpc"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -91,7 +92,9 @@ type Service struct {
lastHandledEventID string lastHandledEventID string
isSyncing atomic.Bool isSyncing atomic.Bool
observabilitySender observability.Sender observabilitySender observability.Sender
labelConflictManager *LabelConflictManager
LabelConflictChecker *LabelConflictChecker
} }
func NewService( func NewService(
@ -112,6 +115,7 @@ func NewService(
maxSyncMemory uint64, maxSyncMemory uint64,
showAllMail bool, showAllMail bool,
observabilitySender observability.Sender, observabilitySender observability.Sender,
featureFlagProvider unleash.FeatureFlagValueProvider,
) *Service { ) *Service {
subscriberName := fmt.Sprintf("imap-%v", identityState.User.ID) subscriberName := fmt.Sprintf("imap-%v", identityState.User.ID)
@ -121,11 +125,12 @@ func NewService(
}) })
rwIdentity := newRWIdentity(identityState, bridgePassProvider, keyPassProvider) rwIdentity := newRWIdentity(identityState, bridgePassProvider, keyPassProvider)
syncUpdateApplier := NewSyncUpdateApplier() labelConflictManager := NewLabelConflictManager(serverManager, gluonIDProvider, client, reporter, featureFlagProvider)
syncUpdateApplier := NewSyncUpdateApplier(labelConflictManager)
syncMessageBuilder := NewSyncMessageBuilder(rwIdentity) syncMessageBuilder := NewSyncMessageBuilder(rwIdentity)
syncReporter := newSyncReporter(identityState.User.ID, eventPublisher, time.Second) syncReporter := newSyncReporter(identityState.User.ID, eventPublisher, time.Second)
return &Service{ service := &Service{
cpc: cpc.NewCPC(), cpc: cpc.NewCPC(),
client: client, client: client,
log: log, log: log,
@ -156,8 +161,12 @@ func NewService(
syncReporter: syncReporter, syncReporter: syncReporter,
syncConfigPath: GetSyncConfigPath(syncConfigDir, identityState.User.ID), syncConfigPath: GetSyncConfigPath(syncConfigDir, identityState.User.ID),
observabilitySender: observabilitySender, observabilitySender: observabilitySender,
labelConflictManager: labelConflictManager,
} }
service.LabelConflictChecker = NewConflictChecker(service, reporter, gluonIDProvider, serverManager)
return service
} }
func (s *Service) Start( func (s *Service) Start(
@ -176,7 +185,14 @@ func (s *Service) Start(
s.syncStateProvider = syncStateProvider s.syncStateProvider = syncStateProvider
} }
s.syncHandler = syncservice.NewHandler(syncRegulator, s.client, s.identityState.UserID(), s.syncStateProvider, s.log, s.panicHandler) s.syncHandler = syncservice.NewHandler(
syncRegulator,
s.client,
s.identityState.UserID(),
s.syncStateProvider,
s.log,
s.panicHandler,
s.reporter)
// Get user labels // Get user labels
apiLabels, err := s.client.GetLabels(ctx, proton.LabelTypeSystem, proton.LabelTypeFolder, proton.LabelTypeLabel) apiLabels, err := s.client.GetLabels(ctx, proton.LabelTypeSystem, proton.LabelTypeFolder, proton.LabelTypeLabel)
@ -524,6 +540,7 @@ func (s *Service) buildConnectors() (map[string]*Connector, error) {
s.reporter, s.reporter,
s.showAllMail, s.showAllMail,
s.syncStateProvider, s.syncStateProvider,
s.serverManager,
) )
return connectors, nil return connectors, nil
@ -541,6 +558,7 @@ func (s *Service) buildConnectors() (map[string]*Connector, error) {
s.reporter, s.reporter,
s.showAllMail, s.showAllMail,
s.syncStateProvider, s.syncStateProvider,
s.serverManager,
) )
} }
@ -651,7 +669,7 @@ func (s *Service) setShowAllMail(v bool) {
func (s *Service) startSyncing() { func (s *Service) startSyncing() {
s.isSyncing.Store(true) s.isSyncing.Store(true)
s.syncHandler.Execute(s.syncReporter, s.labels.GetLabelMap(), s.syncUpdateApplier, s.syncMessageBuilder, syncservice.DefaultRetryCoolDown) s.syncHandler.Execute(s.syncReporter, s.labels.GetLabelMap(), s.syncUpdateApplier, s.syncMessageBuilder, syncservice.DefaultRetryCoolDown, s.LabelConflictChecker)
} }
func (s *Service) cancelSync() { func (s *Service) cancelSync() {
@ -659,6 +677,10 @@ func (s *Service) cancelSync() {
s.isSyncing.Store(false) s.isSyncing.Store(false)
} }
func (s *Service) getConnectors() []*Connector {
return maps.Values(s.connectors)
}
type resyncReq struct{} type resyncReq struct{}
type getLabelsReq struct{} type getLabelsReq struct{}

View File

@ -157,6 +157,7 @@ func addNewAddressSplitMode(ctx context.Context, s *Service, addrID string) erro
s.reporter, s.reporter,
s.showAllMail, s.showAllMail,
s.syncStateProvider, s.syncStateProvider,
s.serverManager,
) )
if err := s.serverManager.AddIMAPUser(ctx, connector, connector.addrID, s.gluonIDProvider, s.syncStateProvider); err != nil { if err := s.serverManager.AddIMAPUser(ctx, connector, connector.addrID, s.gluonIDProvider, s.syncStateProvider); err != nil {
@ -165,7 +166,7 @@ func addNewAddressSplitMode(ctx context.Context, s *Service, addrID string) erro
s.connectors[connector.addrID] = connector s.connectors[connector.addrID] = connector
updates, err := syncLabels(ctx, s.labels.GetLabelMap(), []*Connector{connector}) updates, err := syncLabels(ctx, s.labels.GetLabelMap(), []*Connector{connector}, s.labelConflictManager)
if err != nil { if err != nil {
return fmt.Errorf("failed to create labels updates for new address: %w", err) return fmt.Errorf("failed to create labels updates for new address: %w", err)
} }

View File

@ -42,7 +42,10 @@ func (s *Service) HandleLabelEvents(ctx context.Context, events []proton.LabelEv
continue continue
} }
updates := onLabelCreated(ctx, s, event) updates, err := onLabelCreated(ctx, s, event)
if err != nil {
return fmt.Errorf("failed to handle create label event: %w", err)
}
if err := waitOnIMAPUpdates(ctx, updates); err != nil { if err := waitOnIMAPUpdates(ctx, updates); err != nil {
return err return err
@ -74,8 +77,8 @@ func (s *Service) HandleLabelEvents(ctx context.Context, events []proton.LabelEv
return nil return nil
} }
func onLabelCreated(ctx context.Context, s *Service, event proton.LabelEvent) []imap.Update { func onLabelCreated(ctx context.Context, s *Service, event proton.LabelEvent) ([]imap.Update, error) {
updates := make([]imap.Update, 0, len(s.connectors)) updates := []imap.Update{}
s.log.WithFields(logrus.Fields{ s.log.WithFields(logrus.Fields{
"labelID": event.ID, "labelID": event.ID,
@ -87,7 +90,17 @@ func onLabelCreated(ctx context.Context, s *Service, event proton.LabelEvent) []
wr.SetLabel(event.Label.ID, event.Label, "onLabelCreated") wr.SetLabel(event.Label.ID, event.Label, "onLabelCreated")
labelConflictResolver := s.labelConflictManager.NewConflictResolver(maps.Values(s.connectors))
conflictUpdatesGenerator, err := labelConflictResolver.ResolveConflict(ctx, event.Label, make(map[string]bool))
if err != nil {
return updates, err
}
for _, updateCh := range maps.Values(s.connectors) { for _, updateCh := range maps.Values(s.connectors) {
conflictUpdates := conflictUpdatesGenerator()
updateCh.publishUpdate(ctx, conflictUpdates...)
updates = append(updates, conflictUpdates...)
update := newMailboxCreatedUpdate(imap.MailboxID(event.ID), GetMailboxName(event.Label)) update := newMailboxCreatedUpdate(imap.MailboxID(event.ID), GetMailboxName(event.Label))
updateCh.publishUpdate(ctx, update) updateCh.publishUpdate(ctx, update)
updates = append(updates, update) updates = append(updates, update)
@ -99,7 +112,7 @@ func onLabelCreated(ctx context.Context, s *Service, event proton.LabelEvent) []
Name: event.Label.Name, Name: event.Label.Name,
}) })
return updates return updates, nil
} }
func onLabelUpdated(ctx context.Context, s *Service, event proton.LabelEvent) ([]imap.Update, error) { func onLabelUpdated(ctx context.Context, s *Service, event proton.LabelEvent) ([]imap.Update, error) {
@ -136,8 +149,19 @@ func onLabelUpdated(ctx context.Context, s *Service, event proton.LabelEvent) ([
// Update the label in the map. // Update the label in the map.
wr.SetLabel(apiLabel.ID, apiLabel, "onLabelUpdatedApiID") wr.SetLabel(apiLabel.ID, apiLabel, "onLabelUpdatedApiID")
// Resolve potential conflicts
labelConflictResolver := s.labelConflictManager.NewConflictResolver(maps.Values(s.connectors))
conflictUpdatesGenerator, err := labelConflictResolver.ResolveConflict(ctx, apiLabel, make(map[string]bool))
if err != nil {
return updates, err
}
// Notify the IMAP clients. // Notify the IMAP clients.
for _, updateCh := range maps.Values(s.connectors) { for _, updateCh := range maps.Values(s.connectors) {
conflictUpdates := conflictUpdatesGenerator()
updateCh.publishUpdate(ctx, conflictUpdates...)
updates = append(updates, conflictUpdates...)
update := imap.NewMailboxUpdated( update := imap.NewMailboxUpdated(
imap.MailboxID(apiLabel.ID), imap.MailboxID(apiLabel.ID),
GetMailboxName(apiLabel), GetMailboxName(apiLabel),

View File

@ -31,8 +31,9 @@ import (
) )
type SyncUpdateApplier struct { type SyncUpdateApplier struct {
requestCh chan updateRequest requestCh chan updateRequest
replyCh chan updateReply replyCh chan updateReply
labelConflictManager *LabelConflictManager
} }
type updateReply struct { type updateReply struct {
@ -42,10 +43,11 @@ type updateReply struct {
type updateRequest = func(ctx context.Context, mode usertypes.AddressMode, connectors map[string]*Connector) ([]imap.Update, error) type updateRequest = func(ctx context.Context, mode usertypes.AddressMode, connectors map[string]*Connector) ([]imap.Update, error)
func NewSyncUpdateApplier() *SyncUpdateApplier { func NewSyncUpdateApplier(labelConflictManager *LabelConflictManager) *SyncUpdateApplier {
return &SyncUpdateApplier{ return &SyncUpdateApplier{
requestCh: make(chan updateRequest), requestCh: make(chan updateRequest),
replyCh: make(chan updateReply), replyCh: make(chan updateReply),
labelConflictManager: labelConflictManager,
} }
} }
@ -113,7 +115,7 @@ func (s *SyncUpdateApplier) ApplySyncUpdates(ctx context.Context, updates []sync
func (s *SyncUpdateApplier) SyncLabels(ctx context.Context, labels map[string]proton.Label) error { func (s *SyncUpdateApplier) SyncLabels(ctx context.Context, labels map[string]proton.Label) error {
request := func(ctx context.Context, _ usertypes.AddressMode, connectors map[string]*Connector) ([]imap.Update, error) { request := func(ctx context.Context, _ usertypes.AddressMode, connectors map[string]*Connector) ([]imap.Update, error) {
return syncLabels(ctx, labels, maps.Values(connectors)) return syncLabels(ctx, labels, maps.Values(connectors), s.labelConflictManager)
} }
updates, err := s.sendRequest(ctx, request) updates, err := s.sendRequest(ctx, request)
@ -128,15 +130,34 @@ func (s *SyncUpdateApplier) SyncLabels(ctx context.Context, labels map[string]pr
} }
// nolint:exhaustive // nolint:exhaustive
func syncLabels(ctx context.Context, labels map[string]proton.Label, connectors []*Connector) ([]imap.Update, error) { func syncLabels(ctx context.Context, labels map[string]proton.Label, connectors []*Connector, labelConflictManager *LabelConflictManager) ([]imap.Update, error) {
var updates []imap.Update var updates []imap.Update
userLabelConflictResolver := labelConflictManager.NewConflictResolver(connectors)
internalLabelConflictResolver := labelConflictManager.NewInternalLabelConflictResolver(connectors)
conflictUpdateGenerator, err := internalLabelConflictResolver.ResolveConflict(ctx, labels)
if err != nil {
return updates, err
}
for _, updateCh := range connectors {
conflictUpdates := conflictUpdateGenerator()
updateCh.publishUpdate(ctx, conflictUpdates...)
updates = append(updates, conflictUpdates...)
}
// Create placeholder Folders/Labels mailboxes with the \Noselect attribute. // Create placeholder Folders/Labels mailboxes with the \Noselect attribute.
for _, prefix := range []string{folderPrefix, labelPrefix} { for _, prefix := range []string{folderPrefix, labelPrefix} {
for _, updateCh := range connectors { for _, updateCh := range connectors {
update := newPlaceHolderMailboxCreatedUpdate(prefix) update := newPlaceHolderMailboxCreatedUpdate(prefix)
updateCh.publishUpdate(ctx, update) updateCh.publishUpdate(ctx, update)
updates = append(updates, update) updates = append(updates, update)
// Ensure we perform a rename operation as well. The created event won't update the name if the ID exists.
renameUpdate := imap.NewMailboxUpdated(imap.MailboxID(prefix), []string{prefix})
updateCh.publishUpdate(ctx, renameUpdate)
updates = append(updates, renameUpdate)
} }
} }
@ -155,7 +176,16 @@ func syncLabels(ctx context.Context, labels map[string]proton.Label, connectors
} }
case proton.LabelTypeFolder, proton.LabelTypeLabel: case proton.LabelTypeFolder, proton.LabelTypeLabel:
conflictUpdatesGenerator, err := userLabelConflictResolver.ResolveConflict(ctx, label, make(map[string]bool))
if err != nil {
return updates, err
}
for _, updateCh := range connectors { for _, updateCh := range connectors {
conflictUpdates := conflictUpdatesGenerator()
updateCh.publishUpdate(ctx, conflictUpdates...)
updates = append(updates, conflictUpdates...)
update := newMailboxCreatedUpdate(imap.MailboxID(labelID), GetMailboxName(label)) update := newMailboxCreatedUpdate(imap.MailboxID(labelID), GetMailboxName(label))
updateCh.publishUpdate(ctx, update) updateCh.publishUpdate(ctx, update)
updates = append(updates, update) updates = append(updates, update)

View File

@ -24,6 +24,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"time"
"github.com/Masterminds/semver/v3" "github.com/Masterminds/semver/v3"
"github.com/ProtonMail/gluon" "github.com/ProtonMail/gluon"
@ -37,9 +38,16 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/files" "github.com/ProtonMail/proton-bridge/v3/internal/files"
"github.com/ProtonMail/proton-bridge/v3/internal/logging" "github.com/ProtonMail/proton-bridge/v3/internal/logging"
"github.com/ProtonMail/proton-bridge/v3/internal/services/observability" "github.com/ProtonMail/proton-bridge/v3/internal/services/observability"
"github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
const (
rollingCounterNewConnectionThreshold = 300
rollingCounterNumberOfBuckets = 6
rollingCounterBucketRotationInterval = time.Second * 10
)
var logIMAP = logrus.WithField("pkg", "server/imap") //nolint:gochecknoglobals var logIMAP = logrus.WithField("pkg", "server/imap") //nolint:gochecknoglobals
type IMAPSettingsProvider interface { type IMAPSettingsProvider interface {
@ -81,6 +89,7 @@ func newIMAPServer(
uidValidityGenerator imap.UIDValidityGenerator, uidValidityGenerator imap.UIDValidityGenerator,
panicHandler async.PanicHandler, panicHandler async.PanicHandler,
observabilitySender observability.Sender, observabilitySender observability.Sender,
featureFlagProvider unleash.FeatureFlagValueProvider,
) (*gluon.Server, error) { ) (*gluon.Server, error) {
gluonCacheDir = ApplyGluonCachePathSuffix(gluonCacheDir) gluonCacheDir = ApplyGluonCachePathSuffix(gluonCacheDir)
gluonConfigDir = ApplyGluonConfigPathSuffix(gluonConfigDir) gluonConfigDir = ApplyGluonConfigPathSuffix(gluonConfigDir)
@ -126,6 +135,8 @@ func newIMAPServer(
gluon.WithUIDValidityGenerator(uidValidityGenerator), gluon.WithUIDValidityGenerator(uidValidityGenerator),
gluon.WithPanicHandler(panicHandler), gluon.WithPanicHandler(panicHandler),
gluon.WithObservabilitySender(observability.NewAdapter(observabilitySender), int(observability.GluonImapError), int(observability.GluonMessageError), int(observability.GluonOtherError)), gluon.WithObservabilitySender(observability.NewAdapter(observabilitySender), int(observability.GluonImapError), int(observability.GluonMessageError), int(observability.GluonOtherError)),
gluon.WithConnectionRollingCounter(rollingCounterNewConnectionThreshold, rollingCounterNumberOfBuckets, rollingCounterBucketRotationInterval),
gluon.WithFeatureFlagProvider(featureFlagProvider),
} }
if disableIMAPAuthenticate { if disableIMAPAuthenticate {

View File

@ -34,6 +34,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/services/observability" "github.com/ProtonMail/proton-bridge/v3/internal/services/observability"
bridgesmtp "github.com/ProtonMail/proton-bridge/v3/internal/services/smtp" bridgesmtp "github.com/ProtonMail/proton-bridge/v3/internal/services/smtp"
"github.com/ProtonMail/proton-bridge/v3/internal/services/syncservice" "github.com/ProtonMail/proton-bridge/v3/internal/services/syncservice"
"github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/ProtonMail/proton-bridge/v3/pkg/cpc" "github.com/ProtonMail/proton-bridge/v3/pkg/cpc"
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -63,6 +64,7 @@ type Service struct {
telemetry Telemetry telemetry Telemetry
observabilitySender observability.Sender observabilitySender observability.Sender
featureFlagProvider unleash.FeatureFlagValueProvider
} }
func NewService( func NewService(
@ -75,6 +77,7 @@ func NewService(
uidValidityGenerator imap.UIDValidityGenerator, uidValidityGenerator imap.UIDValidityGenerator,
telemetry Telemetry, telemetry Telemetry,
observabilitySender observability.Sender, observabilitySender observability.Sender,
featureFlagProvider unleash.FeatureFlagValueProvider,
) *Service { ) *Service {
return &Service{ return &Service{
requests: cpc.NewCPC(), requests: cpc.NewCPC(),
@ -91,6 +94,7 @@ func NewService(
telemetry: telemetry, telemetry: telemetry,
observabilitySender: observabilitySender, observabilitySender: observabilitySender,
featureFlagProvider: featureFlagProvider,
} }
} }
@ -200,6 +204,22 @@ func (sm *Service) RemoveSMTPAccount(ctx context.Context, service *bridgesmtp.Se
return err return err
} }
func (sm *Service) GetUserMailboxByName(ctx context.Context, addrID string, mailboxName []string) (imap.MailboxData, error) {
return sm.imapServer.GetUserMailboxByName(ctx, addrID, mailboxName)
}
func (sm *Service) GetUserMailboxCountByInternalID(ctx context.Context, addrID string, internalID imap.InternalMailboxID) (int, error) {
return sm.imapServer.GetUserMailboxCountByInternalID(ctx, addrID, internalID)
}
func (sm *Service) GetOpenIMAPSessionCount() int {
return sm.imapServer.GetOpenSessionCount()
}
func (sm *Service) GetRollingIMAPConnectionCount() int {
return sm.imapServer.GetRollingIMAPConnectionCount()
}
func (sm *Service) run(ctx context.Context, subscription events.Subscription) { func (sm *Service) run(ctx context.Context, subscription events.Subscription) {
eventSub := subscription.Add() eventSub := subscription.Add()
defer subscription.Remove(eventSub) defer subscription.Remove(eventSub)
@ -498,6 +518,7 @@ func (sm *Service) createIMAPServer(ctx context.Context) (*gluon.Server, error)
sm.uidValidityGenerator, sm.uidValidityGenerator,
sm.panicHandler, sm.panicHandler,
sm.observabilitySender, sm.observabilitySender,
sm.featureFlagProvider,
) )
if err == nil { if err == nil {
sm.eventPublisher.PublishEvent(ctx, events.IMAPServerCreated{}) sm.eventPublisher.PublishEvent(ctx, events.IMAPServerCreated{})

View File

@ -44,7 +44,7 @@ type Service struct {
store *Store store *Store
getFlagValueFn unleash.GetFlagValueFn featureFlagValueProvider unleash.FeatureFlagValueProvider
observabilitySender observability.Sender observabilitySender observability.Sender
} }
@ -52,7 +52,7 @@ type Service struct {
const bitfieldRegexPattern = `^\\\d+` const bitfieldRegexPattern = `^\\\d+`
func NewService(userID string, service userevents.Subscribable, eventPublisher events.EventPublisher, store *Store, func NewService(userID string, service userevents.Subscribable, eventPublisher events.EventPublisher, store *Store,
getFlagFn unleash.GetFlagValueFn, observabilitySender observability.Sender) *Service { featureFlagValueProvider unleash.FeatureFlagValueProvider, observabilitySender observability.Sender) *Service {
return &Service{ return &Service{
userID: userID, userID: userID,
@ -68,8 +68,8 @@ func NewService(userID string, service userevents.Subscribable, eventPublisher e
store: store, store: store,
getFlagValueFn: getFlagFn, featureFlagValueProvider: featureFlagValueProvider,
observabilitySender: observabilitySender, observabilitySender: observabilitySender,
} }
} }
@ -102,7 +102,7 @@ func (s *Service) run(ctx context.Context) {
} }
func (s *Service) HandleNotificationEvents(ctx context.Context, notificationEvents []proton.NotificationEvent) error { func (s *Service) HandleNotificationEvents(ctx context.Context, notificationEvents []proton.NotificationEvent) error {
if s.getFlagValueFn(unleash.EventLoopNotificationDisabled) { if s.featureFlagValueProvider.GetFlagValue(unleash.EventLoopNotificationDisabled) {
s.log.Info("Received notification events. Skipping as kill switch is enabled.") s.log.Info("Received notification events. Skipping as kill switch is enabled.")
return nil return nil
} }

View File

@ -19,6 +19,7 @@ package observability
import ( import (
"github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/services/observability/gluonmetrics"
) )
type Adapter struct { type Adapter struct {
@ -88,6 +89,15 @@ func (adapter *Adapter) AddDistinctMetrics(errType interface{}, metrics ...map[s
} }
if len(typedMetrics) > 0 { if len(typedMetrics) > 0 {
adapter.sender.AddDistinctMetrics(DistinctionErrorTypeEnum(errTypeInt), typedMetrics...) adapter.sender.AddDistinctMetrics(DistinctionMetricTypeEnum(errTypeInt), typedMetrics...)
} }
} }
func (adapter *Adapter) AddIMAPConnectionsExceededThresholdMetric(totalOpenIMAPConnections, newIMAPConnections int) {
metric := gluonmetrics.GenerateNewOpenedIMAPConnectionsExceedThreshold(
adapter.sender.GetEmailClient(),
BucketIMAPConnections(totalOpenIMAPConnections),
BucketIMAPConnections(newIMAPConnections))
adapter.sender.AddTimeLimitedMetric(NewIMAPConnectionsExceedThreshold, metric)
}

View File

@ -19,21 +19,22 @@ package observability
import "time" import "time"
// DistinctionErrorTypeEnum - maps to the specific error schema for which we // DistinctionMetricTypeEnum - used to distinct specific metrics which we want to limit over some interval.
// want to send a user update. // Most enums are tied to a specific error schema for which we also send a specific distinction user update.
type DistinctionErrorTypeEnum int type DistinctionMetricTypeEnum int
const ( const (
SyncError DistinctionErrorTypeEnum = iota SyncError DistinctionMetricTypeEnum = iota
GluonImapError GluonImapError
GluonMessageError GluonMessageError
GluonOtherError GluonOtherError
SMTPError SMTPError
EventLoopError // EventLoopError - should always be kept last when inserting new keys. EventLoopError // EventLoopError - should always be kept last when inserting new keys.
NewIMAPConnectionsExceedThreshold
) )
// errorSchemaMap - maps between the DistinctionErrorTypeEnum and the relevant schema name. // errorSchemaMap - maps between some DistinctionMetricTypeEnum and the relevant schema name.
var errorSchemaMap = map[DistinctionErrorTypeEnum]string{ //nolint:gochecknoglobals var errorSchemaMap = map[DistinctionMetricTypeEnum]string{ //nolint:gochecknoglobals
SyncError: "bridge_sync_errors_users_total", SyncError: "bridge_sync_errors_users_total",
EventLoopError: "bridge_event_loop_events_errors_users_total", EventLoopError: "bridge_event_loop_events_errors_users_total",
GluonImapError: "bridge_gluon_imap_errors_users_total", GluonImapError: "bridge_gluon_imap_errors_users_total",
@ -43,9 +44,9 @@ var errorSchemaMap = map[DistinctionErrorTypeEnum]string{ //nolint:gochecknoglob
} }
// createLastSentMap - needs to be updated whenever we make changes to the enum. // createLastSentMap - needs to be updated whenever we make changes to the enum.
func createLastSentMap() map[DistinctionErrorTypeEnum]time.Time { func createLastSentMap() map[DistinctionMetricTypeEnum]time.Time {
registerTime := time.Now().Add(-updateInterval) registerTime := time.Now().Add(-updateInterval)
lastSentMap := make(map[DistinctionErrorTypeEnum]time.Time) lastSentMap := make(map[DistinctionMetricTypeEnum]time.Time)
for errType := SyncError; errType <= EventLoopError; errType++ { for errType := SyncError; errType <= EventLoopError; errType++ {
lastSentMap[errType] = registerTime lastSentMap[errType] = registerTime

View File

@ -40,7 +40,7 @@ type distinctionUtility struct {
panicHandler async.PanicHandler panicHandler async.PanicHandler
lastSentMap map[DistinctionErrorTypeEnum]time.Time // Ensures we don't step over the limit of one user update every 5 mins. lastSentMap map[DistinctionMetricTypeEnum]time.Time // Ensures we don't step over the limit of one user update every 5 mins.
observabilitySender observabilitySender observabilitySender observabilitySender
settingsGetter settingsGetter settingsGetter settingsGetter
@ -87,7 +87,7 @@ func (d *distinctionUtility) setSettingsGetter(getter settingsGetter) {
// checkAndUpdateLastSentMap - checks whether we have sent a relevant user update metric // checkAndUpdateLastSentMap - checks whether we have sent a relevant user update metric
// within the last 5 minutes. // within the last 5 minutes.
func (d *distinctionUtility) checkAndUpdateLastSentMap(key DistinctionErrorTypeEnum) bool { func (d *distinctionUtility) checkAndUpdateLastSentMap(key DistinctionMetricTypeEnum) bool {
curTime := time.Now() curTime := time.Now()
val, ok := d.lastSentMap[key] val, ok := d.lastSentMap[key]
if !ok { if !ok {
@ -107,7 +107,7 @@ func (d *distinctionUtility) checkAndUpdateLastSentMap(key DistinctionErrorTypeE
// and the relevant settings. In the future this will need to be expanded to support multiple // and the relevant settings. In the future this will need to be expanded to support multiple
// versions of the metric if we ever decide to change them. // versions of the metric if we ever decide to change them.
func (d *distinctionUtility) generateUserMetric( func (d *distinctionUtility) generateUserMetric(
metricType DistinctionErrorTypeEnum, metricType DistinctionMetricTypeEnum,
) proton.ObservabilityMetric { ) proton.ObservabilityMetric {
schemaName, ok := errorSchemaMap[metricType] schemaName, ok := errorSchemaMap[metricType]
if !ok { if !ok {
@ -138,7 +138,7 @@ func generateUserMetric(schemaName, plan, mailClient, dohEnabled, betaAccess str
} }
} }
func (d *distinctionUtility) generateDistinctMetrics(errType DistinctionErrorTypeEnum, metrics ...proton.ObservabilityMetric) []proton.ObservabilityMetric { func (d *distinctionUtility) generateDistinctMetrics(errType DistinctionMetricTypeEnum, metrics ...proton.ObservabilityMetric) []proton.ObservabilityMetric {
d.updateHeartbeatData(errType) d.updateHeartbeatData(errType)
if d.checkAndUpdateLastSentMap(errType) { if d.checkAndUpdateLastSentMap(errType) {

View File

@ -0,0 +1,45 @@
// Copyright (c) 2025 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 gluonmetrics
import (
"time"
"github.com/ProtonMail/go-proton-api"
)
const (
newIMAPConnectionThresholdExceededSchemaName = "bridge_imap_recently_opened_connections_total"
newIMAPConnectionThresholdExceededVersion = 1
)
func GenerateNewOpenedIMAPConnectionsExceedThreshold(emailClient, totalOpenIMAPConnectionCount, newlyOpenedIMAPConnectionCount string) proton.ObservabilityMetric {
return proton.ObservabilityMetric{
Name: newIMAPConnectionThresholdExceededSchemaName,
Version: newIMAPConnectionThresholdExceededVersion,
Timestamp: time.Now().Unix(),
Data: map[string]interface{}{
"Value": 1,
"Labels": map[string]string{
"mailClient": emailClient,
"numberOfOpenIMAPConnectionsBuckets": totalOpenIMAPConnectionCount,
"numberOfRecentlyOpenedIMAPConnectionsBuckets": newlyOpenedIMAPConnectionCount,
},
},
}
}

View File

@ -42,7 +42,7 @@ func (d *distinctionUtility) resetHeartbeatData() {
d.heartbeatData.receivedGluonError = false d.heartbeatData.receivedGluonError = false
} }
func (d *distinctionUtility) updateHeartbeatData(errType DistinctionErrorTypeEnum) { func (d *distinctionUtility) updateHeartbeatData(errType DistinctionMetricTypeEnum) {
d.withUpdateHeartbeatDataLock(func() { d.withUpdateHeartbeatDataLock(func() {
//nolint:exhaustive //nolint:exhaustive
switch errType { switch errType {

View File

@ -45,7 +45,9 @@ type client struct {
// so we can easily pass them down to relevant components. // so we can easily pass them down to relevant components.
type Sender interface { type Sender interface {
AddMetrics(metrics ...proton.ObservabilityMetric) AddMetrics(metrics ...proton.ObservabilityMetric)
AddDistinctMetrics(errType DistinctionErrorTypeEnum, metrics ...proton.ObservabilityMetric) AddDistinctMetrics(errType DistinctionMetricTypeEnum, metrics ...proton.ObservabilityMetric)
AddTimeLimitedMetric(metricType DistinctionMetricTypeEnum, metric proton.ObservabilityMetric)
GetEmailClient() string
} }
type Service struct { type Service struct {
@ -325,11 +327,25 @@ func (s *Service) AddMetrics(metrics ...proton.ObservabilityMetric) {
// what number of events come from what number of users. // what number of events come from what number of users.
// As the binning interval is what allows us to do this we // As the binning interval is what allows us to do this we
// should not send these if there are no logged-in users at that moment. // should not send these if there are no logged-in users at that moment.
func (s *Service) AddDistinctMetrics(errType DistinctionErrorTypeEnum, metrics ...proton.ObservabilityMetric) { func (s *Service) AddDistinctMetrics(errType DistinctionMetricTypeEnum, metrics ...proton.ObservabilityMetric) {
metrics = s.distinctionUtility.generateDistinctMetrics(errType, metrics...) metrics = s.distinctionUtility.generateDistinctMetrics(errType, metrics...)
s.addMetricsIfClients(metrics...) s.addMetricsIfClients(metrics...)
} }
// AddTimeLimitedMetric - schedules a metric to be sent if a metric of the same type has not been sent within some interval.
// The interval is defined in the distinction utility.
func (s *Service) AddTimeLimitedMetric(metricType DistinctionMetricTypeEnum, metric proton.ObservabilityMetric) {
if !s.distinctionUtility.checkAndUpdateLastSentMap(metricType) {
return
}
s.addMetricsIfClients(metric)
}
func (s *Service) GetEmailClient() string {
return s.distinctionUtility.getEmailClientUserAgent()
}
// ModifyHeartbeatInterval - should only be used for testing. Resets the heartbeat ticker. // ModifyHeartbeatInterval - should only be used for testing. Resets the heartbeat ticker.
func (s *Service) ModifyHeartbeatInterval(duration time.Duration) { func (s *Service) ModifyHeartbeatInterval(duration time.Duration) {
s.distinctionUtility.heartbeatTicker.Reset(duration) s.distinctionUtility.heartbeatTicker.Reset(duration)

View File

@ -66,3 +66,30 @@ func getEnabled(value bool) string {
} }
return "enabled" return "enabled"
} }
func BucketIMAPConnections(val int) string {
switch {
case val < 10:
return "<10"
case val < 25:
return "10-24"
case val < 50:
return "25-49"
case val < 100:
return "50-99"
case val < 200:
return "100-199"
case val < 300:
return "200-299"
case val < 500:
return "300-499"
case val < 1000:
return "500-999"
case val < 2000:
return "1000-1999"
case val < 3000:
return "2000-2999"
default:
return "3000+"
}
}

View File

@ -21,6 +21,7 @@ import (
"time" "time"
"github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/services/observability"
) )
const ( const (
@ -29,6 +30,9 @@ const (
smtpSendSuccessSchemaName = "bridge_smtp_send_success_total" smtpSendSuccessSchemaName = "bridge_smtp_send_success_total"
smtpSendSuccessSchemaVersion = 1 smtpSendSuccessSchemaVersion = 1
smtpSubmissionRequestSchemaName = "bridge_smtp_send_request_total"
smtpSubmissionRequestSchemaVersion = 1
) )
func generateSMTPErrorObservabilityMetric(errorType string) proton.ObservabilityMetric { func generateSMTPErrorObservabilityMetric(errorType string) proton.ObservabilityMetric {
@ -88,3 +92,19 @@ func GenerateSMTPSendSuccess() proton.ObservabilityMetric {
}, },
} }
} }
func GenerateSMTPSubmissionRequest(emailClient string, numberOfOpenIMAPConnections, numberOfRecentlyOpenedIMAPConnections int) proton.ObservabilityMetric {
return proton.ObservabilityMetric{
Name: smtpSubmissionRequestSchemaName,
Version: smtpSubmissionRequestSchemaVersion,
Timestamp: time.Now().Unix(),
Data: map[string]interface{}{
"Value": 1,
"Labels": map[string]string{
"numberOfOpenIMAPConnections": observability.BucketIMAPConnections(numberOfOpenIMAPConnections),
"numberOfRecentlyOpenedIMAPConnections": observability.BucketIMAPConnections(numberOfRecentlyOpenedIMAPConnections),
"mailClient": emailClient,
},
},
}
}

View File

@ -32,13 +32,24 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/services/observability" "github.com/ProtonMail/proton-bridge/v3/internal/services/observability"
"github.com/ProtonMail/proton-bridge/v3/internal/services/orderedtasks" "github.com/ProtonMail/proton-bridge/v3/internal/services/orderedtasks"
"github.com/ProtonMail/proton-bridge/v3/internal/services/sendrecorder" "github.com/ProtonMail/proton-bridge/v3/internal/services/sendrecorder"
"github.com/ProtonMail/proton-bridge/v3/internal/services/smtp/observabilitymetrics"
"github.com/ProtonMail/proton-bridge/v3/internal/services/userevents" "github.com/ProtonMail/proton-bridge/v3/internal/services/userevents"
"github.com/ProtonMail/proton-bridge/v3/internal/services/useridentity" "github.com/ProtonMail/proton-bridge/v3/internal/services/useridentity"
"github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/ProtonMail/proton-bridge/v3/internal/usertypes" "github.com/ProtonMail/proton-bridge/v3/internal/usertypes"
"github.com/ProtonMail/proton-bridge/v3/pkg/cpc" "github.com/ProtonMail/proton-bridge/v3/pkg/cpc"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
const (
newlyOpenedIMAPConnectionsThreshold = 300
)
type imapSessionCountProvider interface {
GetOpenIMAPSessionCount() int
GetRollingIMAPConnectionCount() int
}
type Service struct { type Service struct {
userID string userID string
panicHandler async.PanicHandler panicHandler async.PanicHandler
@ -59,6 +70,9 @@ type Service struct {
serverManager ServerManager serverManager ServerManager
observabilitySender observability.Sender observabilitySender observability.Sender
imapSessionCountProvider imapSessionCountProvider
featureFlagValueProvider unleash.FeatureFlagValueProvider
} }
func NewService( func NewService(
@ -74,6 +88,8 @@ func NewService(
identityState *useridentity.State, identityState *useridentity.State,
serverManager ServerManager, serverManager ServerManager,
observabilitySender observability.Sender, observabilitySender observability.Sender,
imapSessionCountProvider imapSessionCountProvider,
featureFlagValueProvider unleash.FeatureFlagValueProvider,
) *Service { ) *Service {
subscriberName := fmt.Sprintf("smpt-%v", userID) subscriberName := fmt.Sprintf("smpt-%v", userID)
@ -99,7 +115,9 @@ func NewService(
addressMode: mode, addressMode: mode,
serverManager: serverManager, serverManager: serverManager,
observabilitySender: observabilitySender, imapSessionCountProvider: imapSessionCountProvider,
observabilitySender: observabilitySender,
featureFlagValueProvider: featureFlagValueProvider,
} }
} }
@ -207,7 +225,6 @@ func (s *Service) run(ctx context.Context) {
switch r := request.Value().(type) { switch r := request.Value().(type) {
case *sendMailReq: case *sendMailReq:
s.log.Debug("Received send mail request")
err := s.sendMail(ctx, r) err := s.sendMail(ctx, r)
request.Reply(ctx, nil, err) request.Reply(ctx, nil, err)
@ -252,15 +269,38 @@ type sendMailReq struct {
func (s *Service) sendMail(ctx context.Context, req *sendMailReq) error { func (s *Service) sendMail(ctx context.Context, req *sendMailReq) error {
defer async.HandlePanic(s.panicHandler) defer async.HandlePanic(s.panicHandler)
openSessionCount := s.imapSessionCountProvider.GetOpenIMAPSessionCount()
newlyOpenedSessions := s.imapSessionCountProvider.GetRollingIMAPConnectionCount()
log := s.log.WithFields(logrus.Fields{
"newlyOpenedIMAPConnectionsCount": newlyOpenedSessions,
"openIMAPConnectionsCount": openSessionCount,
})
log.Debug("Received send mail request")
// Send SMTP send request metric to observability.
s.observabilitySender.AddMetrics(observabilitymetrics.GenerateSMTPSubmissionRequest(s.observabilitySender.GetEmailClient(), openSessionCount, newlyOpenedSessions))
// Send report to sentry if kill switch is disabled & number of newly opened IMAP connections exceed threshold.
if !s.featureFlagValueProvider.GetFlagValue(unleash.SMTPSubmissionRequestSentryReportDisabled) && newlyOpenedSessions >= newlyOpenedIMAPConnectionsThreshold {
if err := s.reporter.ReportMessageWithContext("SMTP Send Mail Request - newly opened IMAP connections exceed threshold", reporter.Context{
"newlyOpenedIMAPConnectionsCount": newlyOpenedSessions,
"openIMAPConnectionsCount": openSessionCount,
"emailClient": s.observabilitySender.GetEmailClient(),
}); err != nil {
s.log.WithError(err).Error("Failed to submit report to sentry (SMTP Send Mail Request)")
}
}
start := time.Now() start := time.Now()
defer func() { defer func() {
end := time.Now() end := time.Now()
s.log.Debugf("Send mail request finished in %v", end.Sub(start)) log.Debugf("Send mail request finished in %v", end.Sub(start))
}() }()
if err := s.smtpSendMail(ctx, req.authID, req.from, req.to, req.r); err != nil { if err := s.smtpSendMail(ctx, req.authID, req.from, req.to, req.r); err != nil {
if apiErr := new(proton.APIError); errors.As(err, &apiErr) { if apiErr := new(proton.APIError); errors.As(err, &apiErr) {
s.log.WithError(apiErr).WithField("Details", apiErr.DetailsToString()).Error("failed to send message") log.WithError(apiErr).WithField("Details", apiErr.DetailsToString()).Error("failed to send message")
} }
return err return err

View File

@ -19,10 +19,13 @@ package syncservice
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"time" "time"
"github.com/ProtonMail/gluon/async" "github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/gluon/db"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/network" "github.com/ProtonMail/proton-bridge/v3/internal/network"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -33,6 +36,10 @@ const NumSyncStages = 4
type LabelMap = map[string]proton.Label type LabelMap = map[string]proton.Label
type labelConflictChecker interface {
CheckAndReportConflicts(ctx context.Context, labels map[string]proton.Label) error
}
// Handler is the interface from which we control the syncing of the IMAP data. One instance should be created for each // Handler is the interface from which we control the syncing of the IMAP data. One instance should be created for each
// user and used for every subsequent sync request. // user and used for every subsequent sync request.
type Handler struct { type Handler struct {
@ -45,6 +52,7 @@ type Handler struct {
syncFinishedCh chan error syncFinishedCh chan error
panicHandler async.PanicHandler panicHandler async.PanicHandler
downloadCache *DownloadCache downloadCache *DownloadCache
sentryReporter reporter.Reporter
} }
func NewHandler( func NewHandler(
@ -54,6 +62,7 @@ func NewHandler(
state StateProvider, state StateProvider,
log *logrus.Entry, log *logrus.Entry,
panicHandler async.PanicHandler, panicHandler async.PanicHandler,
sentryReporter reporter.Reporter,
) *Handler { ) *Handler {
return &Handler{ return &Handler{
client: client, client: client,
@ -65,6 +74,7 @@ func NewHandler(
regulator: regulator, regulator: regulator,
panicHandler: panicHandler, panicHandler: panicHandler,
downloadCache: newDownloadCache(), downloadCache: newDownloadCache(),
sentryReporter: sentryReporter,
} }
} }
@ -91,12 +101,17 @@ func (t *Handler) Execute(
updateApplier UpdateApplier, updateApplier UpdateApplier,
messageBuilder MessageBuilder, messageBuilder MessageBuilder,
coolDown time.Duration, coolDown time.Duration,
labelConflictChecker labelConflictChecker,
) { ) {
t.log.Info("Sync triggered") t.log.Info("Sync triggered")
t.group.Once(func(ctx context.Context) { t.group.Once(func(ctx context.Context) {
start := time.Now() start := time.Now()
t.log.WithField("start", start).Info("Beginning user sync") t.log.WithField("start", start).Info("Beginning user sync")
if err := labelConflictChecker.CheckAndReportConflicts(ctx, labels); err != nil {
t.log.WithError(err).Error("Failed to check and report label conflicts")
}
syncReporter.OnStart(ctx) syncReporter.OnStart(ctx)
var err error var err error
for { for {
@ -104,6 +119,20 @@ func (t *Handler) Execute(
t.log.WithError(err).Error("Sync aborted") t.log.WithError(err).Error("Sync aborted")
break break
} else if err = t.run(ctx, syncReporter, labels, updateApplier, messageBuilder); err != nil { } else if err = t.run(ctx, syncReporter, labels, updateApplier, messageBuilder); err != nil {
if db.IsUniqueLabelConstraintError(err) {
if sentryErr := t.sentryReporter.ReportMessageWithContext("Failed to sync due to label unique constraint conflict",
reporter.Context{"err": err}); sentryErr != nil {
t.log.WithError(sentryErr).Error("Failed to report label unique constraint conflict error to Sentry")
}
} else if !(errors.Is(err, context.Canceled)) {
if sentryErr := t.sentryReporter.ReportMessageWithContext("Failed to sync, will retry later", reporter.Context{
"err": err.Error(),
"user_id": t.userID,
}); sentryErr != nil {
t.log.WithError(sentryErr).Error("Failed to report sentry message")
}
}
t.log.WithError(err).Error("Failed to sync, will retry later") t.log.WithError(err).Error("Failed to sync, will retry later")
sleepCtx(ctx, coolDown) sleepCtx(ctx, coolDown)
} else { } else {

View File

@ -25,6 +25,7 @@ import (
"github.com/ProtonMail/gluon/async" "github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/sentry"
"github.com/bradenaw/juniper/xmaps" "github.com/bradenaw/juniper/xmaps"
"github.com/golang/mock/gomock" "github.com/golang/mock/gomock"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -208,6 +209,13 @@ func TestTask_StateHasSyncedState(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
} }
type mockLabelConflictChecker struct {
}
func (m *mockLabelConflictChecker) CheckAndReportConflicts(_ context.Context, _ map[string]proton.Label) error {
return nil
}
func TestTask_RepeatsOnSyncFailure(t *testing.T) { func TestTask_RepeatsOnSyncFailure(t *testing.T) {
const MessageTotal int64 = 50 const MessageTotal int64 = 50
const MessageID string = "foo" const MessageID string = "foo"
@ -271,7 +279,7 @@ func TestTask_RepeatsOnSyncFailure(t *testing.T) {
tt.syncReporter.EXPECT().OnFinished(gomock.Any()) tt.syncReporter.EXPECT().OnFinished(gomock.Any())
tt.syncReporter.EXPECT().OnProgress(gomock.Any(), gomock.Eq(MessageDelta)) tt.syncReporter.EXPECT().OnProgress(gomock.Any(), gomock.Eq(MessageDelta))
tt.task.Execute(tt.syncReporter, labels, tt.updateApplier, tt.messageBuilder, time.Microsecond) tt.task.Execute(tt.syncReporter, labels, tt.updateApplier, tt.messageBuilder, time.Microsecond, &mockLabelConflictChecker{})
require.NoError(t, <-tt.task.OnSyncFinishedCH()) require.NoError(t, <-tt.task.OnSyncFinishedCH())
} }
@ -342,7 +350,7 @@ func newTestHandler(mockCtrl *gomock.Controller, userID string) thandler { // no
client := NewMockAPIClient(mockCtrl) client := NewMockAPIClient(mockCtrl)
messageBuilder := NewMockMessageBuilder(mockCtrl) messageBuilder := NewMockMessageBuilder(mockCtrl)
syncReporter := NewMockReporter(mockCtrl) syncReporter := NewMockReporter(mockCtrl)
task := NewHandler(regulator, client, userID, syncState, logrus.WithField("test", "test"), &async.NoopPanicHandler{}) task := NewHandler(regulator, client, userID, syncState, logrus.WithField("test", "test"), &async.NoopPanicHandler{}, sentry.NullSentryReporter{})
return thandler{ return thandler{
task: task, task: task,

View File

@ -29,6 +29,8 @@ import (
"time" "time"
"github.com/ProtonMail/gluon/async" "github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/gluon/db"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/gluon/watcher" "github.com/ProtonMail/gluon/watcher"
"github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal" "github.com/ProtonMail/proton-bridge/v3/internal"
@ -70,6 +72,8 @@ type Service struct {
eventPollWaitersLock sync.Mutex eventPollWaitersLock sync.Mutex
eventSubscription events.Subscription eventSubscription events.Subscription
eventWatcher *watcher.Watcher[events.Event] eventWatcher *watcher.Watcher[events.Event]
sentryReporter reporter.Reporter
} }
func NewService( func NewService(
@ -82,6 +86,7 @@ func NewService(
eventTimeout time.Duration, eventTimeout time.Duration,
panicHandler async.PanicHandler, panicHandler async.PanicHandler,
eventSubscription events.Subscription, eventSubscription events.Subscription,
sentryReporter reporter.Reporter,
) *Service { ) *Service {
return &Service{ return &Service{
cpc: cpc.NewCPC(), cpc: cpc.NewCPC(),
@ -99,6 +104,7 @@ func NewService(
panicHandler: panicHandler, panicHandler: panicHandler,
eventSubscription: eventSubscription, eventSubscription: eventSubscription,
eventWatcher: eventSubscription.Add(events.ConnStatusDown{}, events.ConnStatusUp{}), eventWatcher: eventSubscription.Add(events.ConnStatusDown{}, events.ConnStatusUp{}),
sentryReporter: sentryReporter,
} }
} }
@ -414,6 +420,14 @@ func (s *Service) handleEventError(ctx context.Context, lastEventID string, even
return subscriberName, fmt.Errorf("failed to handle event due to server error: %w", err) return subscriberName, fmt.Errorf("failed to handle event due to server error: %w", err)
} }
if db.IsUniqueLabelConstraintError(err) {
if err := s.sentryReporter.ReportMessageWithContext("Unique label constraint error occurred on event", reporter.Context{
"err": err,
}); err != nil {
s.log.WithError(err).Error("Failed to report label constraint error to sentry")
}
}
// Otherwise, the error is a client-side issue; notify bridge to handle it. // Otherwise, the error is a client-side issue; notify bridge to handle it.
s.log.WithField("event", event).Warn("Failed to handle API event") s.log.WithField("event", event).Warn("Failed to handle API event")

View File

@ -30,6 +30,7 @@ import (
"github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/events" "github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/events/mocks" "github.com/ProtonMail/proton-bridge/v3/internal/events/mocks"
"github.com/ProtonMail/proton-bridge/v3/internal/sentry"
"github.com/golang/mock/gomock" "github.com/golang/mock/gomock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -49,6 +50,7 @@ func TestServiceHandleEventError_SubscriberEventUnwrapping(t *testing.T) {
time.Second, time.Second,
async.NoopPanicHandler{}, async.NoopPanicHandler{},
events.NewNullSubscription(), events.NewNullSubscription(),
sentry.NullSentryReporter{},
) )
lastEventID := "PrevEvent" lastEventID := "PrevEvent"
@ -87,6 +89,7 @@ func TestServiceHandleEventError_BadEventPutsServiceOnPause(t *testing.T) {
time.Second, time.Second,
async.NoopPanicHandler{}, async.NoopPanicHandler{},
events.NewNullSubscription(), events.NewNullSubscription(),
sentry.NullSentryReporter{},
) )
service.Resume() service.Resume()
lastEventID := "PrevEvent" lastEventID := "PrevEvent"
@ -121,6 +124,7 @@ func TestServiceHandleEventError_BadEventFromPublishTimeout(t *testing.T) {
time.Second, time.Second,
async.NoopPanicHandler{}, async.NoopPanicHandler{},
events.NewNullSubscription(), events.NewNullSubscription(),
sentry.NullSentryReporter{},
) )
lastEventID := "PrevEvent" lastEventID := "PrevEvent"
event := proton.Event{EventID: "MyEvent"} event := proton.Event{EventID: "MyEvent"}
@ -152,6 +156,7 @@ func TestServiceHandleEventError_NoBadEventCheck(t *testing.T) {
time.Second, time.Second,
async.NoopPanicHandler{}, async.NoopPanicHandler{},
events.NewNullSubscription(), events.NewNullSubscription(),
sentry.NullSentryReporter{},
) )
lastEventID := "PrevEvent" lastEventID := "PrevEvent"
event := proton.Event{EventID: "MyEvent"} event := proton.Event{EventID: "MyEvent"}
@ -178,6 +183,7 @@ func TestServiceHandleEventError_JsonUnmarshalEventProducesUncategorizedErrorEve
time.Second, time.Second,
async.NoopPanicHandler{}, async.NoopPanicHandler{},
events.NewNullSubscription(), events.NewNullSubscription(),
sentry.NullSentryReporter{},
) )
lastEventID := "PrevEvent" lastEventID := "PrevEvent"
event := proton.Event{EventID: "MyEvent"} event := proton.Event{EventID: "MyEvent"}

View File

@ -28,6 +28,7 @@ import (
"github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/events" "github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/events/mocks" "github.com/ProtonMail/proton-bridge/v3/internal/events/mocks"
"github.com/ProtonMail/proton-bridge/v3/internal/sentry"
"github.com/golang/mock/gomock" "github.com/golang/mock/gomock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -69,6 +70,7 @@ func TestServiceHandleEvent_CheckEventCategoriesHandledInOrder(t *testing.T) {
10*time.Second, 10*time.Second,
async.NoopPanicHandler{}, async.NoopPanicHandler{},
events.NewNullSubscription(), events.NewNullSubscription(),
sentry.NullSentryReporter{},
) )
subscription := NewCallbackSubscriber("test", EventHandler{ subscription := NewCallbackSubscriber("test", EventHandler{
@ -130,6 +132,7 @@ func TestServiceHandleEvent_CheckEventFailureCausesError(t *testing.T) {
time.Second, time.Second,
async.NoopPanicHandler{}, async.NoopPanicHandler{},
events.NewNullSubscription(), events.NewNullSubscription(),
sentry.NullSentryReporter{},
) )
subscription := NewCallbackSubscriber("test", EventHandler{ subscription := NewCallbackSubscriber("test", EventHandler{
@ -168,6 +171,7 @@ func TestServiceHandleEvent_CheckEventFailureCausesErrorParallel(t *testing.T) {
time.Second, time.Second,
async.NoopPanicHandler{}, async.NoopPanicHandler{},
events.NewNullSubscription(), events.NewNullSubscription(),
sentry.NullSentryReporter{},
) )
subscription := NewCallbackSubscriber("test", EventHandler{ subscription := NewCallbackSubscriber("test", EventHandler{

View File

@ -27,6 +27,7 @@ import (
"github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/events" "github.com/ProtonMail/proton-bridge/v3/internal/events"
mocks2 "github.com/ProtonMail/proton-bridge/v3/internal/events/mocks" mocks2 "github.com/ProtonMail/proton-bridge/v3/internal/events/mocks"
"github.com/ProtonMail/proton-bridge/v3/internal/sentry"
"github.com/ProtonMail/proton-bridge/v3/internal/services/orderedtasks" "github.com/ProtonMail/proton-bridge/v3/internal/services/orderedtasks"
"github.com/ProtonMail/proton-bridge/v3/internal/services/userevents/mocks" "github.com/ProtonMail/proton-bridge/v3/internal/services/userevents/mocks"
"github.com/golang/mock/gomock" "github.com/golang/mock/gomock"
@ -76,6 +77,7 @@ func TestService_EventIDLoadStore(t *testing.T) {
time.Second, time.Second,
async.NoopPanicHandler{}, async.NoopPanicHandler{},
events.NewNullSubscription(), events.NewNullSubscription(),
sentry.NullSentryReporter{},
) )
_, err := service.Start(context.Background(), group) _, err := service.Start(context.Background(), group)
@ -132,6 +134,7 @@ func TestService_RetryEventOnNonCatastrophicFailure(t *testing.T) {
time.Second, time.Second,
async.NoopPanicHandler{}, async.NoopPanicHandler{},
events.NewNullSubscription(), events.NewNullSubscription(),
sentry.NullSentryReporter{},
) )
service.Subscribe(NewCallbackSubscriber("foo", EventHandler{MessageHandler: subscriber})) service.Subscribe(NewCallbackSubscriber("foo", EventHandler{MessageHandler: subscriber}))
@ -182,6 +185,7 @@ func TestService_OnBadEventServiceIsPaused(t *testing.T) {
time.Second, time.Second,
async.NoopPanicHandler{}, async.NoopPanicHandler{},
events.NewNullSubscription(), events.NewNullSubscription(),
sentry.NullSentryReporter{},
) )
// Event publisher expectations. // Event publisher expectations.
@ -249,6 +253,7 @@ func TestService_UnsubscribeDuringEventHandlingDoesNotCauseDeadlock(t *testing.T
time.Second, time.Second,
async.NoopPanicHandler{}, async.NoopPanicHandler{},
events.NewNullSubscription(), events.NewNullSubscription(),
sentry.NullSentryReporter{},
) )
subscription := NewCallbackSubscriber("foo", EventHandler{MessageHandler: subscriber}) subscription := NewCallbackSubscriber("foo", EventHandler{MessageHandler: subscriber})
@ -309,6 +314,7 @@ func TestService_UnsubscribeBeforeHandlingEventIsNotConsideredError(t *testing.T
time.Second, time.Second,
async.NoopPanicHandler{}, async.NoopPanicHandler{},
events.NewNullSubscription(), events.NewNullSubscription(),
sentry.NullSentryReporter{},
) )
subscription := NewEventSubscriber("Foo") subscription := NewEventSubscriber("Foo")
@ -369,6 +375,7 @@ func TestService_WaitOnEventPublishAfterPause(t *testing.T) {
time.Second, time.Second,
async.NoopPanicHandler{}, async.NoopPanicHandler{},
events.NewNullSubscription(), events.NewNullSubscription(),
sentry.NullSentryReporter{},
) )
subscriber.EXPECT().HandleMessageEvents(gomock.Any(), gomock.Eq(messageEvents)).Times(1).DoAndReturn(func(_ context.Context, _ []proton.MessageEvent) error { subscriber.EXPECT().HandleMessageEvents(gomock.Any(), gomock.Eq(messageEvents)).Times(1).DoAndReturn(func(_ context.Context, _ []proton.MessageEvent) error {
@ -442,6 +449,7 @@ func TestService_EventRewind(t *testing.T) {
time.Second, time.Second,
async.NoopPanicHandler{}, async.NoopPanicHandler{},
events.NewNullSubscription(), events.NewNullSubscription(),
sentry.NullSentryReporter{},
) )
_, err := service.Start(context.Background(), group) _, err := service.Start(context.Background(), group)

View File

@ -37,15 +37,32 @@ var pollJitter = 2 * time.Minute //nolint:gochecknoglobals
const filename = "unleash_flags" const filename = "unleash_flags"
const ( const (
EventLoopNotificationDisabled = "InboxBridgeEventLoopNotificationDisabled" EventLoopNotificationDisabled = "InboxBridgeEventLoopNotificationDisabled"
IMAPAuthenticateCommandDisabled = "InboxBridgeImapAuthenticateCommandDisabled" IMAPAuthenticateCommandDisabled = "InboxBridgeImapAuthenticateCommandDisabled"
UserRemovalGluonDataCleanupDisabled = "InboxBridgeUserRemovalGluonDataCleanupDisabled" UserRemovalGluonDataCleanupDisabled = "InboxBridgeUserRemovalGluonDataCleanupDisabled"
UpdateUseNewVersionFileStructureDisabled = "InboxBridgeUpdateWithOsFilterDisabled" UpdateUseNewVersionFileStructureDisabled = "InboxBridgeUpdateWithOsFilterDisabled"
LabelConflictResolverDisabled = "InboxBridgeLabelConflictResolverDisabled"
SMTPSubmissionRequestSentryReportDisabled = "InboxBridgeSmtpSubmissionRequestSentryReportDisabled"
InternalLabelConflictResolverDisabled = "InboxBridgeUnexpectedFoldersLabelsStartupFixupDisabled"
ItnternalLabelConflictNonEmptyMailboxDeletion = "InboxBridgeUnknownNonEmptyMailboxDeletion"
) )
type requestFeaturesFn func(ctx context.Context) (proton.FeatureFlagResult, error) type FeatureFlagValueProvider interface {
type GetFlagValueFn func(key string) bool GetFlagValue(key string) bool
}
// NullUnleashService - mock of the unleash service. Should be used for testing.
type NullUnleashService struct{}
func (n NullUnleashService) GetFlagValue(_ string) bool {
return false
}
func NewNullUnleashService() *NullUnleashService {
return &NullUnleashService{}
}
type requestFeaturesFn func(ctx context.Context) (proton.FeatureFlagResult, error)
type Service struct { type Service struct {
panicHandler async.PanicHandler panicHandler async.PanicHandler
timer *proton.Ticker timer *proton.Ticker

View File

@ -110,7 +110,7 @@ func New(
syncConfigDir string, syncConfigDir string,
isNew bool, isNew bool,
notificationStore *notifications.Store, notificationStore *notifications.Store,
getFlagValFn unleash.GetFlagValueFn, featureFlagValueProvider unleash.FeatureFlagValueProvider,
) (*User, error) { ) (*User, error) {
user, err := newImpl( user, err := newImpl(
ctx, ctx,
@ -130,7 +130,7 @@ func New(
syncConfigDir, syncConfigDir,
isNew, isNew,
notificationStore, notificationStore,
getFlagValFn, featureFlagValueProvider,
) )
if err != nil { if err != nil {
// Cleanup any pending resources on error // Cleanup any pending resources on error
@ -163,7 +163,7 @@ func newImpl(
syncConfigDir string, syncConfigDir string,
isNew bool, isNew bool,
notificationStore *notifications.Store, notificationStore *notifications.Store,
getFlagValueFn unleash.GetFlagValueFn, featureFlagValueProvider unleash.FeatureFlagValueProvider,
) (*User, error) { ) (*User, error) {
logrus.WithField("userID", apiUser.ID).Info("Creating new user") logrus.WithField("userID", apiUser.ID).Info("Creating new user")
@ -241,6 +241,7 @@ func newImpl(
5*time.Minute, 5*time.Minute,
crashHandler, crashHandler,
eventSubscription, eventSubscription,
reporter,
) )
addressMode := usertypes.VaultToAddressMode(encVault.AddressMode()) addressMode := usertypes.VaultToAddressMode(encVault.AddressMode())
@ -262,6 +263,8 @@ func newImpl(
identityState.Clone(), identityState.Clone(),
smtpServerManager, smtpServerManager,
observabilityService, observabilityService,
imapServerManager,
featureFlagValueProvider,
) )
user.imapService = imapservice.NewService( user.imapService = imapservice.NewService(
@ -282,9 +285,10 @@ func newImpl(
user.maxSyncMemory, user.maxSyncMemory,
showAllMail, showAllMail,
observabilityService, observabilityService,
featureFlagValueProvider,
) )
user.notificationService = notifications.NewService(user.id, user.eventService, user, notificationStore, getFlagValueFn, observabilityService) user.notificationService = notifications.NewService(user.id, user.eventService, user, notificationStore, featureFlagValueProvider, observabilityService)
// When we receive an auth object, we update it in the vault. // When we receive an auth object, we update it in the vault.
// This will be used to authorize the user on the next run. // This will be used to authorize the user on the next run.

View File

@ -28,11 +28,13 @@ import (
"github.com/ProtonMail/go-proton-api/server/backend" "github.com/ProtonMail/go-proton-api/server/backend"
"github.com/ProtonMail/proton-bridge/v3/internal/certs" "github.com/ProtonMail/proton-bridge/v3/internal/certs"
"github.com/ProtonMail/proton-bridge/v3/internal/events" "github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/sentry"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice" "github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice"
"github.com/ProtonMail/proton-bridge/v3/internal/services/notifications" "github.com/ProtonMail/proton-bridge/v3/internal/services/notifications"
"github.com/ProtonMail/proton-bridge/v3/internal/services/observability" "github.com/ProtonMail/proton-bridge/v3/internal/services/observability"
"github.com/ProtonMail/proton-bridge/v3/internal/services/smtp" "github.com/ProtonMail/proton-bridge/v3/internal/services/smtp"
"github.com/ProtonMail/proton-bridge/v3/internal/telemetry/mocks" "github.com/ProtonMail/proton-bridge/v3/internal/telemetry/mocks"
"github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/ProtonMail/proton-bridge/v3/internal/vault" "github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/tests" "github.com/ProtonMail/proton-bridge/v3/tests"
"github.com/golang/mock/gomock" "github.com/golang/mock/gomock"
@ -150,12 +152,13 @@ func withUser(tb testing.TB, ctx context.Context, _ *server.Server, m *proton.Ma
nullEventSubscription := events.NewNullSubscription() nullEventSubscription := events.NewNullSubscription()
nullIMAPServerManager := imapservice.NewNullIMAPServerManager() nullIMAPServerManager := imapservice.NewNullIMAPServerManager()
nullSMTPServerManager := smtp.NewNullServerManager() nullSMTPServerManager := smtp.NewNullServerManager()
nullUnleashService := unleash.NewNullUnleashService()
user, err := New( user, err := New(
ctx, ctx,
vaultUser, vaultUser,
client, client,
nil, sentry.NullSentryReporter{},
apiUser, apiUser,
nil, nil,
true, true,
@ -171,9 +174,7 @@ func withUser(tb testing.TB, ctx context.Context, _ *server.Server, m *proton.Ma
notifications.NewStore(func() (string, error) { notifications.NewStore(func() (string, error) {
return "", nil return "", nil
}), }),
func(_ string) bool { nullUnleashService,
return false
},
) )
require.NoError(tb, err) require.NoError(tb, err)
defer user.Close() defer user.Close()

View File

@ -89,8 +89,11 @@ func (r *reportRecorder) close() {
} }
func (r *reportRecorder) assertEmpty() { func (r *reportRecorder) assertEmpty() {
if !r.skipAssert { if !r.skipAssert && len(r.reports) > 0 {
r.assert.Empty(r.reports) for _, report := range r.reports {
// Sentry reports with failed syncs are expected, mostly due to sync context cancellations.
r.assert.Equal(report.message, "Failed to sync, will retry later")
}
} }
} }
@ -143,6 +146,11 @@ func (r *reportRecorder) ReportMessageWithContext(message string, context report
return nil return nil
} }
func (r *reportRecorder) ReportWarningWithContext(message string, context reporter.Context) error {
r.add(false, message, context)
return nil
}
func (r *reportRecorder) ReportExceptionWithContext(data any, context reporter.Context) error { func (r *reportRecorder) ReportExceptionWithContext(data any, context reporter.Context) error {
if context == nil { if context == nil {
context = reporter.Context{} context = reporter.Context{}

View File

@ -45,5 +45,9 @@ Feature: Bridge send remote notification observability metrics
And the user with username "[user:user1]" sends SMTP send success observability metric And the user with username "[user:user1]" sends SMTP send success observability metric
Then it succeeds Then it succeeds
Scenario: Test SMTP send request observability metric
When the user logs in with username "[user:user1]" and password "password"
And the user with username "[user:user1]" sends an SMTP send request observability metric
Then it succeeds

View File

@ -9,3 +9,8 @@ Feature: Bridge send remote notification observability metrics
When the user logs in with username "[user:user1]" and password "password" When the user logs in with username "[user:user1]" and password "password"
And the user with username "[user:user1]" sends all possible gluon error observability metrics And the user with username "[user:user1]" sends all possible gluon error observability metrics
Then it succeeds Then it succeeds
Scenario: Test newly opened IMAP connections in Gluon exceed threshold metric
When the user logs in with username "[user:user1]" and password "password"
And the user with username "[user:user1]" sends a Gluon metric indicating that the number of newly opened IMAP connections within some interval have exceed a threshold value
Then it succeeds

View File

@ -25,6 +25,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice/observabilitymetrics/evtloopmsgevents" "github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice/observabilitymetrics/evtloopmsgevents"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice/observabilitymetrics/syncmsgevents" "github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice/observabilitymetrics/syncmsgevents"
"github.com/ProtonMail/proton-bridge/v3/internal/services/observability" "github.com/ProtonMail/proton-bridge/v3/internal/services/observability"
"github.com/ProtonMail/proton-bridge/v3/internal/services/observability/gluonmetrics"
smtpMetrics "github.com/ProtonMail/proton-bridge/v3/internal/services/smtp/observabilitymetrics" smtpMetrics "github.com/ProtonMail/proton-bridge/v3/internal/services/smtp/observabilitymetrics"
"github.com/ProtonMail/proton-bridge/v3/internal/services/syncservice/observabilitymetrics" "github.com/ProtonMail/proton-bridge/v3/internal/services/syncservice/observabilitymetrics"
) )
@ -188,3 +189,48 @@ func (s *scenario) SMTPSendSuccessObservabilityMetric(username string) error {
return err return err
}) })
} }
func (s *scenario) SMTPSendRequestObservabilityMetric(username string) error {
batch := proton.ObservabilityBatch{
Metrics: []proton.ObservabilityMetric{
smtpMetrics.GenerateSMTPSubmissionRequest("outlook", 1, 10),
smtpMetrics.GenerateSMTPSubmissionRequest("outlook", 10, 25),
smtpMetrics.GenerateSMTPSubmissionRequest("outlook", 30, 45),
smtpMetrics.GenerateSMTPSubmissionRequest("outlook", 50, 75),
smtpMetrics.GenerateSMTPSubmissionRequest("outlook", 100, 150),
smtpMetrics.GenerateSMTPSubmissionRequest("outlook", 200, 250),
smtpMetrics.GenerateSMTPSubmissionRequest("outlook", 300, 450),
smtpMetrics.GenerateSMTPSubmissionRequest("outlook", 500, 750),
smtpMetrics.GenerateSMTPSubmissionRequest("outlook", 1000, 1500),
smtpMetrics.GenerateSMTPSubmissionRequest("outlook", 1900, 2500),
smtpMetrics.GenerateSMTPSubmissionRequest("outlook", 3000, 3500),
},
}
return s.t.withClientPass(context.Background(), username, s.t.getUserByName(username).userPass, func(ctx context.Context, c *proton.Client) error {
err := c.SendObservabilityBatch(ctx, batch)
return err
})
}
func (s *scenario) GluonNewlyOpenedIMAPConnectionsExceedThreshold(username string) error {
batch := proton.ObservabilityBatch{
Metrics: []proton.ObservabilityMetric{
gluonmetrics.GenerateNewOpenedIMAPConnectionsExceedThreshold("outlook", observability.BucketIMAPConnections(1), observability.BucketIMAPConnections(10)),
gluonmetrics.GenerateNewOpenedIMAPConnectionsExceedThreshold("outlook", observability.BucketIMAPConnections(10), observability.BucketIMAPConnections(25)),
gluonmetrics.GenerateNewOpenedIMAPConnectionsExceedThreshold("outlook", observability.BucketIMAPConnections(30), observability.BucketIMAPConnections(45)),
gluonmetrics.GenerateNewOpenedIMAPConnectionsExceedThreshold("outlook", observability.BucketIMAPConnections(50), observability.BucketIMAPConnections(75)),
gluonmetrics.GenerateNewOpenedIMAPConnectionsExceedThreshold("outlook", observability.BucketIMAPConnections(100), observability.BucketIMAPConnections(150)),
gluonmetrics.GenerateNewOpenedIMAPConnectionsExceedThreshold("outlook", observability.BucketIMAPConnections(200), observability.BucketIMAPConnections(250)),
gluonmetrics.GenerateNewOpenedIMAPConnectionsExceedThreshold("outlook", observability.BucketIMAPConnections(300), observability.BucketIMAPConnections(450)),
gluonmetrics.GenerateNewOpenedIMAPConnectionsExceedThreshold("outlook", observability.BucketIMAPConnections(500), observability.BucketIMAPConnections(750)),
gluonmetrics.GenerateNewOpenedIMAPConnectionsExceedThreshold("outlook", observability.BucketIMAPConnections(1000), observability.BucketIMAPConnections(1500)),
gluonmetrics.GenerateNewOpenedIMAPConnectionsExceedThreshold("outlook", observability.BucketIMAPConnections(1900), observability.BucketIMAPConnections(2500)),
gluonmetrics.GenerateNewOpenedIMAPConnectionsExceedThreshold("outlook", observability.BucketIMAPConnections(3000), observability.BucketIMAPConnections(3500)),
},
}
return s.t.withClientPass(context.Background(), username, s.t.getUserByName(username).userPass, func(ctx context.Context, c *proton.Client) error {
err := c.SendObservabilityBatch(ctx, batch)
return err
})
}

View File

@ -242,7 +242,11 @@ func (s *scenario) steps(ctx *godog.ScenarioContext) {
// SMTP metrics // SMTP metrics
ctx.Step(`^the user with username "([^"]*)" sends all possible SMTP error observability metrics$`, s.SMTPErrorObservabilityMetrics) ctx.Step(`^the user with username "([^"]*)" sends all possible SMTP error observability metrics$`, s.SMTPErrorObservabilityMetrics)
ctx.Step(`^the user with username "([^"]*)" sends SMTP send success observability metric$`, s.SMTPSendSuccessObservabilityMetric) ctx.Step(`^the user with username "([^"]*)" sends SMTP send success observability metric$`, s.SMTPSendSuccessObservabilityMetric)
// SMTP submission metric
ctx.Step(`^the user with username "([^"]*)" sends an SMTP send request observability metric$`, s.SMTPSendRequestObservabilityMetric)
// Gluon related metrics // Gluon related metrics
ctx.Step(`^the user with username "([^"]*)" sends all possible gluon error observability metrics$`, s.testGluonErrorObservabilityMetrics) ctx.Step(`^the user with username "([^"]*)" sends all possible gluon error observability metrics$`, s.testGluonErrorObservabilityMetrics)
// Gluon metric - on newly opened IMAP connections exceeding threshold.
ctx.Step(`^the user with username "([^"]*)" sends a Gluon metric indicating that the number of newly opened IMAP connections within some interval have exceed a threshold value$`, s.GluonNewlyOpenedIMAPConnectionsExceedThreshold)
} }