diff --git a/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts b/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts index 8604497f3f..9ced4dd7a9 100644 --- a/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts +++ b/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts @@ -594,6 +594,58 @@ test('syncSubscription renews webhook channel when expiring', async t => { t.truthy(updated?.channelExpiration); }); +test('syncSubscription falls back to polling when push is unsupported', async t => { + const user = await module.create(Mockers.User); + const account = await createAccount(user.id); + const subscription = await createSubscription(account.id, { + syncToken: 'sync-token', + }); + + const provider = new MockCalendarProvider(); + mock.method(provider, 'listEvents', async () => ({ + events: [], + nextSyncToken: 'next-sync', + })); + const watchMock = mock.method(provider, 'watchCalendar', async () => { + throw new CalendarProviderRequestError({ + status: 400, + message: JSON.stringify({ + error: { + errors: [ + { + domain: 'calendar', + reason: 'pushNotSupportedForRequestedResource', + message: 'Push notifications are not supported by this resource.', + }, + ], + code: 400, + message: 'Push notifications are not supported by this resource.', + }, + }), + }); + }); + mock.method(providerFactory, 'get', () => provider); + + await calendarService.syncSubscription(subscription.id); + + let updated = await models.calendarSubscription.get(subscription.id); + t.is(watchMock.mock.callCount(), 1); + t.is(updated?.customChannelId, null); + t.is(updated?.customResourceId, null); + t.truthy(updated?.channelExpiration); + t.true( + updated!.channelExpiration!.getTime() > + Date.now() + 365 * 24 * 60 * 60 * 1000 + ); + + await calendarService.syncSubscription(subscription.id); + + updated = await models.calendarSubscription.get(subscription.id); + t.is(watchMock.mock.callCount(), 1); + t.is(updated?.customChannelId, null); + t.is(updated?.customResourceId, null); +}); + test('syncSubscription keeps schedule moving when webhook renewal fails', async t => { const now = new Date('2026-01-01T00:00:00.000Z').getTime(); mock.method(Date, 'now', () => now); diff --git a/packages/backend/server/src/plugins/calendar/service.ts b/packages/backend/server/src/plugins/calendar/service.ts index 7fdf6683ce..1bf357d012 100644 --- a/packages/backend/server/src/plugins/calendar/service.ts +++ b/packages/backend/server/src/plugins/calendar/service.ts @@ -32,6 +32,9 @@ const SYNC_FAILURE_BACKOFF_BASE_MS = 5 * 60 * 1000; const SYNC_FAILURE_BACKOFF_MAX_MS = 6 * 60 * 60 * 1000; const DEFAULT_REFRESH_INTERVAL_MINUTES = 30; const CHANNEL_RENEW_RETRY_MS = 15 * 60 * 1000; +const UNSUPPORTED_PUSH_CHANNEL_EXPIRATION = new Date( + '9999-12-31T23:59:59.999Z' +); @Injectable() export class CalendarService { @@ -805,6 +808,19 @@ export class CalendarService { return false; } + private isPushUnsupportedError(error: unknown) { + if (!(error instanceof CalendarProviderRequestError)) { + return false; + } + + const status = error.data?.status ?? error.status; + if (status !== 400) return false; + + return error.data?.message?.includes( + 'pushNotSupportedForRequestedResource' + ); + } + private requireProvider(name: CalendarProviderName) { const provider = this.providerFactory.get(name); if (!provider) { @@ -817,6 +833,7 @@ export class CalendarService { subscription: { id: string; externalCalendarId: string; + displayName: string | null; customChannelId: string | null; customResourceId: string | null; channelExpiration: Date | null; @@ -855,13 +872,30 @@ export class CalendarService { const channelId = randomUUID(); const token = this.getWebhookToken(); - const result = await provider.watchCalendar({ - accessToken, - calendarId: subscription.externalCalendarId, - address, - token, - channelId, - }); + let result: Awaited>>; + try { + result = await provider.watchCalendar({ + accessToken, + calendarId: subscription.externalCalendarId, + address, + token, + channelId, + }); + } catch (error) { + if (!this.isPushUnsupportedError(error)) { + throw error; + } + + await this.models.calendarSubscription.updateChannel(subscription.id, { + customChannelId: null, + customResourceId: null, + channelExpiration: UNSUPPORTED_PUSH_CHANNEL_EXPIRATION, + }); + this.logger.log( + `Calendar subscription ${subscription.id} (${subscription.displayName ?? subscription.externalCalendarId}) does not support push notifications; falling back to polling` + ); + return; + } await this.models.calendarSubscription.updateChannel(subscription.id, { customChannelId: result.channelId,