diff --git a/internal/bridge/user.go b/internal/bridge/user.go
index ff4ccbbc..c9c1d719 100644
--- a/internal/bridge/user.go
+++ b/internal/bridge/user.go
@@ -494,7 +494,7 @@ func (bridge *Bridge) addUser(
return fmt.Errorf("failed to add vault user: %w", err)
}
- if err := bridge.addUserWithVault(ctx, client, apiUser, vaultUser); err != nil {
+ if err := bridge.addUserWithVault(ctx, client, apiUser, vaultUser, isNew); err != nil {
if _, ok := err.(*resty.ResponseError); ok || isLogin {
logrus.WithError(err).Error("Failed to add user, clearing its secrets from vault")
@@ -529,6 +529,7 @@ func (bridge *Bridge) addUserWithVault(
client *proton.Client,
apiUser proton.User,
vault *vault.User,
+ isNew bool,
) error {
statsPath, err := bridge.locator.ProvideStatsPath()
if err != nil {
@@ -556,6 +557,7 @@ func (bridge *Bridge) addUserWithVault(
&bridgeEventSubscription{b: bridge},
bridge.syncService,
syncSettingsPath,
+ isNew,
)
if err != nil {
return fmt.Errorf("failed to create user: %w", err)
diff --git a/internal/services/syncservice/interfaces.go b/internal/services/syncservice/interfaces.go
index 39dd9d28..b8b28aa1 100644
--- a/internal/services/syncservice/interfaces.go
+++ b/internal/services/syncservice/interfaces.go
@@ -58,6 +58,10 @@ func (s Status) IsComplete() bool {
return s.HasLabels && s.HasMessages
}
+func (s Status) InProgress() bool {
+ return s.HasLabels || s.HasMessageCount
+}
+
// Regulator is an abstraction for the sync service, since it regulates the number of concurrent sync activities.
type Regulator interface {
Sync(ctx context.Context, stage *Job)
diff --git a/internal/user/user.go b/internal/user/user.go
index bb140ec4..93cd2b6a 100644
--- a/internal/user/user.go
+++ b/internal/user/user.go
@@ -105,6 +105,7 @@ func New(
eventSubscription events.Subscription,
syncService syncservice.Regulator,
syncConfigDir string,
+ isNew bool,
) (*User, error) {
user, err := newImpl(
ctx,
@@ -122,6 +123,7 @@ func New(
eventSubscription,
syncService,
syncConfigDir,
+ isNew,
)
if err != nil {
// Cleanup any pending resources on error
@@ -152,6 +154,7 @@ func newImpl(
eventSubscription events.Subscription,
syncService syncservice.Regulator,
syncConfigDir string,
+ isNew bool,
) (*User, error) {
logrus.WithField("userID", apiUser.ID).Info("Creating new user")
@@ -295,6 +298,14 @@ func newImpl(
return nil
})
+ // If it's not a fresh user check the eventID and evaluate whether it is valid. If it's a new user, we don't
+ // need to perform this check.
+ if !isNew {
+ if err := checkIrrecoverableEventID(ctx, encVault.EventID(), apiUser.ID, syncConfigDir, user); err != nil {
+ return nil, err
+ }
+ }
+
// Start Event Service
lastEventID, err := user.eventService.Start(ctx, user.serviceGroup)
if err != nil {
diff --git a/internal/user/user_check.go b/internal/user/user_check.go
new file mode 100644
index 00000000..c5b8d9fe
--- /dev/null
+++ b/internal/user/user_check.go
@@ -0,0 +1,68 @@
+// Copyright (c) 2023 Proton AG
+//
+// This file is part of Proton Mail Bridge.
+//
+// Proton Mail Bridge is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Proton Mail Bridge is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Proton Mail Bridge. If not, see .
+
+package user
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/ProtonMail/proton-bridge/v3/internal/events"
+ "github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice"
+)
+
+func checkIrrecoverableEventID(
+ ctx context.Context,
+ lastEventID,
+ userID,
+ syncConfigDir string,
+ publisher events.EventPublisher,
+) error {
+ // If we detect that the event ID stored in the vault got reset, the user is not a new account and
+ // we have started or finished syncing: this is an irrecoverable state and we should produce a bad event.
+ if lastEventID != "" {
+ return nil
+ }
+
+ syncConfigPath := imapservice.GetSyncConfigPath(syncConfigDir, userID)
+
+ syncState, err := imapservice.NewSyncState(syncConfigPath)
+ if err != nil {
+ return fmt.Errorf("failed to read imap sync state: %w", err)
+ }
+
+ syncStatus, err := syncState.GetSyncStatus(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to imap sync status: %w", err)
+ }
+
+ if syncStatus.IsComplete() || syncStatus.InProgress() {
+ publisher.PublishEvent(ctx, newEmptyEventIDBadEvent(userID))
+ }
+
+ return nil
+}
+
+func newEmptyEventIDBadEvent(userID string) events.UserBadEvent {
+ return events.UserBadEvent{
+ UserID: userID,
+ OldEventID: "",
+ NewEventID: "",
+ EventInfo: "EventID missing from vault",
+ Error: fmt.Errorf("eventID in vault is empty, when it shouldn't be"),
+ }
+}
diff --git a/internal/user/user_check_test.go b/internal/user/user_check_test.go
new file mode 100644
index 00000000..da22b357
--- /dev/null
+++ b/internal/user/user_check_test.go
@@ -0,0 +1,104 @@
+// Copyright (c) 2023 Proton AG
+//
+// This file is part of Proton Mail Bridge.
+//
+// Proton Mail Bridge is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Proton Mail Bridge is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Proton Mail Bridge. If not, see .
+
+package user
+
+import (
+ "context"
+ "testing"
+
+ "github.com/ProtonMail/proton-bridge/v3/internal/events/mocks"
+ "github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice"
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCheckIrrecoverableEventID_EventIDIsEmptyButNoSyncStarted(t *testing.T) {
+ tmpDir := t.TempDir()
+ userID := "foo"
+ mockCtrl := gomock.NewController(t)
+ publisher := mocks.NewMockEventPublisher(mockCtrl)
+
+ require.NoError(t, checkIrrecoverableEventID(context.Background(), "", userID, tmpDir, publisher))
+}
+
+func TestCheckIrrecoverableEventID_EventIDIsNotEmptyButNoSyncStarted(t *testing.T) {
+ tmpDir := t.TempDir()
+ userID := "foo"
+ mockCtrl := gomock.NewController(t)
+ publisher := mocks.NewMockEventPublisher(mockCtrl)
+
+ require.NoError(t, checkIrrecoverableEventID(context.Background(), "ffoofo", userID, tmpDir, publisher))
+}
+
+func TestCheckIrrecoverableEventID_EventIDIsEmptyButSyncStarted(t *testing.T) {
+ tmpDir := t.TempDir()
+ userID := "foo"
+ mockCtrl := gomock.NewController(t)
+ publisher := mocks.NewMockEventPublisher(mockCtrl)
+
+ publisher.EXPECT().PublishEvent(gomock.Any(), gomock.Eq(newEmptyEventIDBadEvent(userID)))
+
+ require.NoError(t, genSyncState(context.Background(), userID, tmpDir, false))
+ require.NoError(t, checkIrrecoverableEventID(context.Background(), "", userID, tmpDir, publisher))
+}
+
+func TestCheckIrrecoverableEventID_EventIDIsEmptyButSyncFinished(t *testing.T) {
+ tmpDir := t.TempDir()
+ userID := "foo"
+ mockCtrl := gomock.NewController(t)
+ publisher := mocks.NewMockEventPublisher(mockCtrl)
+
+ publisher.EXPECT().PublishEvent(gomock.Any(), gomock.Eq(newEmptyEventIDBadEvent(userID)))
+
+ require.NoError(t, genSyncState(context.Background(), userID, tmpDir, true))
+ require.NoError(t, checkIrrecoverableEventID(context.Background(), "", userID, tmpDir, publisher))
+}
+
+func TestCheckIrrecoverableEventID_EventIDIsNotEmptyButSyncFinished(t *testing.T) {
+ tmpDir := t.TempDir()
+ userID := "foo"
+ mockCtrl := gomock.NewController(t)
+ publisher := mocks.NewMockEventPublisher(mockCtrl)
+
+ require.NoError(t, genSyncState(context.Background(), userID, tmpDir, true))
+ require.NoError(t, checkIrrecoverableEventID(context.Background(), "some event", userID, tmpDir, publisher))
+}
+
+func genSyncState(ctx context.Context, userID, dir string, finished bool) error {
+ s, err := imapservice.NewSyncState(imapservice.GetSyncConfigPath(dir, userID))
+ if err != nil {
+ return err
+ }
+
+ if finished {
+ if err := s.SetHasLabels(ctx, true); err != nil {
+ return err
+ }
+ if err := s.SetHasMessages(ctx, true); err != nil {
+ return err
+ }
+ if err := s.SetMessageCount(ctx, 10); err != nil {
+ return err
+ }
+ } else {
+ if err := s.SetHasLabels(ctx, true); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/user/user_test.go b/internal/user/user_test.go
index 330fb791..a6503402 100644
--- a/internal/user/user_test.go
+++ b/internal/user/user_test.go
@@ -158,6 +158,7 @@ func withUser(tb testing.TB, ctx context.Context, _ *server.Server, m *proton.Ma
nullEventSubscription,
nil,
"",
+ true,
)
require.NoError(tb, err)
defer user.Close()