// 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 . // Package bridge implements the Bridge, which acts as the backend to the UI. package bridge import ( "context" "crypto/tls" "errors" "fmt" "net/http" "os" "regexp" "runtime" "strings" "sync" "time" "github.com/Masterminds/semver/v3" "github.com/ProtonMail/gluon/async" imapEvents "github.com/ProtonMail/gluon/events" "github.com/ProtonMail/gluon/imap" "github.com/ProtonMail/gluon/reporter" "github.com/ProtonMail/gluon/watcher" "github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/proton-bridge/v3/internal/constants" "github.com/ProtonMail/proton-bridge/v3/internal/events" "github.com/ProtonMail/proton-bridge/v3/internal/focus" "github.com/ProtonMail/proton-bridge/v3/internal/identifier" "github.com/ProtonMail/proton-bridge/v3/internal/safe" "github.com/ProtonMail/proton-bridge/v3/internal/sentry" "github.com/ProtonMail/proton-bridge/v3/internal/services/imapsmtpserver" "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/syncservice" "github.com/ProtonMail/proton-bridge/v3/internal/telemetry" "github.com/ProtonMail/proton-bridge/v3/internal/unleash" "github.com/ProtonMail/proton-bridge/v3/internal/updater" "github.com/ProtonMail/proton-bridge/v3/internal/user" "github.com/ProtonMail/proton-bridge/v3/internal/vault" "github.com/ProtonMail/proton-bridge/v3/pkg/keychain" "github.com/bradenaw/juniper/xslices" "github.com/elastic/go-sysinfo/types" "github.com/go-resty/resty/v2" "github.com/sirupsen/logrus" ) var usernameChangeRegex = regexp.MustCompile(`^/Users/([^/]+)/`) type Bridge struct { // vault holds bridge-specific data, such as preferences and known users (authorized or not). vault *vault.Vault // users holds authorized users. users map[string]*user.User usersLock safe.RWMutex // api manages user API clients. api *proton.Manager proxyCtl ProxyController identifier identifier.Identifier // tlsConfig holds the bridge TLS config used by the IMAP and SMTP servers. tlsConfig *tls.Config // imapServer is the bridge's IMAP server. imapEventCh chan imapEvents.Event // updater is the bridge's updater. updater Updater installChLegacy chan installJobLegacy installCh chan installJob // heartbeat is the telemetry heartbeat for metrics. heartbeat *heartBeatState // curVersion is the current version of the bridge, // newVersion is the version that was installed by the updater. curVersion *semver.Version newVersion *semver.Version newVersionLock safe.RWMutex // keychains is the utils that own usable keychains found in the OS. keychains *keychain.List // focusService is used to raise the bridge window when needed. focusService *focus.Service // autostarter is the bridge's autostarter. autostarter Autostarter // locator is the bridge's locator. locator Locator // panicHandler panicHandler async.PanicHandler // reporter reporter reporter.Reporter // watchers holds all registered event watchers. watchers []*watcher.Watcher[events.Event] watchersLock sync.RWMutex // errors contains errors encountered during startup. errors []error // These control the bridge's IMAP and SMTP logging behaviour. logIMAPClient bool logIMAPServer bool logSMTP bool // These two variables keep track of the startup values for the two settings of the same name. // They are updated in the vault on startup so that we're sure they're updated in case of kill/crash, // but we need to keep their initial value for the current instance of bridge. firstStart bool lastVersion *semver.Version // tasks manages the bridge's goroutines. tasks *async.Group // goLoad triggers a load of disconnected users from the vault. goLoad func() // goUpdate triggers a check/install of updates. goUpdate func() serverManager *imapsmtpserver.Service syncService *syncservice.Service // unleashService is responsible for polling the feature flags and caching unleashService *unleash.Service // observabilityService is responsible for handling calls to the observability system observabilityService *observability.Service // notificationStore is used for notification deduplication notificationStore *notifications.Store // getHostVersion primarily used for testing the update logic - it should return an OS version getHostVersion func(host types.Host) string } var logPkg = logrus.WithField("pkg", "bridge") //nolint:gochecknoglobals // New creates a new bridge. func New( locator Locator, // the locator to provide paths to store data vault *vault.Vault, // the bridge's encrypted data store autostarter Autostarter, // the autostarter to manage autostart settings updater Updater, // the updater to fetch and install updates curVersion *semver.Version, // the current version of the bridge keychains *keychain.List, // usable keychains apiURL string, // the URL of the API to use cookieJar http.CookieJar, // the cookie jar to use identifier identifier.Identifier, // the identifier to keep track of the user agent tlsReporter TLSReporter, // the TLS reporter to report TLS errors roundTripper http.RoundTripper, // the round tripper to use for API requests proxyCtl ProxyController, // the DoH controller panicHandler async.PanicHandler, reporter reporter.Reporter, uidValidityGenerator imap.UIDValidityGenerator, heartBeatManager telemetry.HeartbeatManager, logIMAPClient, logIMAPServer bool, // whether to log IMAP client/server activity logSMTP bool, // whether to log SMTP activity ) (*Bridge, <-chan events.Event, error) { // api is the user's API manager. api := proton.New(newAPIOptions(apiURL, curVersion, cookieJar, roundTripper, panicHandler)...) // tasks holds all the bridge's background tasks. tasks := async.NewGroup(context.Background(), panicHandler) // imapEventCh forwards IMAP events from gluon instances to the bridge for processing. imapEventCh := make(chan imapEvents.Event) // bridge is the bridge. bridge, err := newBridge( context.Background(), tasks, imapEventCh, locator, vault, autostarter, updater, curVersion, keychains, panicHandler, reporter, api, identifier, proxyCtl, uidValidityGenerator, heartBeatManager, logIMAPClient, logIMAPServer, logSMTP, ) if err != nil { return nil, nil, fmt.Errorf("failed to create bridge: %w", err) } // Get an event channel for all events (individual events can be subscribed to later). eventCh, _ := bridge.GetEvents() // Initialize all of bridge's background tasks and operations. if err := bridge.init(tlsReporter); err != nil { return nil, nil, fmt.Errorf("failed to initialize bridge: %w", err) } return bridge, eventCh, nil } func newBridge( ctx context.Context, tasks *async.Group, imapEventCh chan imapEvents.Event, locator Locator, vault *vault.Vault, autostarter Autostarter, updater Updater, curVersion *semver.Version, keychains *keychain.List, panicHandler async.PanicHandler, reporter reporter.Reporter, api *proton.Manager, identifier identifier.Identifier, proxyCtl ProxyController, uidValidityGenerator imap.UIDValidityGenerator, heartbeatManager telemetry.HeartbeatManager, logIMAPClient, logIMAPServer, logSMTP bool, ) (*Bridge, error) { tlsConfig, err := loadTLSConfig(vault) if err != nil { return nil, fmt.Errorf("failed to load TLS config: %w", err) } firstStart := vault.GetFirstStart() if err := vault.SetFirstStart(false); err != nil { return nil, fmt.Errorf("failed to save first start indicator: %w", err) } lastVersion := vault.GetLastVersion() if err := vault.SetLastVersion(curVersion); err != nil { return nil, fmt.Errorf("failed to save last version indicator: %w", err) } identifier.SetClientString(vault.GetLastUserAgent()) focusService, err := focus.NewService(locator, curVersion, panicHandler) if err != nil { return nil, fmt.Errorf("failed to create focus service: %w", err) } unleashService := unleash.NewBridgeService(ctx, api, locator, panicHandler) observabilityService := observability.NewService(ctx, panicHandler) bridge := &Bridge{ vault: vault, users: make(map[string]*user.User), usersLock: safe.NewRWMutex(), api: api, proxyCtl: proxyCtl, identifier: identifier, tlsConfig: tlsConfig, imapEventCh: imapEventCh, updater: updater, installChLegacy: make(chan installJobLegacy), installCh: make(chan installJob), curVersion: curVersion, newVersion: curVersion, newVersionLock: safe.NewRWMutex(), keychains: keychains, panicHandler: panicHandler, reporter: reporter, heartbeat: newHeartBeatState(ctx, panicHandler), focusService: focusService, autostarter: autostarter, locator: locator, logIMAPClient: logIMAPClient, logIMAPServer: logIMAPServer, logSMTP: logSMTP, firstStart: firstStart, lastVersion: lastVersion, tasks: tasks, syncService: syncservice.NewService(panicHandler, observabilityService), unleashService: unleashService, observabilityService: observabilityService, notificationStore: notifications.NewStore(locator.ProvideNotificationsCachePath), getHostVersion: func(host types.Host) string { return host.Info().OS.Version }, } bridge.serverManager = imapsmtpserver.NewService(context.Background(), &bridgeSMTPSettings{b: bridge}, &bridgeIMAPSettings{b: bridge}, &bridgeEventPublisher{b: bridge}, panicHandler, reporter, uidValidityGenerator, &bridgeIMAPSMTPTelemetry{b: bridge}, observabilityService, ) // Check whether username has changed and correct (macOS only) bridge.verifyUsernameChange() if err := bridge.serverManager.Init(context.Background(), bridge.tasks, &bridgeEventSubscription{b: bridge}); err != nil { return nil, err } if heartbeatManager == nil { bridge.heartbeat.init(bridge, bridge) } else { bridge.heartbeat.init(bridge, heartbeatManager) } bridge.syncService.Run() bridge.unleashService.Run() bridge.observabilityService.Run(bridge) return bridge, nil } func (bridge *Bridge) init(tlsReporter TLSReporter) error { // Enable or disable the proxy at startup. if bridge.vault.GetProxyAllowed() { bridge.proxyCtl.AllowProxy() } else { bridge.proxyCtl.DisallowProxy() } // Handle connection up/down events. bridge.api.AddStatusObserver(func(status proton.Status) { logPkg.Info("API status changed: ", status) switch { case status == proton.StatusUp: bridge.publish(events.ConnStatusUp{}) bridge.tasks.Once(bridge.onStatusUp) case status == proton.StatusDown: bridge.publish(events.ConnStatusDown{}) bridge.tasks.Once(bridge.onStatusDown) } }) // If any call returns a bad version code, we need to update. bridge.api.AddErrorHandler(proton.AppVersionBadCode, func() { logPkg.Warn("App version is bad") bridge.publish(events.UpdateForced{}) }) // Ensure all outgoing headers have the correct user agent. bridge.api.AddPreRequestHook(func(_ *resty.Client, req *resty.Request) error { req.SetHeader("User-Agent", bridge.identifier.GetUserAgent()) return nil }) // Log all manager API requests (client requests are logged separately). bridge.api.AddPostRequestHook(func(_ *resty.Client, r *resty.Response) error { if _, ok := proton.ClientIDFromContext(r.Request.Context()); !ok { logrus.WithField("pkg", "gpa/manager").Infof("%v: %v %v", r.Status(), r.Request.Method, r.Request.URL) } return nil }) // Publish a TLS issue event if a TLS issue is encountered. bridge.tasks.Once(func(ctx context.Context) { async.RangeContext(ctx, tlsReporter.GetTLSIssueCh(), func(struct{}) { logPkg.Warn("TLS issue encountered") bridge.publish(events.TLSIssue{}) }) }) // Publish a raise event if the focus service is called. bridge.tasks.Once(func(ctx context.Context) { async.RangeContext(ctx, bridge.focusService.GetRaiseCh(), func(struct{}) { logPkg.Info("Focus service requested raise") bridge.publish(events.Raise{}) }) }) // Handle any IMAP events that are forwarded to the bridge from gluon. bridge.tasks.Once(func(ctx context.Context) { async.RangeContext(ctx, bridge.imapEventCh, func(event imapEvents.Event) { logPkg.WithField("event", fmt.Sprintf("%T", event)).Debug("Received IMAP event") bridge.handleIMAPEvent(event) }) }) // Attempt to load users from the vault when triggered. bridge.goLoad = bridge.tasks.Trigger(func(ctx context.Context) { if err := bridge.loadUsers(ctx); err != nil { logPkg.WithError(err).Error("Failed to load users") if netErr := new(proton.NetError); !errors.As(err, &netErr) { sentry.ReportError(bridge.reporter, "Failed to load users", err) } return } bridge.publish(events.AllUsersLoaded{}) }) defer bridge.goLoad() // Check for updates when triggered. bridge.goUpdate = bridge.tasks.PeriodicOrTrigger(constants.UpdateCheckInterval, 0, func(ctx context.Context) { logPkg.Info("Checking for updates") var versionLegacy updater.VersionInfoLegacy var version updater.VersionInfo var err error useOldUpdateLogic := bridge.GetFeatureFlagValue(unleash.UpdateUseNewVersionFileStructureDisabled) if useOldUpdateLogic { versionLegacy, err = bridge.updater.GetVersionInfoLegacy(ctx, bridge.api, bridge.vault.GetUpdateChannel()) } else { version, err = bridge.updater.GetVersionInfo(ctx, bridge.api) } if err != nil { bridge.publish(events.UpdateCheckFailed{Error: err}) if errors.Is(err, updater.ErrVersionFileDownloadOrVerify) { logPkg.WithError(err).Error("Cannot download or verify the version file") if reporterErr := bridge.reporter.ReportMessageWithContext( "Cannot download or verify the version file", reporter.Context{"error": err}, ); reporterErr != nil { logPkg.WithError(reporterErr).Error("Failed to report version file check error") } } } else { if useOldUpdateLogic { bridge.handleUpdateLegacy(versionLegacy) } else { bridge.handleUpdate(version) } } }) defer bridge.goUpdate() // Install updates when available - based on old update logic bridge.tasks.Once(func(ctx context.Context) { async.RangeContext(ctx, bridge.installChLegacy, func(job installJobLegacy) { bridge.installUpdateLegacy(ctx, job) }) }) // Install updates when available - based on new update logic bridge.tasks.Once(func(ctx context.Context) { async.RangeContext(ctx, bridge.installCh, func(job installJob) { bridge.installUpdate(ctx, job) }) }) return nil } // GetEvents returns a channel of events of the given type. // If no types are supplied, all events are returned. func (bridge *Bridge) GetEvents(ofType ...events.Event) (<-chan events.Event, context.CancelFunc) { watcher := bridge.addWatcher(ofType...) return watcher.GetChannel(), func() { bridge.remWatcher(watcher) } } func (bridge *Bridge) PushError(err error) { bridge.errors = append(bridge.errors, err) } func (bridge *Bridge) GetErrors() []error { return bridge.errors } func (bridge *Bridge) Close(ctx context.Context) { logPkg.Info("Closing bridge") // Stop observability service bridge.observabilityService.Stop() // Stop heart beat before closing users. bridge.heartbeat.stop() // Close all users. safe.Lock(func() { for _, user := range bridge.users { user.Close() } }, bridge.usersLock) // Close the servers if err := bridge.serverManager.CloseServers(ctx); err != nil { logPkg.WithError(err).Error("Failed to close servers") } bridge.syncService.Close() // Stop all ongoing tasks. bridge.tasks.CancelAndWait() // Close the focus service. bridge.focusService.Close() // Close the unleash service. bridge.unleashService.Close() // Close the watchers. bridge.watchersLock.Lock() defer bridge.watchersLock.Unlock() for _, watcher := range bridge.watchers { watcher.Close() } bridge.watchers = nil } func (bridge *Bridge) publish(event events.Event) { bridge.watchersLock.RLock() defer bridge.watchersLock.RUnlock() logPkg.WithField("event", event).Debug("Publishing event") for _, watcher := range bridge.watchers { if watcher.IsWatching(event) { if ok := watcher.Send(event); !ok { logPkg.WithField("event", event).Warn("Failed to send event to watcher") } } } } func (bridge *Bridge) addWatcher(ofType ...events.Event) *watcher.Watcher[events.Event] { bridge.watchersLock.Lock() defer bridge.watchersLock.Unlock() watcher := watcher.New(bridge.panicHandler, ofType...) bridge.watchers = append(bridge.watchers, watcher) return watcher } func (bridge *Bridge) remWatcher(watcher *watcher.Watcher[events.Event]) { bridge.watchersLock.Lock() defer bridge.watchersLock.Unlock() idx := xslices.Index(bridge.watchers, watcher) if idx < 0 { return } bridge.watchers = append(bridge.watchers[:idx], bridge.watchers[idx+1:]...) watcher.Close() } func (bridge *Bridge) onStatusUp(_ context.Context) { logPkg.Info("Handling API status up") bridge.goLoad() } func (bridge *Bridge) onStatusDown(ctx context.Context) { logPkg.Info("Handling API status down") for backoff := time.Second; ; backoff = min(backoff*2, 30*time.Second) { select { case <-ctx.Done(): return case <-time.After(backoff): logPkg.Info("Pinging API") if err := bridge.api.Ping(ctx); err != nil { logPkg.WithError(err).Warn("Ping failed, API is still unreachable") } else { return } } } } func (bridge *Bridge) Repair() { var wg sync.WaitGroup userIDs := bridge.GetUserIDs() for _, userID := range userIDs { logPkg.Info("Initiating repair for userID:", userID) userInfo, err := bridge.GetUserInfo(userID) if err != nil { logPkg.WithError(err).Error("Failed getting user info for repair; ID:", userID) continue } if userInfo.State != Connected { logPkg.Info("User is not connected. Repair will be executed on following successful log in.", userID) if err := bridge.vault.GetUser(userID, func(user *vault.User) { if err := user.SetShouldSync(true); err != nil { logPkg.WithError(err).Error("Failed setting vault should sync for user:", userID) } }); err != nil { logPkg.WithError(err).Error("Unable to get user vault when scheduling repair:", userID) } continue } bridgeUser, ok := bridge.users[userID] if !ok { logPkg.Info("UserID does not exist in bridge user map", userID) continue } wg.Add(1) go func(userID string) { defer wg.Done() if err = bridgeUser.TriggerRepair(); err != nil { logPkg.WithError(err).Error("Failed re-syncing IMAP for userID", userID) } }(userID) } wg.Wait() } func loadTLSConfig(vault *vault.Vault) (*tls.Config, error) { cert, err := tls.X509KeyPair(vault.GetBridgeTLSCert()) if err != nil { return nil, err } return &tls.Config{ Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12, }, nil } func (bridge *Bridge) HasAPIConnection() bool { return bridge.api.GetStatus() == proton.StatusUp } // verifyUsernameChange - works only on macOS // it attempts to check whether a username change has taken place by comparing the gluon DB path (which is static and provided by bridge) // to the gluon Cache path - which can be modified by the user and is stored in the vault; // if a username discrepancy is detected, and the cache folder does not exist with the "old" username // then we verify whether the gluon cache exists using the "new" username (provided by the DB path in this case) // if so we modify the cache directory in the user vault. func (bridge *Bridge) verifyUsernameChange() { if runtime.GOOS != "darwin" { return } gluonDBPath, err := bridge.GetGluonDataDir() if err != nil { logPkg.WithError(err).Error("Failed to get gluon db path") return } gluonCachePath := bridge.GetGluonCacheDir() // If the cache folder exists even on another user account or is in `/Users/Shared` we would still be able to access it // though it depends on the permissions; this is an edge-case. if _, err := os.Stat(gluonCachePath); err == nil { return } newCacheDir := GetUpdatedCachePath(gluonDBPath, gluonCachePath) if newCacheDir == "" { return } if _, err := os.Stat(newCacheDir); err == nil { logPkg.Info("Username change detected. Trying to restore gluon cache directory") if err = bridge.vault.SetGluonDir(newCacheDir); err != nil { logPkg.WithError(err).Error("Failed to restore gluon cache directory") return } logPkg.Info("Successfully restored gluon cache directory") } } func GetUpdatedCachePath(gluonDBPath, gluonCachePath string) string { // If gluon cache is moved to an external drive; regex find will fail; as is expected cachePathMatches := usernameChangeRegex.FindStringSubmatch(gluonCachePath) if len(cachePathMatches) < 2 { return "" } cacheUsername := cachePathMatches[1] dbPathMatches := usernameChangeRegex.FindStringSubmatch(gluonDBPath) if len(dbPathMatches) < 2 { return "" } dbUsername := dbPathMatches[1] if cacheUsername == dbUsername { return "" } return strings.Replace(gluonCachePath, "/Users/"+cacheUsername+"/", "/Users/"+dbUsername+"/", 1) } func (bridge *Bridge) GetFeatureFlagValue(key string) bool { return bridge.unleashService.GetFlagValue(key) } func (bridge *Bridge) PushObservabilityMetric(metric proton.ObservabilityMetric) { bridge.observabilityService.AddMetrics(metric) } func (bridge *Bridge) PushDistinctObservabilityMetrics(errType observability.DistinctionErrorTypeEnum, metrics ...proton.ObservabilityMetric) { bridge.observabilityService.AddDistinctMetrics(errType, metrics...) } func (bridge *Bridge) ModifyObservabilityHeartbeatInterval(duration time.Duration) { bridge.observabilityService.ModifyHeartbeatInterval(duration) } func (bridge *Bridge) ReportMessageWithContext(message string, messageCtx reporter.Context) { if err := bridge.reporter.ReportMessageWithContext(message, messageCtx); err != nil { logPkg.WithFields(logrus.Fields{ "err": err, "sentryMessage": message, "messageCtx": messageCtx, }).Info("Error occurred when sending Report to Sentry") } } // GetUsers is only used for testing purposes. func (bridge *Bridge) GetUsers() map[string]*user.User { return bridge.users } // SetCurrentVersionTest - sets the current version of bridge; should only be used for tests. func (bridge *Bridge) SetCurrentVersionTest(version *semver.Version) { bridge.curVersion = version bridge.newVersion = version } // SetHostVersionGetterTest - sets the OS version helper func; only used for testing. func (bridge *Bridge) SetHostVersionGetterTest(fn func(host types.Host) string) { bridge.getHostVersion = fn } // SetRolloutPercentageTest - sets the rollout percentage; should only be used for testing. func (bridge *Bridge) SetRolloutPercentageTest(rollout float64) error { return bridge.vault.SetUpdateRollout(rollout) }