Compare commits

...

6 Commits

25 changed files with 584 additions and 110 deletions

View File

@ -3,6 +3,18 @@
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

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.21.0+git
BRIDGE_APP_VERSION?=3.21.2+git
APP_VERSION:=${BRIDGE_APP_VERSION}
APP_FULL_NAME:=Proton Mail Bridge
APP_VENDOR:=Proton AG

2
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.20250604083016-c6e17f8461b1
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

4
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.20250604083016-c6e17f8461b1 h1:FvkPBZF/M5GpZTy+hzhaheyi+Z5XWeZOL5GKVKqj85Y=
github.com/ProtonMail/gluon v0.17.1-0.20250604083016-c6e17f8461b1/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=

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

@ -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())

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) {
@ -302,6 +315,10 @@ func (n NullSentryReporter) ReportMessageWithContext(string, reporter.Context) e
return nil
}
func (n NullSentryReporter) ReportWarningWithContext(string, reporter.Context) error {
return nil
}
func (n NullSentryReporter) ReportExceptionWithContext(any, reporter.Context) error {
return nil
}

View File

@ -42,6 +42,7 @@ type gluonIDProvider interface {
type sentryReporter interface {
ReportMessageWithContext(string, reporter.Context) error
ReportWarningWithContext(string, reporter.Context) error
}
type apiClient interface {
@ -50,6 +51,8 @@ type apiClient interface {
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
@ -86,47 +89,62 @@ func (m *LabelConflictManager) generateMailboxFetcher(connectors []*Connector) m
}
}
type UserLabelConflictResolver interface {
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 userLabelConflictResolverImpl struct {
type labelConflictResolverImpl struct {
mailboxFetch mailboxFetcherFn
client apiClient
reporter sentryReporter
log *logrus.Entry
}
type nullUserLabelConflictResolverImpl struct {
type nullLabelConflictResolverImpl struct {
}
func (r *nullUserLabelConflictResolverImpl) ResolveConflict(_ context.Context, _ proton.Label, _ map[string]bool) (func() []imap.Update, error) {
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) NewUserConflictResolver(connectors []*Connector) UserLabelConflictResolver {
func (m *LabelConflictManager) NewConflictResolver(connectors []*Connector) LabelConflictResolver {
if m.featureFlagProvider.GetFlagValue(unleash.LabelConflictResolverDisabled) {
return &nullUserLabelConflictResolverImpl{}
return &nullLabelConflictResolverImpl{}
}
return &userLabelConflictResolverImpl{
return &labelConflictResolverImpl{
mailboxFetch: m.generateMailboxFetcher(connectors),
client: m.client,
reporter: m.reporter,
log: logrus.WithFields(logrus.Fields{
"pkg": "imapservice/userLabelConflictResolver",
"pkg": "imapservice/labelConflictResolver",
"numberOfConnectors": len(connectors),
}),
}
}
func (r *userLabelConflictResolverImpl) ResolveConflict(ctx context.Context, label proton.Label, visited map[string]bool) (func() []imap.Update, error) {
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.
@ -170,14 +188,14 @@ func (r *userLabelConflictResolverImpl) ResolveConflict(ctx context.Context, lab
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)
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)),
imap.NewMailboxDeleted(imap.MailboxID(mailboxData.RemoteID)), // Should this be with remote ID
newMailboxUpdatedOrCreated(imap.MailboxID(label.ID), GetMailboxName(label)),
}
}
@ -240,19 +258,22 @@ func hashLabelPaths(path []string) string {
}
type InternalLabelConflictResolver interface {
ResolveConflict(ctx context.Context) (func() []imap.Update, error)
ResolveConflict(ctx context.Context, apiLabels map[string]proton.Label) (func() []imap.Update, error)
}
type internalLabelConflictResolverImpl struct {
mailboxFetch mailboxFetcherFn
client apiClient
reporter sentryReporter
log *logrus.Entry
mailboxFetch mailboxFetcherFn
mailboxMessageCountFetch mailboxMessageCountFetcherFn
userLabelConflictResolver LabelConflictResolver
allowNonEmptyMailboxDeletion bool
client apiClient
reporter sentryReporter
log *logrus.Entry
}
type nullInternalLabelConflictResolver struct{}
func (r *nullInternalLabelConflictResolver) ResolveConflict(_ context.Context) (func() []imap.Update, error) {
func (r *nullInternalLabelConflictResolver) ResolveConflict(_ context.Context, _ map[string]proton.Label) (func() []imap.Update, error) {
return func() []imap.Update { return []imap.Update{} }, nil
}
@ -262,9 +283,12 @@ func (m *LabelConflictManager) NewInternalLabelConflictResolver(connectors []*Co
}
return &internalLabelConflictResolverImpl{
mailboxFetch: m.generateMailboxFetcher(connectors),
client: m.client,
reporter: m.reporter,
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),
@ -272,17 +296,17 @@ func (m *LabelConflictManager) NewInternalLabelConflictResolver(connectors []*Co
}
}
func (r *internalLabelConflictResolverImpl) ResolveConflict(ctx context.Context) (func() []imap.Update, error) {
var updateFns []func() []imap.Update
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} {
label := proton.Label{
internalLabel := proton.Label{
Path: []string{prefix},
ID: prefix,
Name: prefix,
}
mbox, err := r.mailboxFetch(ctx, label)
mbox, err := r.mailboxFetch(ctx, internalLabel)
if err != nil {
if db.IsErrNotFound(err) {
continue
@ -290,11 +314,75 @@ func (r *internalLabelConflictResolverImpl) ResolveConflict(ctx context.Context)
return nil, err
}
if mbox.RemoteID != label.ID {
// If the ID's don't match we should delete these.
fn := func() []imap.Update { return []imap.Update{imap.NewMailboxDeleted(imap.MailboxID(prefix))} }
updateFns = append(updateFns, fn)
// 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

@ -88,6 +88,11 @@ func (m *mockReporter) ReportMessageWithContext(msg string, ctx reporter.Context
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{
@ -121,7 +126,7 @@ func TestResolveConflict_UnexpectedLabelConflict(t *testing.T) {
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
resolver := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{}).
NewUserConflictResolver([]*imapservice.Connector{connector})
NewConflictResolver([]*imapservice.Connector{connector})
visited := make(map[string]bool)
_, err := resolver.ResolveConflict(ctx, label, visited)
@ -152,7 +157,7 @@ func TestResolveDiscrepancy_LabelDoesNotExist(t *testing.T) {
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewUserConflictResolver(connectors)
resolver := manager.NewConflictResolver(connectors)
visited := make(map[string]bool)
fn, err := resolver.ResolveConflict(ctx, label, visited)
@ -185,7 +190,7 @@ func TestResolveConflict_MailboxFetchError(t *testing.T) {
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
resolver := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{}).
NewUserConflictResolver([]*imapservice.Connector{connector})
NewConflictResolver([]*imapservice.Connector{connector})
visited := make(map[string]bool)
_, err := resolver.ResolveConflict(ctx, label, visited)
@ -223,7 +228,7 @@ func TestResolveDiscrepancy_ConflictingLabelDeletedRemotely(t *testing.T) {
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewUserConflictResolver(connectors)
resolver := manager.NewConflictResolver(connectors)
visited := make(map[string]bool)
fn, err := resolver.ResolveConflict(ctx, label, visited)
@ -266,7 +271,7 @@ func TestResolveDiscrepancy_LabelAlreadyCorrect(t *testing.T) {
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewUserConflictResolver(connectors)
resolver := manager.NewConflictResolver(connectors)
visited := make(map[string]bool)
fn, err := resolver.ResolveConflict(ctx, label, visited)
@ -295,7 +300,7 @@ func TestResolveConflict_DeepNestedPath(t *testing.T) {
connector := &imapservice.Connector{}
connector.SetAddrIDTest("addr-1")
resolver := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{}).
NewUserConflictResolver([]*imapservice.Connector{connector})
NewConflictResolver([]*imapservice.Connector{connector})
visited := make(map[string]bool)
fn, err := resolver.ResolveConflict(ctx, label, visited)
@ -351,7 +356,7 @@ func TestResolveLabelDiscrepancy_LabelSwap(t *testing.T) {
for _, label := range apiLabels {
mockClient.
On("GetLabel", mock.Anything, label.ID, []proton.LabelType{proton.LabelTypeFolder, proton.LabelTypeLabel}).
On("GetLabel", mock.Anything, label.ID, []proton.LabelType{proton.LabelTypeFolder, proton.LabelTypeLabel, proton.LabelTypeSystem}).
Return(label, nil)
}
@ -360,7 +365,7 @@ func TestResolveLabelDiscrepancy_LabelSwap(t *testing.T) {
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewUserConflictResolver(connectors)
resolver := manager.NewConflictResolver(connectors)
visited := make(map[string]bool)
fn, err := resolver.ResolveConflict(context.Background(), apiLabels[0], visited)
@ -444,7 +449,7 @@ func TestResolveLabelDiscrepancy_LabelSwapExtended(t *testing.T) {
for _, label := range apiLabels {
mockClient.
On("GetLabel", mock.Anything, label.ID, []proton.LabelType{proton.LabelTypeFolder, proton.LabelTypeLabel}).
On("GetLabel", mock.Anything, label.ID, []proton.LabelType{proton.LabelTypeFolder, proton.LabelTypeLabel, proton.LabelTypeSystem}).
Return(label, nil)
}
@ -453,7 +458,7 @@ func TestResolveLabelDiscrepancy_LabelSwapExtended(t *testing.T) {
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewUserConflictResolver(connectors)
resolver := manager.NewConflictResolver(connectors)
fn, err := resolver.ResolveConflict(context.Background(), apiLabels[0], make(map[string]bool))
require.NoError(t, err)
@ -529,7 +534,7 @@ func TestResolveLabelDiscrepancy_LabelSwapCyclic(t *testing.T) {
for _, label := range apiLabels {
mockClient.
On("GetLabel", mock.Anything, label.ID, []proton.LabelType{proton.LabelTypeFolder, proton.LabelTypeLabel}).
On("GetLabel", mock.Anything, label.ID, []proton.LabelType{proton.LabelTypeFolder, proton.LabelTypeLabel, proton.LabelTypeSystem}).
Return(label, nil)
}
@ -538,7 +543,7 @@ func TestResolveLabelDiscrepancy_LabelSwapCyclic(t *testing.T) {
connectors := []*imapservice.Connector{connector}
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewUserConflictResolver(connectors)
resolver := manager.NewConflictResolver(connectors)
fn, err := resolver.ResolveConflict(context.Background(), apiLabels[0], make(map[string]bool))
require.NoError(t, err)
@ -602,17 +607,17 @@ func TestResolveLabelDiscrepancy_LabelSwapCyclicWithDeletedLabel(t *testing.T) {
for _, label := range apiLabels {
mockClient.
On("GetLabel", mock.Anything, label.ID, []proton.LabelType{proton.LabelTypeFolder, proton.LabelTypeLabel}).
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}).Return(proton.Label{}, proton.ErrNoSuchLabel)
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.NewUserConflictResolver(connectors)
resolver := manager.NewConflictResolver(connectors)
fn, err := resolver.ResolveConflict(context.Background(), apiLabels[2], make(map[string]bool))
require.NoError(t, err)
@ -665,17 +670,17 @@ func TestResolveLabelDiscrepancy_LabelSwapCyclicWithDeletedLabel_KillSwitchEnabl
for _, label := range apiLabels {
mockClient.
On("GetLabel", mock.Anything, label.ID, []proton.LabelType{proton.LabelTypeFolder, proton.LabelTypeLabel}).
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}).Return(proton.Label{}, proton.ErrNoSuchLabel)
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.NewUserConflictResolver(connectors)
resolver := manager.NewConflictResolver(connectors)
fn, err := resolver.ResolveConflict(context.Background(), apiLabels[2], make(map[string]bool))
require.NoError(t, err)
@ -706,7 +711,8 @@ func TestInternalLabelConflictResolver_NoConflicts(t *testing.T) {
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewInternalLabelConflictResolver(connectors)
fn, err := resolver.ResolveConflict(ctx)
apiLabels := make(map[string]proton.Label)
fn, err := resolver.ResolveConflict(ctx, apiLabels)
assert.NoError(t, err)
updates := fn()
@ -735,81 +741,159 @@ func TestInternalLabelConflictResolver_CorrectIDs(t *testing.T) {
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewInternalLabelConflictResolver(connectors)
fn, err := resolver.ResolveConflict(ctx)
apiLabels := make(map[string]proton.Label)
fn, err := resolver.ResolveConflict(ctx, apiLabels)
assert.NoError(t, err)
updates := fn()
assert.Empty(t, updates)
}
func TestInternalLabelConflictResolver_ConflictingFoldersID(t *testing.T) {
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"}}, nil)
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)
fn, err := resolver.ResolveConflict(ctx)
// 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.MailboxDeleted)
deleted, ok := updates[0].(*imap.MailboxDeletedSilent)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID("Folders"), deleted.MailboxID)
assert.Equal(t, imap.MailboxID("wrong-id"), deleted.MailboxID)
}
func TestInternalLabelConflictResolver_BothConflicting(t *testing.T) {
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-folders-id", BridgeName: []string{"Folders"}}, nil)
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{RemoteID: "wrong-labels-id", BridgeName: []string{"Labels"}}, nil)
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)
fn, err := resolver.ResolveConflict(ctx)
assert.NoError(t, err)
// 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.Len(t, updates, 2)
assert.Empty(t, updates, 0)
}
updateOne, ok := updates[0].(*imap.MailboxDeleted)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID("Folders"), updateOne.MailboxID)
func TestInternalLabelConflictResolver_ConflictingAPILabelSameName(t *testing.T) {
ctx := context.Background()
updateTwo, ok := updates[1].(*imap.MailboxDeleted)
assert.True(t, ok)
assert.Equal(t, imap.MailboxID("Labels"), updateTwo.MailboxID)
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) {
@ -832,7 +916,8 @@ func TestInternalLabelConflictResolver_MailboxFetchError(t *testing.T) {
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderFalse{})
resolver := manager.NewInternalLabelConflictResolver(connectors)
_, err := resolver.ResolveConflict(ctx)
apiLabels := make(map[string]proton.Label)
_, err := resolver.ResolveConflict(ctx, apiLabels)
assert.Error(t, err)
assert.Contains(t, err.Error(), "database connection error")
}
@ -859,7 +944,16 @@ func TestNewInternalLabelConflictResolver_KillSwitchEnabled(t *testing.T) {
manager := imapservice.NewLabelConflictManager(mockLabelProvider, mockIDProvider, mockClient, mockReporter, ffProviderTrue{})
resolver := manager.NewInternalLabelConflictResolver(connectors)
fn, err := resolver.ResolveConflict(ctx)
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()

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,
}
}
@ -909,3 +918,12 @@ func (s *Connector) getSenderProtonAddress(p *parser.Parser) (proton.Address, er
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

@ -55,6 +55,7 @@ func (t labelDiscrepancyType) String() string {
type labelDiscrepancy struct {
labelName string
labelPath string
labelPathParsed string
labelID string
conflictingLabelName string
conflictingLabelID string
@ -76,10 +77,12 @@ func newLabelDiscrepancy(label proton.Label, mbox imap.MailboxData, dType labelD
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)
}
@ -93,9 +96,10 @@ func discrepanciesToContext(discrepancies []labelDiscrepancy) reporter.Context {
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_id"] = d.labelID
ctx[prefix+"label_path_parsed"] = d.labelPathParsed
ctx[prefix+"conflicting_label_name"] = d.conflictingLabelName
ctx[prefix+"conflicting_label_id"] = d.conflictingLabelID
}

View File

@ -40,7 +40,10 @@ type IMAPServerManager interface {
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
}
@ -77,6 +80,10 @@ func (n NullIMAPServerManager) GetUserMailboxByName(_ context.Context, _ string,
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
}

View File

@ -540,6 +540,7 @@ func (s *Service) buildConnectors() (map[string]*Connector, error) {
s.reporter,
s.showAllMail,
s.syncStateProvider,
s.serverManager,
)
return connectors, nil
@ -557,6 +558,7 @@ func (s *Service) buildConnectors() (map[string]*Connector, error) {
s.reporter,
s.showAllMail,
s.syncStateProvider,
s.serverManager,
)
}

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 {

View File

@ -90,7 +90,7 @@ func onLabelCreated(ctx context.Context, s *Service, event proton.LabelEvent) ([
wr.SetLabel(event.Label.ID, event.Label, "onLabelCreated")
labelConflictResolver := s.labelConflictManager.NewUserConflictResolver(maps.Values(s.connectors))
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
@ -150,7 +150,7 @@ func onLabelUpdated(ctx context.Context, s *Service, event proton.LabelEvent) ([
wr.SetLabel(apiLabel.ID, apiLabel, "onLabelUpdatedApiID")
// Resolve potential conflicts
labelConflictResolver := s.labelConflictManager.NewUserConflictResolver(maps.Values(s.connectors))
labelConflictResolver := s.labelConflictManager.NewConflictResolver(maps.Values(s.connectors))
conflictUpdatesGenerator, err := labelConflictResolver.ResolveConflict(ctx, apiLabel, make(map[string]bool))
if err != nil {
return updates, err

View File

@ -133,24 +133,31 @@ func (s *SyncUpdateApplier) SyncLabels(ctx context.Context, labels map[string]pr
func syncLabels(ctx context.Context, labels map[string]proton.Label, connectors []*Connector, labelConflictManager *LabelConflictManager) ([]imap.Update, error) {
var updates []imap.Update
userLabelConflictResolver := labelConflictManager.NewUserConflictResolver(connectors)
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} {
conflictUpdateGenerator, err := internalLabelConflictResolver.ResolveConflict(ctx)
if err != nil {
return updates, err
}
for _, updateCh := range connectors {
conflictUpdates := conflictUpdateGenerator()
updateCh.publishUpdate(ctx, conflictUpdates...)
updates = append(updates, conflictUpdates...)
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)
}
}

View File

@ -208,6 +208,10 @@ func (sm *Service) GetUserMailboxByName(ctx context.Context, addrID string, mail
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()
}

View File

@ -37,13 +37,14 @@ var pollJitter = 2 * time.Minute //nolint:gochecknoglobals
const filename = "unleash_flags"
const (
EventLoopNotificationDisabled = "InboxBridgeEventLoopNotificationDisabled"
IMAPAuthenticateCommandDisabled = "InboxBridgeImapAuthenticateCommandDisabled"
UserRemovalGluonDataCleanupDisabled = "InboxBridgeUserRemovalGluonDataCleanupDisabled"
UpdateUseNewVersionFileStructureDisabled = "InboxBridgeUpdateWithOsFilterDisabled"
LabelConflictResolverDisabled = "InboxBridgeLabelConflictResolverDisabled"
SMTPSubmissionRequestSentryReportDisabled = "InboxBridgeSmtpSubmissionRequestSentryReportDisabled"
InternalLabelConflictResolverDisabled = "InboxBridgeUnexpectedFoldersLabelsStartupFixupDisabled"
EventLoopNotificationDisabled = "InboxBridgeEventLoopNotificationDisabled"
IMAPAuthenticateCommandDisabled = "InboxBridgeImapAuthenticateCommandDisabled"
UserRemovalGluonDataCleanupDisabled = "InboxBridgeUserRemovalGluonDataCleanupDisabled"
UpdateUseNewVersionFileStructureDisabled = "InboxBridgeUpdateWithOsFilterDisabled"
LabelConflictResolverDisabled = "InboxBridgeLabelConflictResolverDisabled"
SMTPSubmissionRequestSentryReportDisabled = "InboxBridgeSmtpSubmissionRequestSentryReportDisabled"
InternalLabelConflictResolverDisabled = "InboxBridgeUnexpectedFoldersLabelsStartupFixupDisabled"
ItnternalLabelConflictNonEmptyMailboxDeletion = "InboxBridgeUnknownNonEmptyMailboxDeletion"
)
type FeatureFlagValueProvider interface {

View File

@ -146,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{}