IMAP Error handling enhancement and fix revoked authentication edge case (#17133)
## Changes - Removed complex retry logic in `ImapClientProvider` by delegating to root orchestrator - Added `parseImapAuthenticationError` for handling revoked authentication credentials previously the code existed in `parse-imap-error.util` but it didn't belong there and nor was not used in `ImapClientProvider` causing revoked channels to be stuck in limbo - Removed `parseImapError` from `*-error-handler.service.ts ` - Created `isImapNetworkError` for consistency with Gmail and Microsoft - `isImapNetworkError` is called at utility level for consistency
This commit is contained in:
+1
@@ -1,4 +1,5 @@
|
||||
export enum MessageNetworkExceptionCode {
|
||||
ECONNREFUSED = 'ECONNREFUSED',
|
||||
ECONNRESET = 'ECONNRESET',
|
||||
ENOTFOUND = 'ENOTFOUND',
|
||||
ECONNABORTED = 'ECONNABORTED',
|
||||
|
||||
+36
-80
@@ -7,6 +7,7 @@ import { CustomError, isDefined } from 'twenty-shared/utils';
|
||||
import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
import { MessageImportDriverExceptionCode } from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
|
||||
import { parseImapAuthenticationError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-imap-authentication-error.util';
|
||||
|
||||
type ConnectedAccountIdentifier = Pick<
|
||||
ConnectedAccountWorkspaceEntity,
|
||||
@@ -17,14 +18,22 @@ type ConnectedAccountIdentifier = Pick<
|
||||
export class ImapClientProvider {
|
||||
private readonly logger = new Logger(ImapClientProvider.name);
|
||||
|
||||
private static readonly RETRY_ATTEMPTS = 3;
|
||||
private static readonly RETRY_DELAY_MS = 1000;
|
||||
private static readonly CONNECTION_TIMEOUT_MS = 30000;
|
||||
private static readonly GREETING_TIMEOUT_MS = 16000;
|
||||
|
||||
async getClient(
|
||||
connectedAccount: ConnectedAccountIdentifier,
|
||||
): Promise<ImapFlow> {
|
||||
return this.createConnectionWithRetry(connectedAccount);
|
||||
try {
|
||||
return await this.createConnection(connectedAccount);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to establish IMAP connection for ${connectedAccount.handle}: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
|
||||
throw parseImapAuthenticationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async closeClient(client: ImapFlow): Promise<void> {
|
||||
@@ -36,34 +45,6 @@ export class ImapClientProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private async createConnectionWithRetry(
|
||||
connectedAccount: ConnectedAccountIdentifier,
|
||||
attempt = 1,
|
||||
): Promise<ImapFlow> {
|
||||
try {
|
||||
return await this.createConnection(connectedAccount);
|
||||
} catch (error) {
|
||||
if (attempt < ImapClientProvider.RETRY_ATTEMPTS) {
|
||||
const delay = ImapClientProvider.RETRY_DELAY_MS * attempt;
|
||||
|
||||
this.logger.warn(
|
||||
`IMAP connection attempt ${attempt} failed for ${connectedAccount.handle}, retrying in ${delay}ms: ${error.message}`,
|
||||
);
|
||||
|
||||
await this.delay(delay);
|
||||
|
||||
return this.createConnectionWithRetry(connectedAccount, attempt + 1);
|
||||
}
|
||||
|
||||
this.logger.error(
|
||||
`Failed to establish IMAP connection for ${connectedAccount.handle} after ${ImapClientProvider.RETRY_ATTEMPTS} attempts: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async createConnection(
|
||||
connectedAccount: ConnectedAccountIdentifier,
|
||||
): Promise<ImapFlow> {
|
||||
@@ -78,46 +59,33 @@ export class ImapClientProvider {
|
||||
(connectedAccount.connectionParameters as unknown as ImapSmtpCaldavParams) ||
|
||||
{};
|
||||
|
||||
let client: ImapFlow | null = null;
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
if (!isDefined(connectedAccount.handle)) {
|
||||
throw new CustomError(
|
||||
'Handle is required',
|
||||
MessageImportDriverExceptionCode.CHANNEL_MISCONFIGURED,
|
||||
);
|
||||
}
|
||||
|
||||
const client = new ImapFlow({
|
||||
host: connectionParameters.IMAP?.host || '',
|
||||
port: connectionParameters.IMAP?.port || 993,
|
||||
secure: connectionParameters.IMAP?.secure,
|
||||
auth: {
|
||||
user: isDefined(connectionParameters.IMAP?.username)
|
||||
? connectionParameters.IMAP?.username
|
||||
: connectedAccount.handle,
|
||||
pass: connectionParameters.IMAP?.password || '',
|
||||
},
|
||||
logger: false,
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
connectionTimeout: ImapClientProvider.CONNECTION_TIMEOUT_MS,
|
||||
greetingTimeout: ImapClientProvider.GREETING_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
try {
|
||||
client = new ImapFlow({
|
||||
host: connectionParameters.IMAP?.host || '',
|
||||
port: connectionParameters.IMAP?.port || 993,
|
||||
secure: connectionParameters.IMAP?.secure,
|
||||
auth: {
|
||||
user: isDefined(connectionParameters.IMAP?.username)
|
||||
? connectionParameters.IMAP?.username
|
||||
: connectedAccount.handle,
|
||||
pass: connectionParameters.IMAP?.password || '',
|
||||
},
|
||||
logger: false,
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
});
|
||||
|
||||
const connectionPromise = client.connect();
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(
|
||||
() => reject(new Error('Connection timeout')),
|
||||
ImapClientProvider.CONNECTION_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.race([connectionPromise, timeoutPromise]);
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
await client.connect();
|
||||
|
||||
this.logger.log(
|
||||
`Connected to IMAP server for ${connectedAccount.handle}`,
|
||||
@@ -125,25 +93,13 @@ export class ImapClientProvider {
|
||||
|
||||
return client;
|
||||
} catch (error) {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (client) {
|
||||
try {
|
||||
await client.logout();
|
||||
} catch (cleanupError) {
|
||||
this.logger.warn(
|
||||
`Failed to cleanup client after connection error: ${cleanupError.message}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await client.logout();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
-7
@@ -1,6 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { parseImapError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-imap-error.util';
|
||||
import { parseImapMessageListFetchError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-imap-message-list-fetch-error.util';
|
||||
|
||||
@Injectable()
|
||||
@@ -12,12 +11,6 @@ export class ImapMessageListFetchErrorHandler {
|
||||
`IMAP: Error fetching message list: ${JSON.stringify(error)}`,
|
||||
);
|
||||
|
||||
const networkError = parseImapError(error, { cause: error });
|
||||
|
||||
if (networkError) {
|
||||
throw networkError;
|
||||
}
|
||||
|
||||
throw parseImapMessageListFetchError(error, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
-8
@@ -1,6 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { parseImapError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-imap-error.util';
|
||||
import { parseImapMessagesImportError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-imap-messages-import-error.util';
|
||||
|
||||
@Injectable()
|
||||
@@ -11,13 +10,6 @@ export class ImapMessagesImportErrorHandler {
|
||||
this.logger.error(
|
||||
`IMAP: Error importing message ${messageExternalId}: ${JSON.stringify(error)}`,
|
||||
);
|
||||
|
||||
const networkError = parseImapError(error, { cause: error });
|
||||
|
||||
if (networkError) {
|
||||
throw networkError;
|
||||
}
|
||||
|
||||
throw parseImapMessagesImportError(error, messageExternalId, {
|
||||
cause: error,
|
||||
});
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { MessageNetworkExceptionCode } from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-network.exception';
|
||||
|
||||
const IMAPFLOW_TIMEOUT_ERROR_CODES = [
|
||||
'ETIMEOUT',
|
||||
'UPGRADE_TIMEOUT',
|
||||
'CONNECT_TIMEOUT',
|
||||
'GREETING_TIMEOUT',
|
||||
];
|
||||
|
||||
const IMAPFLOW_CONNECTION_ERROR_CODES = [
|
||||
'NoConnection',
|
||||
'EConnectionClosed',
|
||||
'ProxyError',
|
||||
'ClosedAfterConnectTLS',
|
||||
'ClosedAfterConnectText',
|
||||
];
|
||||
|
||||
const NODEJS_NETWORK_ERROR_CODES = [
|
||||
MessageNetworkExceptionCode.ECONNREFUSED,
|
||||
MessageNetworkExceptionCode.ECONNRESET,
|
||||
MessageNetworkExceptionCode.ENOTFOUND,
|
||||
MessageNetworkExceptionCode.ECONNABORTED,
|
||||
MessageNetworkExceptionCode.ETIMEDOUT,
|
||||
MessageNetworkExceptionCode.ERR_NETWORK,
|
||||
MessageNetworkExceptionCode.EHOSTUNREACH,
|
||||
];
|
||||
|
||||
export const isImapNetworkError = (error: Error): boolean => {
|
||||
const errorWithCode = error as { code?: string };
|
||||
|
||||
if (!isDefined(errorWithCode.code)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IMAPFLOW_TIMEOUT_ERROR_CODES.includes(errorWithCode.code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IMAPFLOW_CONNECTION_ERROR_CODES.includes(errorWithCode.code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
NODEJS_NETWORK_ERROR_CODES.includes(
|
||||
errorWithCode.code as MessageNetworkExceptionCode,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
MessageImportDriverException,
|
||||
MessageImportDriverExceptionCode,
|
||||
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
|
||||
import { type ImapFlowError } from 'src/modules/messaging/message-import-manager/drivers/imap/types/imap-error.type';
|
||||
import { isImapNetworkError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/is-imap-network-error.util';
|
||||
|
||||
export const parseImapAuthenticationError = (
|
||||
error: ImapFlowError,
|
||||
): MessageImportDriverException => {
|
||||
if (isImapNetworkError(error)) {
|
||||
return new MessageImportDriverException(
|
||||
`IMAP network error: ${error.message}`,
|
||||
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
if (error.authenticationFailed === true) {
|
||||
return new MessageImportDriverException(
|
||||
`IMAP authentication error: ${error.message}`,
|
||||
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
return new MessageImportDriverException(
|
||||
`Unknown IMAP authentication error: ${error.message}`,
|
||||
MessageImportDriverExceptionCode.UNKNOWN,
|
||||
{ cause: error },
|
||||
);
|
||||
};
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
import {
|
||||
MessageImportDriverException,
|
||||
MessageImportDriverExceptionCode,
|
||||
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
|
||||
import { MessageNetworkExceptionCode } from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-network.exception';
|
||||
import { isImapFlowError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/is-imap-flow-error.util';
|
||||
|
||||
export const parseImapError = (
|
||||
error: Error,
|
||||
options?: { cause?: Error },
|
||||
): MessageImportDriverException | null => {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isImapFlowError(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error.message.includes('Connection not available')) {
|
||||
return new MessageImportDriverException(
|
||||
`IMAP client not available: ${error.message}`,
|
||||
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
|
||||
{ cause: options?.cause || error },
|
||||
);
|
||||
}
|
||||
|
||||
if (error.message.includes('timeout')) {
|
||||
return new MessageImportDriverException(
|
||||
`IMAP connection timeout: ${error.message}`,
|
||||
MessageNetworkExceptionCode.ETIMEDOUT,
|
||||
{ cause: options?.cause || error },
|
||||
);
|
||||
}
|
||||
|
||||
if (error.code === 'ECONNREFUSED' || error.message === 'Failed to connect') {
|
||||
return new MessageImportDriverException(
|
||||
`IMAP connection error: ${error.message}`,
|
||||
MessageImportDriverExceptionCode.UNKNOWN_NETWORK_ERROR,
|
||||
{ cause: options?.cause || error },
|
||||
);
|
||||
}
|
||||
|
||||
if (error.serverResponseCode) {
|
||||
if (error.serverResponseCode === 'AUTHENTICATIONFAILED') {
|
||||
return new MessageImportDriverException(
|
||||
`IMAP authentication error: ${error.responseText || error.message}`,
|
||||
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||
{ cause: options?.cause || error },
|
||||
);
|
||||
}
|
||||
|
||||
if (error.serverResponseCode === 'NONEXISTENT') {
|
||||
return new MessageImportDriverException(
|
||||
`IMAP mailbox not found: ${error.responseText || error.message}`,
|
||||
MessageImportDriverExceptionCode.NOT_FOUND,
|
||||
{ cause: options?.cause || error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (error.authenticationFailed === true) {
|
||||
return new MessageImportDriverException(
|
||||
`IMAP authentication error: ${error.responseText || error.message}`,
|
||||
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||
{ cause: options?.cause || error },
|
||||
);
|
||||
}
|
||||
|
||||
if (error.message === 'Command failed') {
|
||||
if (error.responseText) {
|
||||
if (error.responseText.includes('Resource temporarily unavailable')) {
|
||||
return new MessageImportDriverException(
|
||||
`IMAP temporary error: ${error.responseText}`,
|
||||
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
|
||||
{ cause: options?.cause || error },
|
||||
);
|
||||
}
|
||||
|
||||
return new MessageImportDriverException(
|
||||
`IMAP command failed: ${error.responseText}`,
|
||||
MessageImportDriverExceptionCode.UNKNOWN,
|
||||
{ cause: options?.cause || error },
|
||||
);
|
||||
}
|
||||
|
||||
return new MessageImportDriverException(
|
||||
`IMAP command failed: ${error.message}`,
|
||||
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
|
||||
{ cause: options?.cause || error },
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
+9
@@ -3,6 +3,7 @@ import {
|
||||
MessageImportDriverExceptionCode,
|
||||
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
|
||||
import { isImapFlowError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/is-imap-flow-error.util';
|
||||
import { isImapNetworkError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/is-imap-network-error.util';
|
||||
|
||||
export const parseImapMessageListFetchError = (
|
||||
error: Error,
|
||||
@@ -16,6 +17,14 @@ export const parseImapMessageListFetchError = (
|
||||
);
|
||||
}
|
||||
|
||||
if (isImapNetworkError(error)) {
|
||||
return new MessageImportDriverException(
|
||||
`IMAP network error: ${error.message}`,
|
||||
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
|
||||
{ cause: options?.cause },
|
||||
);
|
||||
}
|
||||
|
||||
const errorMessage = error.message || '';
|
||||
|
||||
if (!isImapFlowError(error)) {
|
||||
|
||||
+9
@@ -3,6 +3,7 @@ import {
|
||||
MessageImportDriverExceptionCode,
|
||||
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
|
||||
import { isImapFlowError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/is-imap-flow-error.util';
|
||||
import { isImapNetworkError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/is-imap-network-error.util';
|
||||
|
||||
export const parseImapMessagesImportError = (
|
||||
error: Error,
|
||||
@@ -17,6 +18,14 @@ export const parseImapMessagesImportError = (
|
||||
);
|
||||
}
|
||||
|
||||
if (isImapNetworkError(error)) {
|
||||
return new MessageImportDriverException(
|
||||
`IMAP network error: ${error.message}`,
|
||||
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
|
||||
{ cause: options?.cause },
|
||||
);
|
||||
}
|
||||
|
||||
const errorMessage = error.message || '';
|
||||
|
||||
if (!isImapFlowError(error)) {
|
||||
|
||||
Reference in New Issue
Block a user