fix(messaging): reset sync state when IMAP/SMTP/CalDAV credentials are updated (#20405)

## 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.
This commit is contained in:
Thomas Heinrichsdobler
2026-05-10 13:58:53 +02:00
committed by GitHub
parent a962cdc34f
commit 086830f81b
3 changed files with 242 additions and 11 deletions
@@ -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],
@@ -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,
});
});
@@ -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<MessageChannelEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
@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<MessagingMessageListFetchJobData>(
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<CalendarEventListFetchJobData>(
CalendarEventListFetchJob.name,
{ workspaceId, calendarChannelId: existingCalendarChannel.id },
);
}
return newOrExistingAccountId;
},
);