Compare commits

...

16 Commits

Author SHA1 Message Date
4cc2ded001 chore: Kanmon Bridge 3.21.2 changelog. 2025-07-07 11:34:42 +02:00
15880dfe19 fix(BRIDGE-406): fixed faulty certificate chain validation logic; made certificate pin checks exclusive to leaf certs; 2025-07-04 15:19:44 +02:00
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
55 changed files with 2495 additions and 111 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)
* [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)
* [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)
* [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)

View File

@ -3,6 +3,61 @@
Changelog [format](http://keepachangelog.com/en/1.0.0/)
## Kanmon Bridge 3.21.2
### Fixed
* BRIDGE-406: Fixed faulty certificate chain validation logic. Made certificate pin checks exclusive to leaf certificates.
## 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
### Changed

View File

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

3
go.mod
View File

@ -7,7 +7,7 @@ toolchain go1.24.2
require (
github.com/0xAX/notificator v0.0.0-20220220101646-ee9b8921e557
github.com/Masterminds/semver/v3 v3.2.0
github.com/ProtonMail/gluon v0.17.1-0.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-proton-api v0.4.1-0.20250417134000-e624a080f7ba
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/spf13/pflag v1.0.5 // 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/ugorji/go/codec v1.2.11 // 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-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug=
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.20250324123053-2abce471ad71/go.mod h1:0/c03TzZPNiSgY5UDJK1iRDkjlDPwWugxTT6et2qDu8=
github.com/ProtonMail/gluon v0.17.1-0.20250611120816-05167d499f8d h1:45W7G+X0w7nzLzeB0eiFkGho5DTK1jNmmNbt3IhN524=
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 v1.1.4-proton h1:KIo9uNlk3vzlwI7o5VjhiEjI4Ld1TDixOMnoNZyfpFE=
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-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/go-proton-api v0.4.1-0.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/go.mod h1:eXIoLyIHxvPo8Kd9e1ygYIrAwbeWJhLi3vgSz2crlK4=
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.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
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/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
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/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
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-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.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/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
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.8.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/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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.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.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/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=

View File

@ -335,6 +335,7 @@ func newBridge(
uidValidityGenerator,
&bridgeIMAPSMTPTelemetry{b: bridge},
observabilityService,
unleashService,
)
// Check whether username has changed and correct (macOS only)
@ -746,7 +747,7 @@ func (bridge *Bridge) PushObservabilityMetric(metric proton.ObservabilityMetric)
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...)
}

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().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
}

View File

@ -88,3 +88,18 @@ func (mr *MockReporterMockRecorder) ReportMessageWithContext(arg0, arg1 interfac
mr.mock.ctrl.T.Helper()
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) AddDistinctMetrics(errType observability.DistinctionErrorTypeEnum, _ ...proton.ObservabilityMetric) {
func (m *MockObservabilitySender) AddDistinctMetrics(errType observability.DistinctionMetricTypeEnum, _ ...proton.ObservabilityMetric) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "AddDistinctMetrics", errType)
}
@ -35,7 +35,18 @@ func (m *MockObservabilitySender) AddMetrics(metrics ...proton.ObservabilityMetr
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()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock,
"AddDistinctMetrics",
@ -47,3 +58,13 @@ func (mr *MockObservabilitySenderRecorder) AddMetrics(metrics ...proton.Observab
mr.mock.ctrl.T.Helper()
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,
isNew,
bridge.notificationStore,
bridge.unleashService.GetFlagValue,
bridge.unleashService,
)
if err != nil {
return fmt.Errorf("failed to create user: %w", err)

View File

@ -23,6 +23,7 @@ import (
"net"
"net/http"
"net/mail"
"runtime"
"strings"
"sync/atomic"
"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) {
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)
closeCh()

View File

@ -22,6 +22,8 @@ import (
"crypto/tls"
"net"
"net/http"
"net/url"
"strings"
"time"
)
@ -29,6 +31,11 @@ type TLSDialer interface {
DialTLSContext(ctx context.Context, network, address string) (conn net.Conn, err error)
}
type SecureTLSDialer interface {
DialTLSContext(ctx context.Context, network, address string) (conn net.Conn, err error)
ShouldSkipCertificateChainVerification(address string) bool
}
func SetBasicTransportTimeouts(t *http.Transport) {
t.MaxIdleConns = 100
t.MaxIdleConnsPerHost = 100
@ -71,6 +78,35 @@ func NewBasicTLSDialer(hostURL string) *BasicTLSDialer {
}
}
func extractDomain(hostname string) string {
parts := strings.Split(hostname, ".")
if len(parts) >= 2 {
return strings.Join(parts[len(parts)-2:], ".")
}
return hostname
}
// ShouldSkipCertificateChainVerification determines whether certificate chain validation should be skipped.
// It compares the domain of the requested address with the configured host URL domain.
// Returns true if the domains don't match (skip verification), false if they do (perform verification).
//
// NOTE: This assumes single-part TLDs (.com, .me) and won't handle multi-part TLDs correctly.
func (d *BasicTLSDialer) ShouldSkipCertificateChainVerification(address string) bool {
parsedURL, err := url.Parse(d.hostURL)
if err != nil {
return true
}
addressHost, _, err := net.SplitHostPort(address)
if err != nil {
addressHost = address
}
hostDomain := extractDomain(parsedURL.Host)
addressDomain := extractDomain(addressHost)
return addressDomain != hostDomain
}
// DialTLSContext returns a connection to the given address using the given network.
func (d *BasicTLSDialer) DialTLSContext(ctx context.Context, network, address string) (conn net.Conn, err error) {
return (&tls.Dialer{
@ -78,7 +114,7 @@ func (d *BasicTLSDialer) DialTLSContext(ctx context.Context, network, address st
Timeout: 30 * time.Second,
},
Config: &tls.Config{
InsecureSkipVerify: address != d.hostURL, //nolint:gosec
InsecureSkipVerify: d.ShouldSkipCertificateChainVerification(address), //nolint:gosec
},
}).DialContext(ctx, network, address)
}

View File

@ -0,0 +1,134 @@
// 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 dialer
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestBasicTLSDialer_ShouldSkipCertificateChainVerification(t *testing.T) {
tests := []struct {
hostURL string
address string
expected bool
}{
{
hostURL: "https://mail-api.proton.me",
address: "mail-api.proton.me:443",
expected: false,
},
{
hostURL: "https://proton.me",
address: "proton.me",
expected: false,
},
{
hostURL: "https://api.proton.me",
address: "mail.proton.me:443",
expected: false,
},
{
hostURL: "https://proton.me",
address: "mail-api.proton.me:443",
expected: false,
},
{
hostURL: "https://mail-api.proton.me",
address: "proton.me:443",
expected: false,
},
{
hostURL: "https://mail.google.com",
address: "mail-api.proton.me:443",
expected: true,
},
{
hostURL: "https://mail-api.protonmail.com",
address: "mail-api.proton.me:443",
expected: true,
},
{
hostURL: "https://proton.me",
address: "google.com:443",
expected: true,
},
{
hostURL: "https://proton.me",
address: "proton.com:443",
expected: true,
},
{
hostURL: "https://proton.me",
address: "example.me:443",
expected: true,
},
{
hostURL: "https://proton.me",
address: "mail.example.com:443",
expected: true,
},
{
hostURL: "https://proton.me",
address: "proton.me",
expected: false,
},
{
hostURL: "https://proton.me:8080",
address: "proton.me:443",
expected: true,
},
{
hostURL: "https://proton.me/api/v1",
address: "proton.me:443",
expected: false,
},
{
hostURL: "https://proton.black",
address: "mail-api.pascal.proton.black",
expected: false,
},
{
hostURL: "https://mail-api.pascal.proton.black",
address: "mail-api.pascal.proton.black",
expected: false,
},
{
hostURL: "https://mail-api.pascal.proton.black",
address: "proton.black:332",
expected: false,
},
{
hostURL: "https://mail-api.pascal.proton.black",
address: "proton.me",
expected: true,
},
{
hostURL: "https://mail-api.pascal.proton.black",
address: "proton.me:332",
expected: true,
},
}
for _, tt := range tests {
dialer := NewBasicTLSDialer(tt.hostURL)
result := dialer.ShouldSkipCertificateChainVerification(tt.address)
require.Equal(t, tt.expected, result)
}
}

View File

@ -50,12 +50,12 @@ var TrustedAPIPins = []string{ //nolint:gochecknoglobals
}
// TLSReportURI is the address where TLS reports should be sent.
const TLSReportURI = "https://reports.protonmail.ch/reports/tls"
const TLSReportURI = "https://reports.proton.me/reports/tls"
// PinningTLSDialer wraps a TLSDialer to check fingerprints after connecting and
// to report errors if the fingerprint check fails.
type PinningTLSDialer struct {
dialer TLSDialer
dialer SecureTLSDialer
pinChecker PinChecker
reporter Reporter
tlsIssueCh chan struct{}
@ -68,13 +68,13 @@ type Reporter interface {
// PinChecker is used to check TLS keys of connections.
type PinChecker interface {
CheckCertificate(conn net.Conn) error
CheckCertificate(conn net.Conn, certificateChainVerificationSkipped bool) error
}
// NewPinningTLSDialer constructs a new dialer which only returns TCP connections to servers
// which present known certificates.
// It checks pins using the given pinChecker and reports issues using the given reporter.
func NewPinningTLSDialer(dialer TLSDialer, reporter Reporter, pinChecker PinChecker) *PinningTLSDialer {
func NewPinningTLSDialer(dialer SecureTLSDialer, reporter Reporter, pinChecker PinChecker) *PinningTLSDialer {
return &PinningTLSDialer{
dialer: dialer,
pinChecker: pinChecker,
@ -85,6 +85,7 @@ func NewPinningTLSDialer(dialer TLSDialer, reporter Reporter, pinChecker PinChec
// DialTLSContext dials the given network/address, returning an error if the certificates don't match the trusted pins.
func (p *PinningTLSDialer) DialTLSContext(ctx context.Context, network, address string) (net.Conn, error) {
shouldSkipCertificateChainVerification := p.dialer.ShouldSkipCertificateChainVerification(address)
conn, err := p.dialer.DialTLSContext(ctx, network, address)
if err != nil {
return nil, err
@ -95,7 +96,7 @@ func (p *PinningTLSDialer) DialTLSContext(ctx context.Context, network, address
return nil, err
}
if err := p.pinChecker.CheckCertificate(conn); err != nil {
if err := p.pinChecker.CheckCertificate(conn, shouldSkipCertificateChainVerification); err != nil {
if tlsConn, ok := conn.(*tls.Conn); ok && p.reporter != nil {
p.reporter.ReportCertIssue(TLSReportURI, host, port, tlsConn.ConnectionState())
}

View File

@ -41,3 +41,15 @@ func NewTLSPinChecker(trustedPins []string) *TLSPinChecker {
func certFingerprint(cert *x509.Certificate) string {
return fmt.Sprintf(`pin-sha256=%q`, algo.HashBase64SHA256(string(cert.RawSubjectPublicKeyInfo)))
}
func (p *TLSPinChecker) isCertFoundInKnownPins(cert *x509.Certificate) bool {
fingerprint := certFingerprint(cert)
for _, pin := range p.trustedPins {
if pin == fingerprint {
return true
}
}
return false
}

View File

@ -25,8 +25,8 @@ import (
"net"
)
// CheckCertificate returns whether the connection presents a known TLS certificate.
func (p *TLSPinChecker) CheckCertificate(conn net.Conn) error {
// CheckCertificate verifies that the connection presents a known pinned leaf TLS certificate.
func (p *TLSPinChecker) CheckCertificate(conn net.Conn, certificateChainVerificationSkipped bool) error {
tlsConn, ok := conn.(*tls.Conn)
if !ok {
return errors.New("connection is not a TLS connection")
@ -34,14 +34,31 @@ func (p *TLSPinChecker) CheckCertificate(conn net.Conn) error {
connState := tlsConn.ConnectionState()
for _, peerCert := range connState.PeerCertificates {
fingerprint := certFingerprint(peerCert)
// When certificate chain verification is enabled (e.g., for known API hosts), we expect the TLS handshake to produce verified chains.
// We then validate that the leaf certificate of at least one verified chain matches a known pinned public key.
if !certificateChainVerificationSkipped {
if len(connState.VerifiedChains) == 0 {
return errors.New("no verified certificate chains")
}
for _, pin := range p.trustedPins {
if pin == fingerprint {
for _, chain := range connState.VerifiedChains {
// Check if the leaf certificate is one of the trusted pins.
if p.isCertFoundInKnownPins(chain[0]) {
return nil
}
}
return ErrTLSMismatch
}
// When certificate chain verification is skipped (e.g., for DoH proxies using self-signed certs),
// we only validate the leaf certificate against known pinned public keys.
if len(connState.PeerCertificates) == 0 {
return errors.New("no peer certificates available")
}
if p.isCertFoundInKnownPins(connState.PeerCertificates[0]) {
return nil
}
return ErrTLSMismatch

View File

@ -23,6 +23,6 @@ import "net"
// CheckCertificate returns whether the connection presents a known TLS certificate.
// The QA implementation always returns nil.
func (p *TLSPinChecker) CheckCertificate(conn net.Conn) error {
func (p *TLSPinChecker) CheckCertificate(conn net.Conn, _ bool) error {
return nil
}

View File

@ -64,8 +64,7 @@ func TestTLSPinInvalid(t *testing.T) {
checkTLSIssueHandler(t, 1, called)
}
// Disabled for now we'll need to patch this up.
func _TestTLSPinNoMatch(t *testing.T) { //nolint:unused
func TestTLSPinNoMatch(t *testing.T) {
skipIfProxyIsSet(t)
called, _, reporter, checker, cm := createClientWithPinningDialer(getRootURL())
@ -96,7 +95,7 @@ func TestTLSSignedCertTrustedPublicKey(t *testing.T) {
_, dialer, _, checker, _ := createClientWithPinningDialer("")
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")
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()
err := fmt.Errorf("recover: %v", i)
return r.scopedReport(context, func() {
return r.scopedReport(context, func(_ *sentry.Scope) {
SkipDuringUnwind()
if eventID := sentry.CaptureException(err); eventID != nil {
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 {
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()
if eventID := sentry.CaptureMessage(msg); eventID != nil {
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.
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()
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)},
)
}
doReport()
doReport(scope)
})
if !sentry.Flush(time.Second * 10) {
@ -287,3 +300,25 @@ func contextToString(context sentry.Context) sentry.Context {
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"
)
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.
type Connector struct {
addrID string
@ -67,6 +71,8 @@ type Connector struct {
sharedCache *SharedCache
syncState *SyncState
mailboxCountProvider mailboxCountProvider
}
var errNoSenderAddressMatch = errors.New("no matching sender found in address list")
@ -82,6 +88,7 @@ func NewConnector(
reporter reporter.Reporter,
showAllMail bool,
syncState *SyncState,
mailboxCountProvider mailboxCountProvider,
) *Connector {
userID := identityState.UserID()
@ -115,6 +122,8 @@ func NewConnector(
sharedCache: NewSharedCached(),
syncState: syncState,
mailboxCountProvider: mailboxCountProvider,
}
}
@ -800,8 +809,10 @@ func (s *Connector) createDraftWithParser(ctx context.Context, parser *parser.Pa
return draft, nil
}
func (s *Connector) publishUpdate(_ context.Context, update imap.Update) {
s.updateCh.Enqueue(update)
func (s *Connector) publishUpdate(_ context.Context, updates ...imap.Update) {
for _, update := range updates {
s.updateCh.Enqueue(update)
}
}
func fixGODT3003Labels(
@ -903,3 +914,16 @@ func (s *Connector) getSenderProtonAddress(p *parser.Parser) (proton.Address, er
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 {
var name []string
@ -112,9 +122,10 @@ func GetMailboxName(label proton.Label) []string {
case proton.LabelTypeLabel:
name = append([]string{labelPrefix}, label.Path...)
case proton.LabelTypeContactGroup:
fallthrough
case proton.LabelTypeSystem:
name = []string{label.Name}
case proton.LabelTypeContactGroup:
fallthrough
default:
name = label.Path
@ -122,3 +133,12 @@ func GetMailboxName(label proton.Label) []string {
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"
"github.com/ProtonMail/gluon/connector"
"github.com/ProtonMail/gluon/imap"
"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
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{}
@ -67,6 +76,22 @@ func (n NullIMAPServerManager) LogRemoteLabelIDs(
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 {
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/userevents"
"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/pkg/cpc"
"github.com/sirupsen/logrus"
@ -91,7 +92,9 @@ type Service struct {
lastHandledEventID string
isSyncing atomic.Bool
observabilitySender observability.Sender
observabilitySender observability.Sender
labelConflictManager *LabelConflictManager
LabelConflictChecker *LabelConflictChecker
}
func NewService(
@ -112,6 +115,7 @@ func NewService(
maxSyncMemory uint64,
showAllMail bool,
observabilitySender observability.Sender,
featureFlagProvider unleash.FeatureFlagValueProvider,
) *Service {
subscriberName := fmt.Sprintf("imap-%v", identityState.User.ID)
@ -121,11 +125,12 @@ func NewService(
})
rwIdentity := newRWIdentity(identityState, bridgePassProvider, keyPassProvider)
syncUpdateApplier := NewSyncUpdateApplier()
labelConflictManager := NewLabelConflictManager(serverManager, gluonIDProvider, client, reporter, featureFlagProvider)
syncUpdateApplier := NewSyncUpdateApplier(labelConflictManager)
syncMessageBuilder := NewSyncMessageBuilder(rwIdentity)
syncReporter := newSyncReporter(identityState.User.ID, eventPublisher, time.Second)
return &Service{
service := &Service{
cpc: cpc.NewCPC(),
client: client,
log: log,
@ -156,8 +161,12 @@ func NewService(
syncReporter: syncReporter,
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(
@ -176,7 +185,14 @@ func (s *Service) Start(
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
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.showAllMail,
s.syncStateProvider,
s.serverManager,
)
return connectors, nil
@ -541,6 +558,7 @@ func (s *Service) buildConnectors() (map[string]*Connector, error) {
s.reporter,
s.showAllMail,
s.syncStateProvider,
s.serverManager,
)
}
@ -651,7 +669,7 @@ func (s *Service) setShowAllMail(v bool) {
func (s *Service) startSyncing() {
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() {
@ -659,6 +677,10 @@ func (s *Service) cancelSync() {
s.isSyncing.Store(false)
}
func (s *Service) getConnectors() []*Connector {
return maps.Values(s.connectors)
}
type resyncReq struct{}
type getLabelsReq struct{}

View File

@ -157,6 +157,7 @@ func addNewAddressSplitMode(ctx context.Context, s *Service, addrID string) erro
s.reporter,
s.showAllMail,
s.syncStateProvider,
s.serverManager,
)
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
updates, err := syncLabels(ctx, s.labels.GetLabelMap(), []*Connector{connector})
updates, err := syncLabels(ctx, s.labels.GetLabelMap(), []*Connector{connector}, s.labelConflictManager)
if err != nil {
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
}
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 {
return err
@ -74,8 +77,8 @@ func (s *Service) HandleLabelEvents(ctx context.Context, events []proton.LabelEv
return nil
}
func onLabelCreated(ctx context.Context, s *Service, event proton.LabelEvent) []imap.Update {
updates := make([]imap.Update, 0, len(s.connectors))
func onLabelCreated(ctx context.Context, s *Service, event proton.LabelEvent) ([]imap.Update, error) {
updates := []imap.Update{}
s.log.WithFields(logrus.Fields{
"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")
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) {
conflictUpdates := conflictUpdatesGenerator()
updateCh.publishUpdate(ctx, conflictUpdates...)
updates = append(updates, conflictUpdates...)
update := newMailboxCreatedUpdate(imap.MailboxID(event.ID), GetMailboxName(event.Label))
updateCh.publishUpdate(ctx, update)
updates = append(updates, update)
@ -99,7 +112,7 @@ func onLabelCreated(ctx context.Context, s *Service, event proton.LabelEvent) []
Name: event.Label.Name,
})
return updates
return updates, nil
}
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.
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.
for _, updateCh := range maps.Values(s.connectors) {
conflictUpdates := conflictUpdatesGenerator()
updateCh.publishUpdate(ctx, conflictUpdates...)
updates = append(updates, conflictUpdates...)
update := imap.NewMailboxUpdated(
imap.MailboxID(apiLabel.ID),
GetMailboxName(apiLabel),

View File

@ -31,8 +31,9 @@ import (
)
type SyncUpdateApplier struct {
requestCh chan updateRequest
replyCh chan updateReply
requestCh chan updateRequest
replyCh chan updateReply
labelConflictManager *LabelConflictManager
}
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)
func NewSyncUpdateApplier() *SyncUpdateApplier {
func NewSyncUpdateApplier(labelConflictManager *LabelConflictManager) *SyncUpdateApplier {
return &SyncUpdateApplier{
requestCh: make(chan updateRequest),
replyCh: make(chan updateReply),
requestCh: make(chan updateRequest),
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 {
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)
@ -128,15 +130,34 @@ func (s *SyncUpdateApplier) SyncLabels(ctx context.Context, labels map[string]pr
}
// 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
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.
for _, prefix := range []string{folderPrefix, labelPrefix} {
for _, updateCh := range connectors {
update := newPlaceHolderMailboxCreatedUpdate(prefix)
updateCh.publishUpdate(ctx, 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:
conflictUpdatesGenerator, err := userLabelConflictResolver.ResolveConflict(ctx, label, make(map[string]bool))
if err != nil {
return updates, err
}
for _, updateCh := range connectors {
conflictUpdates := conflictUpdatesGenerator()
updateCh.publishUpdate(ctx, conflictUpdates...)
updates = append(updates, conflictUpdates...)
update := newMailboxCreatedUpdate(imap.MailboxID(labelID), GetMailboxName(label))
updateCh.publishUpdate(ctx, update)
updates = append(updates, update)

View File

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

View File

@ -34,6 +34,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/services/observability"
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/unleash"
"github.com/ProtonMail/proton-bridge/v3/pkg/cpc"
"github.com/emersion/go-smtp"
"github.com/sirupsen/logrus"
@ -63,6 +64,7 @@ type Service struct {
telemetry Telemetry
observabilitySender observability.Sender
featureFlagProvider unleash.FeatureFlagValueProvider
}
func NewService(
@ -75,6 +77,7 @@ func NewService(
uidValidityGenerator imap.UIDValidityGenerator,
telemetry Telemetry,
observabilitySender observability.Sender,
featureFlagProvider unleash.FeatureFlagValueProvider,
) *Service {
return &Service{
requests: cpc.NewCPC(),
@ -91,6 +94,7 @@ func NewService(
telemetry: telemetry,
observabilitySender: observabilitySender,
featureFlagProvider: featureFlagProvider,
}
}
@ -200,6 +204,22 @@ func (sm *Service) RemoveSMTPAccount(ctx context.Context, service *bridgesmtp.Se
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) {
eventSub := subscription.Add()
defer subscription.Remove(eventSub)
@ -498,6 +518,7 @@ func (sm *Service) createIMAPServer(ctx context.Context) (*gluon.Server, error)
sm.uidValidityGenerator,
sm.panicHandler,
sm.observabilitySender,
sm.featureFlagProvider,
)
if err == nil {
sm.eventPublisher.PublishEvent(ctx, events.IMAPServerCreated{})

View File

@ -44,7 +44,7 @@ type Service struct {
store *Store
getFlagValueFn unleash.GetFlagValueFn
featureFlagValueProvider unleash.FeatureFlagValueProvider
observabilitySender observability.Sender
}
@ -52,7 +52,7 @@ type Service struct {
const bitfieldRegexPattern = `^\\\d+`
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{
userID: userID,
@ -68,8 +68,8 @@ func NewService(userID string, service userevents.Subscribable, eventPublisher e
store: store,
getFlagValueFn: getFlagFn,
observabilitySender: observabilitySender,
featureFlagValueProvider: featureFlagValueProvider,
observabilitySender: observabilitySender,
}
}
@ -102,7 +102,7 @@ func (s *Service) run(ctx context.Context) {
}
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.")
return nil
}

View File

@ -19,6 +19,7 @@ package observability
import (
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/services/observability/gluonmetrics"
)
type Adapter struct {
@ -88,6 +89,15 @@ func (adapter *Adapter) AddDistinctMetrics(errType interface{}, metrics ...map[s
}
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"
// DistinctionErrorTypeEnum - maps to the specific error schema for which we
// want to send a user update.
type DistinctionErrorTypeEnum int
// DistinctionMetricTypeEnum - used to distinct specific metrics which we want to limit over some interval.
// Most enums are tied to a specific error schema for which we also send a specific distinction user update.
type DistinctionMetricTypeEnum int
const (
SyncError DistinctionErrorTypeEnum = iota
SyncError DistinctionMetricTypeEnum = iota
GluonImapError
GluonMessageError
GluonOtherError
SMTPError
EventLoopError // EventLoopError - should always be kept last when inserting new keys.
NewIMAPConnectionsExceedThreshold
)
// errorSchemaMap - maps between the DistinctionErrorTypeEnum and the relevant schema name.
var errorSchemaMap = map[DistinctionErrorTypeEnum]string{ //nolint:gochecknoglobals
// errorSchemaMap - maps between some DistinctionMetricTypeEnum and the relevant schema name.
var errorSchemaMap = map[DistinctionMetricTypeEnum]string{ //nolint:gochecknoglobals
SyncError: "bridge_sync_errors_users_total",
EventLoopError: "bridge_event_loop_events_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.
func createLastSentMap() map[DistinctionErrorTypeEnum]time.Time {
func createLastSentMap() map[DistinctionMetricTypeEnum]time.Time {
registerTime := time.Now().Add(-updateInterval)
lastSentMap := make(map[DistinctionErrorTypeEnum]time.Time)
lastSentMap := make(map[DistinctionMetricTypeEnum]time.Time)
for errType := SyncError; errType <= EventLoopError; errType++ {
lastSentMap[errType] = registerTime

View File

@ -40,7 +40,7 @@ type distinctionUtility struct {
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
settingsGetter settingsGetter
@ -87,7 +87,7 @@ func (d *distinctionUtility) setSettingsGetter(getter settingsGetter) {
// checkAndUpdateLastSentMap - checks whether we have sent a relevant user update metric
// within the last 5 minutes.
func (d *distinctionUtility) checkAndUpdateLastSentMap(key DistinctionErrorTypeEnum) bool {
func (d *distinctionUtility) checkAndUpdateLastSentMap(key DistinctionMetricTypeEnum) bool {
curTime := time.Now()
val, ok := d.lastSentMap[key]
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
// versions of the metric if we ever decide to change them.
func (d *distinctionUtility) generateUserMetric(
metricType DistinctionErrorTypeEnum,
metricType DistinctionMetricTypeEnum,
) proton.ObservabilityMetric {
schemaName, ok := errorSchemaMap[metricType]
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)
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
}
func (d *distinctionUtility) updateHeartbeatData(errType DistinctionErrorTypeEnum) {
func (d *distinctionUtility) updateHeartbeatData(errType DistinctionMetricTypeEnum) {
d.withUpdateHeartbeatDataLock(func() {
//nolint:exhaustive
switch errType {

View File

@ -45,7 +45,9 @@ type client struct {
// so we can easily pass them down to relevant components.
type Sender interface {
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 {
@ -325,11 +327,25 @@ func (s *Service) AddMetrics(metrics ...proton.ObservabilityMetric) {
// what number of events come from what number of users.
// 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.
func (s *Service) AddDistinctMetrics(errType DistinctionErrorTypeEnum, metrics ...proton.ObservabilityMetric) {
func (s *Service) AddDistinctMetrics(errType DistinctionMetricTypeEnum, metrics ...proton.ObservabilityMetric) {
metrics = s.distinctionUtility.generateDistinctMetrics(errType, 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.
func (s *Service) ModifyHeartbeatInterval(duration time.Duration) {
s.distinctionUtility.heartbeatTicker.Reset(duration)

View File

@ -66,3 +66,30 @@ func getEnabled(value bool) string {
}
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"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/services/observability"
)
const (
@ -29,6 +30,9 @@ const (
smtpSendSuccessSchemaName = "bridge_smtp_send_success_total"
smtpSendSuccessSchemaVersion = 1
smtpSubmissionRequestSchemaName = "bridge_smtp_send_request_total"
smtpSubmissionRequestSchemaVersion = 1
)
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/orderedtasks"
"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/useridentity"
"github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/ProtonMail/proton-bridge/v3/internal/usertypes"
"github.com/ProtonMail/proton-bridge/v3/pkg/cpc"
"github.com/sirupsen/logrus"
)
const (
newlyOpenedIMAPConnectionsThreshold = 300
)
type imapSessionCountProvider interface {
GetOpenIMAPSessionCount() int
GetRollingIMAPConnectionCount() int
}
type Service struct {
userID string
panicHandler async.PanicHandler
@ -59,6 +70,9 @@ type Service struct {
serverManager ServerManager
observabilitySender observability.Sender
imapSessionCountProvider imapSessionCountProvider
featureFlagValueProvider unleash.FeatureFlagValueProvider
}
func NewService(
@ -74,6 +88,8 @@ func NewService(
identityState *useridentity.State,
serverManager ServerManager,
observabilitySender observability.Sender,
imapSessionCountProvider imapSessionCountProvider,
featureFlagValueProvider unleash.FeatureFlagValueProvider,
) *Service {
subscriberName := fmt.Sprintf("smpt-%v", userID)
@ -99,7 +115,9 @@ func NewService(
addressMode: mode,
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) {
case *sendMailReq:
s.log.Debug("Received send mail request")
err := s.sendMail(ctx, r)
request.Reply(ctx, nil, err)
@ -252,15 +269,38 @@ type sendMailReq struct {
func (s *Service) sendMail(ctx context.Context, req *sendMailReq) error {
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()
defer func() {
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 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

View File

@ -19,10 +19,13 @@ package syncservice
import (
"context"
"errors"
"fmt"
"time"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/gluon/db"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/network"
"github.com/sirupsen/logrus"
@ -33,6 +36,10 @@ const NumSyncStages = 4
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
// user and used for every subsequent sync request.
type Handler struct {
@ -45,6 +52,7 @@ type Handler struct {
syncFinishedCh chan error
panicHandler async.PanicHandler
downloadCache *DownloadCache
sentryReporter reporter.Reporter
}
func NewHandler(
@ -54,6 +62,7 @@ func NewHandler(
state StateProvider,
log *logrus.Entry,
panicHandler async.PanicHandler,
sentryReporter reporter.Reporter,
) *Handler {
return &Handler{
client: client,
@ -65,6 +74,7 @@ func NewHandler(
regulator: regulator,
panicHandler: panicHandler,
downloadCache: newDownloadCache(),
sentryReporter: sentryReporter,
}
}
@ -91,12 +101,17 @@ func (t *Handler) Execute(
updateApplier UpdateApplier,
messageBuilder MessageBuilder,
coolDown time.Duration,
labelConflictChecker labelConflictChecker,
) {
t.log.Info("Sync triggered")
t.group.Once(func(ctx context.Context) {
start := time.Now()
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)
var err error
for {
@ -104,6 +119,20 @@ func (t *Handler) Execute(
t.log.WithError(err).Error("Sync aborted")
break
} 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")
sleepCtx(ctx, coolDown)
} else {

View File

@ -25,6 +25,7 @@ import (
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/sentry"
"github.com/bradenaw/juniper/xmaps"
"github.com/golang/mock/gomock"
"github.com/sirupsen/logrus"
@ -208,6 +209,13 @@ func TestTask_StateHasSyncedState(t *testing.T) {
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) {
const MessageTotal int64 = 50
const MessageID string = "foo"
@ -271,7 +279,7 @@ func TestTask_RepeatsOnSyncFailure(t *testing.T) {
tt.syncReporter.EXPECT().OnFinished(gomock.Any())
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())
}
@ -342,7 +350,7 @@ func newTestHandler(mockCtrl *gomock.Controller, userID string) thandler { // no
client := NewMockAPIClient(mockCtrl)
messageBuilder := NewMockMessageBuilder(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{
task: task,

View File

@ -29,6 +29,8 @@ import (
"time"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/gluon/db"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/gluon/watcher"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal"
@ -70,6 +72,8 @@ type Service struct {
eventPollWaitersLock sync.Mutex
eventSubscription events.Subscription
eventWatcher *watcher.Watcher[events.Event]
sentryReporter reporter.Reporter
}
func NewService(
@ -82,6 +86,7 @@ func NewService(
eventTimeout time.Duration,
panicHandler async.PanicHandler,
eventSubscription events.Subscription,
sentryReporter reporter.Reporter,
) *Service {
return &Service{
cpc: cpc.NewCPC(),
@ -99,6 +104,7 @@ func NewService(
panicHandler: panicHandler,
eventSubscription: eventSubscription,
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)
}
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.
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/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/events/mocks"
"github.com/ProtonMail/proton-bridge/v3/internal/sentry"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
)
@ -49,6 +50,7 @@ func TestServiceHandleEventError_SubscriberEventUnwrapping(t *testing.T) {
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
sentry.NullSentryReporter{},
)
lastEventID := "PrevEvent"
@ -87,6 +89,7 @@ func TestServiceHandleEventError_BadEventPutsServiceOnPause(t *testing.T) {
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
sentry.NullSentryReporter{},
)
service.Resume()
lastEventID := "PrevEvent"
@ -121,6 +124,7 @@ func TestServiceHandleEventError_BadEventFromPublishTimeout(t *testing.T) {
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
sentry.NullSentryReporter{},
)
lastEventID := "PrevEvent"
event := proton.Event{EventID: "MyEvent"}
@ -152,6 +156,7 @@ func TestServiceHandleEventError_NoBadEventCheck(t *testing.T) {
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
sentry.NullSentryReporter{},
)
lastEventID := "PrevEvent"
event := proton.Event{EventID: "MyEvent"}
@ -178,6 +183,7 @@ func TestServiceHandleEventError_JsonUnmarshalEventProducesUncategorizedErrorEve
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
sentry.NullSentryReporter{},
)
lastEventID := "PrevEvent"
event := proton.Event{EventID: "MyEvent"}

View File

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

View File

@ -27,6 +27,7 @@ import (
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
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/userevents/mocks"
"github.com/golang/mock/gomock"
@ -76,6 +77,7 @@ func TestService_EventIDLoadStore(t *testing.T) {
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
sentry.NullSentryReporter{},
)
_, err := service.Start(context.Background(), group)
@ -132,6 +134,7 @@ func TestService_RetryEventOnNonCatastrophicFailure(t *testing.T) {
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
sentry.NullSentryReporter{},
)
service.Subscribe(NewCallbackSubscriber("foo", EventHandler{MessageHandler: subscriber}))
@ -182,6 +185,7 @@ func TestService_OnBadEventServiceIsPaused(t *testing.T) {
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
sentry.NullSentryReporter{},
)
// Event publisher expectations.
@ -249,6 +253,7 @@ func TestService_UnsubscribeDuringEventHandlingDoesNotCauseDeadlock(t *testing.T
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
sentry.NullSentryReporter{},
)
subscription := NewCallbackSubscriber("foo", EventHandler{MessageHandler: subscriber})
@ -309,6 +314,7 @@ func TestService_UnsubscribeBeforeHandlingEventIsNotConsideredError(t *testing.T
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
sentry.NullSentryReporter{},
)
subscription := NewEventSubscriber("Foo")
@ -369,6 +375,7 @@ func TestService_WaitOnEventPublishAfterPause(t *testing.T) {
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
sentry.NullSentryReporter{},
)
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,
async.NoopPanicHandler{},
events.NewNullSubscription(),
sentry.NullSentryReporter{},
)
_, err := service.Start(context.Background(), group)

View File

@ -37,15 +37,32 @@ var pollJitter = 2 * time.Minute //nolint:gochecknoglobals
const filename = "unleash_flags"
const (
EventLoopNotificationDisabled = "InboxBridgeEventLoopNotificationDisabled"
IMAPAuthenticateCommandDisabled = "InboxBridgeImapAuthenticateCommandDisabled"
UserRemovalGluonDataCleanupDisabled = "InboxBridgeUserRemovalGluonDataCleanupDisabled"
UpdateUseNewVersionFileStructureDisabled = "InboxBridgeUpdateWithOsFilterDisabled"
EventLoopNotificationDisabled = "InboxBridgeEventLoopNotificationDisabled"
IMAPAuthenticateCommandDisabled = "InboxBridgeImapAuthenticateCommandDisabled"
UserRemovalGluonDataCleanupDisabled = "InboxBridgeUserRemovalGluonDataCleanupDisabled"
UpdateUseNewVersionFileStructureDisabled = "InboxBridgeUpdateWithOsFilterDisabled"
LabelConflictResolverDisabled = "InboxBridgeLabelConflictResolverDisabled"
SMTPSubmissionRequestSentryReportDisabled = "InboxBridgeSmtpSubmissionRequestSentryReportDisabled"
InternalLabelConflictResolverDisabled = "InboxBridgeUnexpectedFoldersLabelsStartupFixupDisabled"
ItnternalLabelConflictNonEmptyMailboxDeletion = "InboxBridgeUnknownNonEmptyMailboxDeletion"
)
type requestFeaturesFn func(ctx context.Context) (proton.FeatureFlagResult, error)
type GetFlagValueFn func(key string) bool
type FeatureFlagValueProvider interface {
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 {
panicHandler async.PanicHandler
timer *proton.Ticker

View File

@ -110,7 +110,7 @@ func New(
syncConfigDir string,
isNew bool,
notificationStore *notifications.Store,
getFlagValFn unleash.GetFlagValueFn,
featureFlagValueProvider unleash.FeatureFlagValueProvider,
) (*User, error) {
user, err := newImpl(
ctx,
@ -130,7 +130,7 @@ func New(
syncConfigDir,
isNew,
notificationStore,
getFlagValFn,
featureFlagValueProvider,
)
if err != nil {
// Cleanup any pending resources on error
@ -163,7 +163,7 @@ func newImpl(
syncConfigDir string,
isNew bool,
notificationStore *notifications.Store,
getFlagValueFn unleash.GetFlagValueFn,
featureFlagValueProvider unleash.FeatureFlagValueProvider,
) (*User, error) {
logrus.WithField("userID", apiUser.ID).Info("Creating new user")
@ -241,6 +241,7 @@ func newImpl(
5*time.Minute,
crashHandler,
eventSubscription,
reporter,
)
addressMode := usertypes.VaultToAddressMode(encVault.AddressMode())
@ -262,6 +263,8 @@ func newImpl(
identityState.Clone(),
smtpServerManager,
observabilityService,
imapServerManager,
featureFlagValueProvider,
)
user.imapService = imapservice.NewService(
@ -282,9 +285,10 @@ func newImpl(
user.maxSyncMemory,
showAllMail,
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.
// 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/proton-bridge/v3/internal/certs"
"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/notifications"
"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/telemetry/mocks"
"github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/tests"
"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()
nullIMAPServerManager := imapservice.NewNullIMAPServerManager()
nullSMTPServerManager := smtp.NewNullServerManager()
nullUnleashService := unleash.NewNullUnleashService()
user, err := New(
ctx,
vaultUser,
client,
nil,
sentry.NullSentryReporter{},
apiUser,
nil,
true,
@ -171,9 +174,7 @@ func withUser(tb testing.TB, ctx context.Context, _ *server.Server, m *proton.Ma
notifications.NewStore(func() (string, error) {
return "", nil
}),
func(_ string) bool {
return false
},
nullUnleashService,
)
require.NoError(tb, err)
defer user.Close()

View File

@ -89,8 +89,11 @@ func (r *reportRecorder) close() {
}
func (r *reportRecorder) assertEmpty() {
if !r.skipAssert {
r.assert.Empty(r.reports)
if !r.skipAssert && len(r.reports) > 0 {
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
}
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 {
if context == nil {
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
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"
And the user with username "[user:user1]" sends all possible gluon error observability metrics
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/syncmsgevents"
"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"
"github.com/ProtonMail/proton-bridge/v3/internal/services/syncservice/observabilitymetrics"
)
@ -188,3 +189,48 @@ func (s *scenario) SMTPSendSuccessObservabilityMetric(username string) error {
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
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)
// SMTP submission metric
ctx.Step(`^the user with username "([^"]*)" sends an SMTP send request observability metric$`, s.SMTPSendRequestObservabilityMetric)
// Gluon related metrics
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)
}