fix: handle Microsoft mailFolders 404 as insufficient permissions instead of NOT_FOUND
https://sonarly.com/issue/8521?type=bug Microsoft Graph API returns HTTP 404 on `GET /me/mailFolders` for certain deactivated/unlicensed accounts whose error message doesn't match the narrow "inactive, soft-deleted, or is hosted on-premise" check, causing the channel to be permanently marked as FAILED_UNKNOWN with a Sentry alert claiming this "should never happen". Fix: Two changes targeting the root cause at different depths: **1. `parseMicrosoftMessagesImportError` (deepest fix):** All HTTP 404 responses from Microsoft Graph API are now classified as `INSUFFICIENT_PERMISSIONS` instead of branching on a narrow string match. Previously, only 404s containing the exact substring "The mailbox is either inactive, soft-deleted, or is hosted on-premise." were treated as permission errors; all other 404s were classified as `NOT_FOUND`. Microsoft returns 404 for various forms of inaccessible resources (deactivated mailbox, license removed, deleted folder, etc.) with different error messages. The correct semantic for all of these is "the connected account cannot access this resource" — the same category as 401/403. The error message now includes both `error.code` and `error.message` for better diagnostics. **2. `handleNotFoundException` (safety net):** Removed the `MESSAGE_LIST_FETCH` special case that permanently failed the channel (`FAILED_UNKNOWN`) and fired a "should never happen" Sentry alert. All NOT_FOUND exceptions now route to `resetAndMarkAsMessagesListFetchPending` (retry), matching the pattern used by `SYNC_CURSOR_ERROR`. This is correct because the `processMessageListFetch` catch block wraps both folder sync and message list fetch — a NOT_FOUND from either step should trigger a retry, not a permanent failure. The now-unused `syncStep` parameter was removed from the method signature. With fix #1, Microsoft 404s will no longer reach `handleNotFoundException` at all (they'll go to `handleInsufficientPermissionsException`). Fix #2 ensures that if any other driver produces a NOT_FOUND during MESSAGE_LIST_FETCH, the channel retries instead of permanently failing.
This commit is contained in:
+2
-6
@@ -9,11 +9,7 @@ import { createAtomComponentFamilySelector } from '@/ui/utilities/state/jotai/ut
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
isDefined,
|
||||
isSamePlainDate,
|
||||
parseToPlainDateOrThrow,
|
||||
} from 'twenty-shared/utils';
|
||||
import { isDefined, isSamePlainDate } from 'twenty-shared/utils';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
export const calendarDayRecordIdsComponentFamilySelector =
|
||||
@@ -67,7 +63,7 @@ export const calendarDayRecordIdsComponentFamilySelector =
|
||||
|
||||
const recordDateAsPlainDateInTimeZone =
|
||||
fieldMetadataItem.type === FieldMetadataType.DATE
|
||||
? parseToPlainDateOrThrow(recordDate)
|
||||
? Temporal.PlainDate.from(recordDate)
|
||||
: Temporal.Instant.from(recordDate)
|
||||
.toZonedDateTimeISO(timeZone)
|
||||
.toPlainDate();
|
||||
|
||||
+1
-2
@@ -21,7 +21,6 @@ import { Temporal } from 'temporal-polyfill';
|
||||
import { type Nullable } from 'twenty-shared/types';
|
||||
import {
|
||||
isDefined,
|
||||
parseToPlainDateOrThrow,
|
||||
turnJSDateToPlainDate,
|
||||
type RelativeDateFilter,
|
||||
} from 'twenty-shared/utils';
|
||||
@@ -357,7 +356,7 @@ export const DatePicker = ({
|
||||
}: DatePickerProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const plainDate = isDefined(plainDateString)
|
||||
? parseToPlainDateOrThrow(plainDateString)
|
||||
? Temporal.PlainDate.from(plainDateString)
|
||||
: Temporal.Now.plainDateISO();
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
+2
-6
@@ -15,11 +15,7 @@ import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { type Nullable } from 'twenty-shared/types';
|
||||
import {
|
||||
isDefined,
|
||||
parseToPlainDateOrThrow,
|
||||
turnJSDateToPlainDate,
|
||||
} from 'twenty-shared/utils';
|
||||
import { isDefined, turnJSDateToPlainDate } from 'twenty-shared/utils';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
export const MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID =
|
||||
@@ -327,7 +323,7 @@ export const DatePickerWithoutCalendar = ({
|
||||
onClose,
|
||||
}: DatePickerWithoutCalendarProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const plainDate = isDefined(date) ? parseToPlainDateOrThrow(date) : null;
|
||||
const plainDate = isDefined(date) ? Temporal.PlainDate.from(date) : null;
|
||||
|
||||
const { closeDropdown: closeDropdownMonthSelect } = useCloseDropdown();
|
||||
const { closeDropdown: closeDropdownYearSelect } = useCloseDropdown();
|
||||
|
||||
+5
-17
@@ -36,23 +36,11 @@ export const parseMicrosoftMessagesImportError = (
|
||||
}
|
||||
|
||||
if (error.statusCode === 404) {
|
||||
if (
|
||||
error.message?.includes(
|
||||
'The mailbox is either inactive, soft-deleted, or is hosted on-premise.',
|
||||
)
|
||||
) {
|
||||
return new MessageImportDriverException(
|
||||
`Disabled, deleted, inactive or no licence Microsoft account - code:${error.code}`,
|
||||
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||
{ cause: options?.cause },
|
||||
);
|
||||
} else {
|
||||
return new MessageImportDriverException(
|
||||
`Not found - code:${error.code}`,
|
||||
MessageImportDriverExceptionCode.NOT_FOUND,
|
||||
{ cause: options?.cause },
|
||||
);
|
||||
}
|
||||
return new MessageImportDriverException(
|
||||
`Microsoft Graph API resource not found - code:${error.code} message:${error.message}`,
|
||||
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||
{ cause: options?.cause },
|
||||
);
|
||||
}
|
||||
|
||||
if (error.statusCode === 410) {
|
||||
|
||||
-27
@@ -57,7 +57,6 @@ export class MessageImportExceptionHandlerService {
|
||||
switch (exception.code) {
|
||||
case MessageImportDriverExceptionCode.NOT_FOUND:
|
||||
await this.handleNotFoundException(
|
||||
syncStep,
|
||||
messageChannel,
|
||||
workspaceId,
|
||||
);
|
||||
@@ -245,35 +244,9 @@ export class MessageImportExceptionHandlerService {
|
||||
}
|
||||
|
||||
private async handleNotFoundException(
|
||||
syncStep: MessageImportSyncStep,
|
||||
messageChannel: Pick<MessageChannelWorkspaceEntity, 'id'>,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
if (syncStep === MessageImportSyncStep.MESSAGE_LIST_FETCH) {
|
||||
await this.messageChannelSyncStatusService.markAsFailed(
|
||||
[messageChannel.id],
|
||||
workspaceId,
|
||||
MessageChannelSyncStatus.FAILED_UNKNOWN,
|
||||
);
|
||||
|
||||
this.exceptionHandlerService.captureExceptions(
|
||||
[
|
||||
new Error(
|
||||
'Not Found exception occurred while fetching message list, which should never happen',
|
||||
),
|
||||
],
|
||||
{
|
||||
additionalData: {
|
||||
messageChannelId: messageChannel.id,
|
||||
syncStep,
|
||||
},
|
||||
workspace: { id: workspaceId },
|
||||
},
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.messageChannelSyncStatusService.resetAndMarkAsMessagesListFetchPending(
|
||||
[messageChannel.id],
|
||||
workspaceId,
|
||||
|
||||
Reference in New Issue
Block a user