From 086830f81bd9a488dde1927f9a2483c1af1c10c3 Mon Sep 17 00:00:00 2001 From: Thomas Heinrichsdobler Date: Sun, 10 May 2026 13:58:53 +0200 Subject: [PATCH] fix(messaging): reset sync state when IMAP/SMTP/CalDAV credentials are updated (#20405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Updating credentials for an existing IMAP/SMTP/CalDAV connected account in **Settings → Accounts → Connection settings** has no effect on the sync. The save persists the new `connectionParameters`, but `messageChannel.syncStatus` / `messageChannel.syncStage` / `connectedAccount.authFailedAt` are left untouched, and no fetch job is queued. This matters most when the channel is in `FAILED_INSUFFICIENT_PERMISSIONS` (e.g. after Apple invalidates iCloud app-specific passwords, or on any other auth failure): `MessagingRelaunchFailedMessageChannelsCronJob` only retries `FAILED_UNKNOWN`, so the account is stuck on "Sync failed" forever despite the credentials now being correct. The only known workarounds are a direct DB update or deleting and recreating the account. #19273 fixed the frontend cache angle of credential editing; this PR fixes the backend half of the same UX (the channel state machine). ## Reproduce 1. Connect an IMAP/SMTP account. 2. Force an auth failure (e.g. revoke the app-specific password upstream). Wait until `messageChannel.syncStatus` flips to `FAILED_INSUFFICIENT_PERMISSIONS`. 3. Generate a fresh password, edit the account in **Settings → Accounts**, save. 4. Observe: account stays "Sync failed" indefinitely; `core.messageChannel.syncStatus` and `core.connectedAccount.authFailedAt` are unchanged; no IMAP connect attempt in the worker logs. ## Root cause `packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts → processAccount` saves the updated `connectionParameters` but never resets the sync state nor enqueues a fetch job. The OAuth providers handle this: | Reset step | `google-apis.service.ts` | `microsoft-apis.service.ts` | `imap-smtp-caldav-apis.service.ts` (before this PR) | |---|---|---|---| | `updateConnectedAccountOnReconnect` (clears `authFailedAt`) | yes | yes | — | | `accountsToReconnectService.removeAccountToReconnect` | yes | yes | — | | `resetAndMarkAsMessagesListFetchPending` | yes | yes | — | | Enqueue `MessagingMessageListFetchJob` | yes | yes | — | | `resetAndMarkAsCalendarEventListFetchPending` | yes | yes | — | | Enqueue `CalendarEventListFetchJob` | yes | yes | — | #12061 introduced this behaviour for Google/Microsoft. The IMAP service was added later and the equivalent reconnect plumbing was never ported. ## Fix Mirrors the Google/Microsoft pattern in `processAccount`: - **Inside** the transaction, when an account already exists: clear `authFailedAt` on the connected account. - **After** the transaction, when an existing account is being updated: - drop the account from `accountsToReconnect` user-vars, - if the message channel exists and IMAP is configured, call `resetAndMarkAsMessagesListFetchPending` and enqueue `MessagingMessageListFetchJob` (skipped while the channel is still `PENDING_CONFIGURATION`), - same logic for the calendar channel and `CalendarEventListFetchJob`. Wires `MessageChannelSyncStatusService`, `CalendarChannelSyncStatusService`, `AccountsToReconnectService` and the messaging/calendar queues into `IMAPAPIsModule`. ## Tests - Extended the existing `should preserve existing channels when updating account credentials` case to assert: `authFailedAt: null` is written within the transaction; `removeAccountToReconnect` is called with the resolved `userId`; `resetAndMarkAs*` and queue `add` are called for both channels. - New case: `should not queue fetch jobs for channels still in PENDING_CONFIGURATION`. - New case: `should not run reconnect logic when creating a brand new account`. I could not run the full server test suite locally (no `node_modules` checked out); relying on CI. ## Out of scope - Extending `UpdateConnectedAccountOnReconnectService` to a non-OAuth shape: kept inline to minimise the blast radius. Refactoring opportunity for a follow-up. - Behaviour when the user removes IMAP or CALDAV from the parameters on update (the channel currently lingers in its old state). Pre-existing and not made worse by this PR. --- .../imap-api/imap-apis.module.ts | 6 + .../imap-smtp-caldav-apis.service.spec.ts | 175 +++++++++++++++++- .../services/imap-smtp-caldav-apis.service.ts | 72 ++++++- 3 files changed, 242 insertions(+), 11 deletions(-) diff --git a/packages/twenty-server/src/modules/connected-account/imap-api/imap-apis.module.ts b/packages/twenty-server/src/modules/connected-account/imap-api/imap-apis.module.ts index 89870cb6b9..23686f0e68 100644 --- a/packages/twenty-server/src/modules/connected-account/imap-api/imap-apis.module.ts +++ b/packages/twenty-server/src/modules/connected-account/imap-api/imap-apis.module.ts @@ -12,7 +12,10 @@ import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channe import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module'; +import { CalendarCommonModule } from 'src/modules/calendar/common/calendar-common.module'; +import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; import { ImapSmtpCalDavAPIService } from 'src/modules/connected-account/services/imap-smtp-caldav-apis.service'; +import { MessagingCommonModule } from 'src/modules/messaging/common/messaging-common.module'; import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-folder-manager/messaging-folder-sync-manager.module'; @Module({ @@ -30,6 +33,9 @@ import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message- TwentyORMModule, FeatureFlagModule, AuthModule, + CalendarCommonModule, + ConnectedAccountModule, + MessagingCommonModule, MessagingFolderSyncManagerModule, ], providers: [ImapSmtpCalDavAPIService], diff --git a/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.spec.ts b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.spec.ts index 1838499544..d1de64c9ab 100644 --- a/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.spec.ts +++ b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.spec.ts @@ -1,17 +1,28 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { ConnectedAccountProvider } from 'twenty-shared/types'; +import { + CalendarChannelSyncStage, + ConnectedAccountProvider, + MessageChannelSyncStage, +} from 'twenty-shared/types'; import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service'; import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service'; import { type EmailAccountConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; -import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; -import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; -import { ImapSmtpCalDavAPIService } from 'src/modules/connected-account/services/imap-smtp-caldav-apis.service'; -import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity'; +import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; +import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service'; +import { CalendarEventListFetchJob } from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job'; +import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service'; +import { ImapSmtpCalDavAPIService } from 'src/modules/connected-account/services/imap-smtp-caldav-apis.service'; +import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service'; +import { MessagingMessageListFetchJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job'; import { SyncMessageFoldersService } from 'src/modules/messaging/message-folder-manager/services/sync-message-folders.service'; jest.mock('uuid', () => ({ @@ -43,9 +54,9 @@ describe('ImapSmtpCalDavAPIService', () => { const mockTransactionManagerSave = jest.fn(); const mockTransactionManager = { - getRepository: jest - .fn() - .mockReturnValue({ save: mockTransactionManagerSave }), + getRepository: jest.fn().mockReturnValue({ + save: mockTransactionManagerSave, + }), }; const mockConnectedAccountRepository = { @@ -83,6 +94,26 @@ describe('ImapSmtpCalDavAPIService', () => { createCalendarChannel: jest.fn().mockResolvedValue('mocked-uuid'), }; + const mockMessageQueueService = { + add: jest.fn(), + }; + + const mockCalendarQueueService = { + add: jest.fn(), + }; + + const mockAccountsToReconnectService = { + removeAccountToReconnect: jest.fn(), + }; + + const mockMessagingChannelSyncStatusService = { + resetAndMarkAsMessagesListFetchPending: jest.fn(), + }; + + const mockCalendarChannelSyncStatusService = { + resetAndMarkAsCalendarEventListFetchPending: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -134,6 +165,26 @@ describe('ImapSmtpCalDavAPIService', () => { syncMessageFolders: jest.fn().mockResolvedValue([]), }, }, + { + provide: getQueueToken(MessageQueue.messagingQueue), + useValue: mockMessageQueueService, + }, + { + provide: getQueueToken(MessageQueue.calendarQueue), + useValue: mockCalendarQueueService, + }, + { + provide: AccountsToReconnectService, + useValue: mockAccountsToReconnectService, + }, + { + provide: MessageChannelSyncStatusService, + useValue: mockMessagingChannelSyncStatusService, + }, + { + provide: CalendarChannelSyncStatusService, + useValue: mockCalendarChannelSyncStatusService, + }, ], }).compile(); @@ -185,6 +236,7 @@ describe('ImapSmtpCalDavAPIService', () => { connectionParameters: baseInput.connectionParameters, userWorkspaceId: 'user-workspace-id', workspaceId: 'workspace-id', + authFailedAt: null, }); expect( @@ -212,11 +264,13 @@ describe('ImapSmtpCalDavAPIService', () => { const existingMessageChannel = { id: 'existing-message-channel-id', connectedAccountId: 'existing-account-id', + syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING, } as MessageChannelEntity; const existingCalendarChannel = { id: 'existing-calendar-channel-id', connectedAccountId: 'existing-account-id', + syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING, } as CalendarChannelEntity; mockConnectedAccountRepository.findOne.mockResolvedValue(existingAccount); @@ -236,6 +290,16 @@ describe('ImapSmtpCalDavAPIService', () => { const inputWithConnectedAccountId = { ...baseInput, + connectionParameters: { + ...baseInput.connectionParameters, + CALDAV: { + host: 'caldav.example.com', + port: 443, + secure: true, + username: 'test@example.com', + password: 'password', + }, + } as EmailAccountConnectionParameters, connectedAccountId: 'existing-account-id', }; @@ -245,9 +309,10 @@ describe('ImapSmtpCalDavAPIService', () => { id: 'existing-account-id', handle: 'test@example.com', provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV, - connectionParameters: baseInput.connectionParameters, + connectionParameters: inputWithConnectedAccountId.connectionParameters, userWorkspaceId: 'user-workspace-id', workspaceId: 'workspace-id', + authFailedAt: null, }); expect( @@ -256,6 +321,97 @@ describe('ImapSmtpCalDavAPIService', () => { expect( mockCreateCalendarChannelService.createCalendarChannel, ).not.toHaveBeenCalled(); + + expect( + mockAccountsToReconnectService.removeAccountToReconnect, + ).toHaveBeenCalledWith('user-id', 'workspace-id', 'existing-account-id'); + + expect( + mockMessagingChannelSyncStatusService.resetAndMarkAsMessagesListFetchPending, + ).toHaveBeenCalledWith(['existing-message-channel-id'], 'workspace-id'); + expect(mockMessageQueueService.add).toHaveBeenCalledWith( + MessagingMessageListFetchJob.name, + { + workspaceId: 'workspace-id', + messageChannelId: 'existing-message-channel-id', + }, + ); + + expect( + mockCalendarChannelSyncStatusService.resetAndMarkAsCalendarEventListFetchPending, + ).toHaveBeenCalledWith(['existing-calendar-channel-id'], 'workspace-id'); + expect(mockCalendarQueueService.add).toHaveBeenCalledWith( + CalendarEventListFetchJob.name, + { + workspaceId: 'workspace-id', + calendarChannelId: 'existing-calendar-channel-id', + }, + ); + }); + + it('should leave channels in PENDING_CONFIGURATION untouched', async () => { + const existingAccount = { + id: 'existing-account-id', + handle: 'test@example.com', + userWorkspaceId: 'user-workspace-id', + provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV, + } as ConnectedAccountEntity; + + const existingMessageChannel = { + id: 'existing-message-channel-id', + connectedAccountId: 'existing-account-id', + syncStage: MessageChannelSyncStage.PENDING_CONFIGURATION, + } as MessageChannelEntity; + + mockConnectedAccountRepository.findOne.mockResolvedValue(existingAccount); + mockMessageChannelRepository.findOne.mockResolvedValue( + existingMessageChannel, + ); + mockCalendarChannelRepository.findOne.mockResolvedValue(null); + mockWorkspaceMemberRepository.findOne.mockResolvedValue({ + id: 'workspace-member-id', + userId: 'user-id', + }); + mockUserWorkspaceRepository.findOne.mockResolvedValue({ + id: 'user-workspace-id', + }); + + await service.processAccount({ + ...baseInput, + connectedAccountId: 'existing-account-id', + }); + + expect( + mockMessagingChannelSyncStatusService.resetAndMarkAsMessagesListFetchPending, + ).not.toHaveBeenCalled(); + expect(mockMessageQueueService.add).not.toHaveBeenCalled(); + }); + + it('should not run reconnect logic when creating a brand new account', async () => { + mockConnectedAccountRepository.findOne.mockResolvedValue(null); + mockMessageChannelRepository.findOne.mockResolvedValue(null); + mockCalendarChannelRepository.findOne.mockResolvedValue(null); + mockWorkspaceMemberRepository.findOne.mockResolvedValue({ + id: 'workspace-member-id', + userId: 'user-id', + }); + mockUserWorkspaceRepository.findOne.mockResolvedValue({ + id: 'user-workspace-id', + }); + + await service.processAccount(baseInput); + + expect( + mockAccountsToReconnectService.removeAccountToReconnect, + ).not.toHaveBeenCalled(); + expect( + mockMessagingChannelSyncStatusService.resetAndMarkAsMessagesListFetchPending, + ).not.toHaveBeenCalled(); + expect( + mockCalendarChannelSyncStatusService.resetAndMarkAsCalendarEventListFetchPending, + ).not.toHaveBeenCalled(); + expect(mockMessageQueueService.add).not.toHaveBeenCalled(); + expect(mockCalendarQueueService.add).not.toHaveBeenCalled(); }); it('should only create message channel when only IMAP is configured', async () => { @@ -455,6 +611,7 @@ describe('ImapSmtpCalDavAPIService', () => { connectionParameters: baseInput.connectionParameters, userWorkspaceId: 'user-workspace-id', workspaceId: 'workspace-id', + authFailedAt: null, }); }); diff --git a/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts index 011a2f5a11..2f02c4eb0e 100644 --- a/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts +++ b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts @@ -1,7 +1,11 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { ConnectedAccountProvider } from 'twenty-shared/types'; +import { + CalendarChannelSyncStage, + ConnectedAccountProvider, + MessageChannelSyncStage, +} from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { v4 } from 'uuid'; import { EntityManager, Repository } from 'typeorm'; @@ -10,6 +14,9 @@ import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-err import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service'; import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service'; import { type EmailAccountConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto'; +import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; @@ -18,8 +25,19 @@ import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspac import { getWorkspaceContext } from 'src/engine/twenty-orm/storage/orm-workspace-context.storage'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; import { resolveRolePermissionConfig } from 'src/engine/twenty-orm/utils/resolve-role-permission-config.util'; -import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { + CalendarEventListFetchJob, + type CalendarEventListFetchJobData, +} from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job'; +import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service'; +import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service'; +import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service'; +import { + MessagingMessageListFetchJob, + type MessagingMessageListFetchJobData, +} from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job'; import { SyncMessageFoldersService } from 'src/modules/messaging/message-folder-manager/services/sync-message-folders.service'; +import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; @Injectable() export class ImapSmtpCalDavAPIService { @@ -33,9 +51,16 @@ export class ImapSmtpCalDavAPIService { private readonly messageChannelRepository: Repository, @InjectRepository(UserWorkspaceEntity) private readonly userWorkspaceRepository: Repository, + @InjectMessageQueue(MessageQueue.messagingQueue) + private readonly messageQueueService: MessageQueueService, + @InjectMessageQueue(MessageQueue.calendarQueue) + private readonly calendarQueueService: MessageQueueService, private readonly createMessageChannelService: CreateMessageChannelService, private readonly createCalendarChannelService: CreateCalendarChannelService, private readonly syncMessageFoldersService: SyncMessageFoldersService, + private readonly accountsToReconnectService: AccountsToReconnectService, + private readonly messagingChannelSyncStatusService: MessageChannelSyncStatusService, + private readonly calendarChannelSyncStatusService: CalendarChannelSyncStatusService, ) {} async getImapSmtpCaldavConnectedAccount( @@ -154,6 +179,7 @@ export class ImapSmtpCalDavAPIService { connectionParameters: input.connectionParameters, userWorkspaceId, workspaceId, + authFailedAt: null, }); if (shouldCreateMessageChannel) { @@ -176,6 +202,14 @@ export class ImapSmtpCalDavAPIService { }, ); + if (isDefined(existingAccount)) { + await this.accountsToReconnectService.removeAccountToReconnect( + member.userId, + workspaceId, + newOrExistingAccountId, + ); + } + if (shouldCreateMessageChannel) { const newMessageChannel = await this.messageChannelRepository.findOne( { @@ -195,6 +229,40 @@ export class ImapSmtpCalDavAPIService { } } + if ( + isDefined(existingMessageChannel) && + isDefined(input.connectionParameters.IMAP) && + existingMessageChannel.syncStage !== + MessageChannelSyncStage.PENDING_CONFIGURATION + ) { + await this.messagingChannelSyncStatusService.resetAndMarkAsMessagesListFetchPending( + [existingMessageChannel.id], + workspaceId, + ); + + await this.messageQueueService.add( + MessagingMessageListFetchJob.name, + { workspaceId, messageChannelId: existingMessageChannel.id }, + ); + } + + if ( + isDefined(existingCalendarChannel) && + isDefined(input.connectionParameters.CALDAV) && + existingCalendarChannel.syncStage !== + CalendarChannelSyncStage.PENDING_CONFIGURATION + ) { + await this.calendarChannelSyncStatusService.resetAndMarkAsCalendarEventListFetchPending( + [existingCalendarChannel.id], + workspaceId, + ); + + await this.calendarQueueService.add( + CalendarEventListFetchJob.name, + { workspaceId, calendarChannelId: existingCalendarChannel.id }, + ); + } + return newOrExistingAccountId; }, );