feat: SMTP Driver Integration (#12993)

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
neo773
2025-07-10 18:47:26 +05:30
committed by GitHub
parent fe9de195c3
commit aede38000e
50 changed files with 1358 additions and 484 deletions

View File

@ -14,7 +14,7 @@ FRONTEND_URL=http://localhost:3001
# REFRESH_TOKEN_EXPIRES_IN=90d
# FILE_TOKEN_EXPIRES_IN=1d
# MESSAGING_PROVIDER_GMAIL_ENABLED=false
# MESSAGING_PROVIDER_IMAP_ENABLED=false
# IS_IMAP_SMTP_CALDAV_ENABLED=false
# CALENDAR_PROVIDER_GOOGLE_ENABLED=false
# MESSAGING_PROVIDER_MICROSOFT_ENABLED=false
# CALENDAR_PROVIDER_MICROSOFT_ENABLED=false

View File

@ -11,7 +11,7 @@ FRONTEND_URL=http://localhost:3001
AUTH_GOOGLE_ENABLED=false
MESSAGING_PROVIDER_GMAIL_ENABLED=false
MESSAGING_PROVIDER_IMAP_ENABLED=false
IS_IMAP_SMTP_CALDAV_ENABLED=false
CALENDAR_PROVIDER_GOOGLE_ENABLED=false
MESSAGING_PROVIDER_MICROSOFT_ENABLED=false
CALENDAR_PROVIDER_MICROSOFT_ENABLED=false

View File

@ -96,7 +96,7 @@ describe('ClientConfigController', () => {
isGoogleMessagingEnabled: false,
isGoogleCalendarEnabled: false,
isConfigVariablesInDbEnabled: false,
isIMAPMessagingEnabled: false,
isImapSmtpCaldavEnabled: false,
calendarBookingPageId: undefined,
};

View File

@ -178,7 +178,7 @@ export class ClientConfig {
isConfigVariablesInDbEnabled: boolean;
@Field(() => Boolean)
isIMAPMessagingEnabled: boolean;
isImapSmtpCaldavEnabled: boolean;
@Field(() => String, { nullable: true })
calendarBookingPageId?: string;

View File

@ -138,8 +138,8 @@ export class ClientConfigService {
isConfigVariablesInDbEnabled: this.twentyConfigService.get(
'IS_CONFIG_VARIABLES_IN_DB_ENABLED',
),
isIMAPMessagingEnabled: this.twentyConfigService.get(
'MESSAGING_PROVIDER_IMAP_ENABLED',
isImapSmtpCaldavEnabled: this.twentyConfigService.get(
'IS_IMAP_SMTP_CALDAV_ENABLED',
),
calendarBookingPageId: this.twentyConfigService.get(
'CALENDAR_BOOKING_PAGE_ID',

View File

@ -13,12 +13,13 @@ export type PublicFeatureFlag = {
export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [
{
key: FeatureFlagKey.IS_IMAP_ENABLED,
key: FeatureFlagKey.IS_IMAP_SMTP_CALDAV_ENABLED,
metadata: {
label: 'IMAP',
label: 'IMAP, SMTP, CalDAV',
description:
'Easily add email accounts from any provider that supports IMAP (and soon, send emails with SMTP)',
imagePath: 'https://twenty.com/images/lab/is-imap-enabled.png',
'Easily add email accounts from any provider that supports IMAP, send emails with SMTP (and soon, sync calendars with CalDAV)',
imagePath:
'https://twenty.com/images/lab/is-imap-smtp-caldav-enabled.png',
},
},
...(process.env.CLOUDFLARE_API_KEY

View File

@ -5,7 +5,7 @@ export enum FeatureFlagKey {
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',

View File

@ -14,9 +14,6 @@ export class ConnectionParameters {
@Field(() => Number)
port: number;
@Field(() => String)
username: string;
/**
* Note: This field is stored in plain text in the database.
* While encrypting it could provide an extra layer of defense, we have decided not to,
@ -37,9 +34,6 @@ export class ConnectionParametersOutput {
@Field(() => Number)
port: number;
@Field(() => String)
username: string;
@Field(() => String)
password: string;

View File

@ -1,4 +1,10 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import {
HttpException,
HttpStatus,
UseFilters,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { ConnectedAccountProvider } from 'twenty-shared/types';
@ -39,36 +45,6 @@ export class ImapSmtpCaldavResolver {
private readonly mailConnectionValidatorService: ImapSmtpCaldavValidatorService,
) {}
private async checkIfFeatureEnabled(
workspaceId: string,
accountType: AccountType,
): Promise<void> {
if (accountType.type === 'IMAP') {
const isImapEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_IMAP_ENABLED,
workspaceId,
);
if (!isImapEnabled) {
throw new UserInputError(
'IMAP feature is not enabled for this workspace',
);
}
}
if (accountType.type === 'SMTP') {
throw new UserInputError(
'SMTP feature is not enabled for this workspace',
);
}
if (accountType.type === 'CALDAV') {
throw new UserInputError(
'CALDAV feature is not enabled for this workspace',
);
}
}
@Query(() => ConnectedImapSmtpCaldavAccount)
@UseGuards(WorkspaceAuthGuard)
async getConnectedImapSmtpCaldavAccount(
@ -111,7 +87,18 @@ export class ImapSmtpCaldavResolver {
@AuthWorkspace() workspace: Workspace,
@Args('id', { nullable: true }) id?: string,
): Promise<ImapSmtpCaldavConnectionSuccess> {
await this.checkIfFeatureEnabled(workspace.id, accountType);
const isImapSmtpCaldavFeatureFlagEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_IMAP_SMTP_CALDAV_ENABLED,
workspace.id,
);
if (!isImapSmtpCaldavFeatureFlagEnabled) {
throw new HttpException(
'IMAP, SMTP, CalDAV feature is not enabled for this workspace',
HttpStatus.FORBIDDEN,
);
}
const validatedParams =
this.mailConnectionValidatorService.validateProtocolConnectionParams(
@ -119,6 +106,7 @@ export class ImapSmtpCaldavResolver {
);
await this.ImapSmtpCaldavConnectionService.testImapSmtpCaldav(
handle,
validatedParams,
accountType.type,
);

View File

@ -10,7 +10,6 @@ export class ImapSmtpCaldavValidatorService {
private readonly protocolConnectionSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.number().int().positive('Port must be a positive number'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
secure: z.boolean().optional(),
});
@ -19,7 +18,10 @@ export class ImapSmtpCaldavValidatorService {
params: ConnectionParameters,
): ConnectionParameters {
if (!params) {
throw new UserInputError('Protocol connection parameters are required');
throw new UserInputError('Protocol connection parameters are required', {
userFriendlyMessage:
'Please provide connection details to configure your email account.',
});
}
try {
@ -32,10 +34,17 @@ export class ImapSmtpCaldavValidatorService {
throw new UserInputError(
`Protocol connection validation failed: ${errorMessages}`,
{
userFriendlyMessage:
'Please check your connection settings. Make sure the server host, port, and password are correct.',
},
);
}
throw new UserInputError('Protocol connection validation failed');
throw new UserInputError('Protocol connection validation failed', {
userFriendlyMessage:
'There was an issue with your connection settings. Please try again.',
});
}
}
}

View File

@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { ImapFlow } from 'imapflow';
import { createTransport } from 'nodemailer';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@ -19,17 +20,16 @@ export class ImapSmtpCaldavService {
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
async testImapConnection(params: ConnectionParameters): Promise<boolean> {
if (!params.host || !params.username || !params.password) {
throw new UserInputError('Missing required IMAP connection parameters');
}
async testImapConnection(
handle: string,
params: ConnectionParameters,
): Promise<boolean> {
const client = new ImapFlow({
host: params.host,
port: params.port,
secure: params.secure ?? true,
auth: {
user: params.username,
user: handle,
pass: params.password,
},
logger: false,
@ -57,16 +57,27 @@ export class ImapSmtpCaldavService {
if (error.authenticationFailed) {
throw new UserInputError(
'IMAP authentication failed. Please check your credentials.',
{
userFriendlyMessage:
"We couldn't log in to your email account. Please check your email address and password, then try again.",
},
);
}
if (error.code === 'ECONNREFUSED') {
throw new UserInputError(
`IMAP connection refused. Please verify server and port.`,
{
userFriendlyMessage:
"We couldn't connect to your email server. Please check your server settings and try again.",
},
);
}
throw new UserInputError(`IMAP connection failed: ${error.message}`);
throw new UserInputError(`IMAP connection failed: ${error.message}`, {
userFriendlyMessage:
'We encountered an issue connecting to your email account. Please check your settings and try again.',
});
} finally {
if (client.authenticated) {
await client.logout();
@ -74,36 +85,70 @@ export class ImapSmtpCaldavService {
}
}
async testSmtpConnection(params: ConnectionParameters): Promise<boolean> {
this.logger.log('SMTP connection testing not yet implemented', params);
async testSmtpConnection(
handle: string,
params: ConnectionParameters,
): Promise<boolean> {
const transport = createTransport({
host: params.host,
port: params.port,
auth: {
user: handle,
pass: params.password,
},
tls: {
rejectUnauthorized: false,
},
});
try {
await transport.verify();
} catch (error) {
this.logger.error(
`SMTP connection failed: ${error.message}`,
error.stack,
);
throw new UserInputError(`SMTP connection failed: ${error.message}`, {
userFriendlyMessage:
"We couldn't connect to your outgoing email server. Please check your SMTP settings and try again.",
});
}
return true;
}
async testCaldavConnection(params: ConnectionParameters): Promise<boolean> {
async testCaldavConnection(
handle: string,
params: ConnectionParameters,
): Promise<boolean> {
this.logger.log('CALDAV connection testing not yet implemented', params);
return true;
}
async testImapSmtpCaldav(
handle: string,
params: ConnectionParameters,
accountType: AccountType,
): Promise<boolean> {
if (accountType === 'IMAP') {
return this.testImapConnection(params);
return this.testImapConnection(handle, params);
}
if (accountType === 'SMTP') {
return this.testSmtpConnection(params);
return this.testSmtpConnection(handle, params);
}
if (accountType === 'CALDAV') {
return this.testCaldavConnection(params);
return this.testCaldavConnection(handle, params);
}
throw new UserInputError(
'Invalid account type. Must be one of: IMAP, SMTP, CALDAV',
{
userFriendlyMessage:
'Please select a valid connection type (IMAP, SMTP, or CalDAV) and try again.',
},
);
}

View File

@ -1,7 +1,6 @@
export type ConnectionParameters = {
host: string;
port: number;
username: string;
password: string;
secure?: boolean;
};
@ -9,7 +8,6 @@ export type ConnectionParameters = {
export type AccountType = 'IMAP' | 'SMTP' | 'CALDAV';
export type ImapSmtpCaldavParams = {
handle: string;
IMAP?: ConnectionParameters;
SMTP?: ConnectionParameters;
CALDAV?: ConnectionParameters;

View File

@ -148,7 +148,7 @@ export class ConfigVariables {
description: 'Enable or disable the IMAP messaging integration',
type: ConfigVariableType.BOOLEAN,
})
MESSAGING_PROVIDER_IMAP_ENABLED = false;
IS_IMAP_SMTP_CALDAV_ENABLED = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.MicrosoftAuth,

View File

@ -46,7 +46,7 @@ export const seedFeatureFlags = async (
value: false,
},
{
key: FeatureFlagKey.IS_IMAP_ENABLED,
key: FeatureFlagKey.IS_IMAP_SMTP_CALDAV_ENABLED,
workspaceId: workspaceId,
value: true,
},

View File

@ -88,6 +88,20 @@ export class ImapSmtpCalDavAPIService {
workspaceId,
});
let shouldEnableSync = false;
if (connectedAccount) {
const hadOnlySmtp =
connectedAccount.connectionParameters?.SMTP &&
!connectedAccount.connectionParameters?.IMAP &&
!connectedAccount.connectionParameters?.CALDAV;
const isAddingImapOrCaldav =
input.accountType === 'IMAP' || input.accountType === 'CALDAV';
shouldEnableSync = Boolean(hadOnlySmtp && isAddingImapOrCaldav);
}
await workspaceDataSource.transaction(async () => {
if (!existingAccountId) {
const newConnectedAccount = await connectedAccountRepository.save(
@ -129,7 +143,10 @@ export class ImapSmtpCalDavAPIService {
connectedAccountId: newOrExistingConnectedAccountId,
type: MessageChannelType.EMAIL,
handle,
syncStatus: MessageChannelSyncStatus.ONGOING,
isSyncEnabled: shouldEnableSync,
syncStatus: shouldEnableSync
? MessageChannelSyncStatus.ONGOING
: MessageChannelSyncStatus.NOT_SYNCED,
},
{},
);
@ -200,9 +217,12 @@ export class ImapSmtpCalDavAPIService {
},
{
syncStage: MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING,
syncStatus: null,
syncStatus: shouldEnableSync
? MessageChannelSyncStatus.ONGOING
: MessageChannelSyncStatus.NOT_SYNCED,
syncCursor: '',
syncStageStartedAt: null,
isSyncEnabled: shouldEnableSync,
},
);
@ -227,22 +247,24 @@ export class ImapSmtpCalDavAPIService {
}
});
if (this.twentyConfigService.get('MESSAGING_PROVIDER_IMAP_ENABLED')) {
const messageChannels = await messageChannelRepository.find({
where: {
connectedAccountId: newOrExistingConnectedAccountId,
},
});
if (!shouldEnableSync) {
return;
}
for (const messageChannel of messageChannels) {
await this.messageQueueService.add<MessagingMessageListFetchJobData>(
MessagingMessageListFetchJob.name,
{
workspaceId,
messageChannelId: messageChannel.id,
},
);
}
const messageChannels = await messageChannelRepository.find({
where: {
connectedAccountId: newOrExistingConnectedAccountId,
},
});
for (const messageChannel of messageChannels) {
await this.messageQueueService.add<MessagingMessageListFetchJobData>(
MessagingMessageListFetchJob.name,
{
workspaceId,
messageChannelId: messageChannel.id,
},
);
}
}
}

View File

@ -64,14 +64,14 @@ export class ImapClientProvider {
await client.connect();
this.logger.log(
`Connected to IMAP server for ${connectionParameters.handle}`,
`Connected to IMAP server for ${connectedAccount.handle}`,
);
try {
const mailboxes = await client.list();
this.logger.log(
`Available mailboxes for ${connectionParameters.handle}: ${mailboxes.map((m) => m.path).join(', ')}`,
`Available mailboxes for ${connectedAccount.handle}: ${mailboxes.map((m) => m.path).join(', ')}`,
);
} catch (error) {
this.logger.warn(`Failed to list mailboxes: ${error.message}`);

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SmtpClientProvider } from './providers/smtp-client.provider';
@Module({
providers: [SmtpClientProvider],
exports: [SmtpClientProvider],
})
export class MessagingSmtpDriverModule {}

View File

@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import { createTransport, Transporter } from 'nodemailer';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
@Injectable()
export class SmtpClientProvider {
public async getSmtpClient(
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'connectionParameters' | 'handle'
>,
): Promise<Transporter> {
const smtpParams = connectedAccount.connectionParameters?.SMTP;
if (!smtpParams) {
throw new Error('SMTP settings not configured for this account');
}
const transporter = createTransport({
host: smtpParams.host,
port: smtpParams.port,
auth: {
user: connectedAccount.handle,
pass: smtpParams.password,
},
tls: {
rejectUnauthorized: false,
},
});
return transporter;
}
}

View File

@ -22,6 +22,7 @@ import { MessagingOngoingStaleCronJob } from 'src/modules/messaging/message-impo
import { MessagingGmailDriverModule } from 'src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module';
import { MessagingIMAPDriverModule } from 'src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module';
import { MessagingMicrosoftDriverModule } from 'src/modules/messaging/message-import-manager/drivers/microsoft/messaging-microsoft-driver.module';
import { MessagingSmtpDriverModule } from 'src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module';
import { MessagingAddSingleMessageToCacheForImportJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-add-single-message-to-cache-for-import.job';
import { MessagingCleanCacheJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-clean-cache';
import { MessagingMessageListFetchJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job';
@ -48,6 +49,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
MessagingGmailDriverModule,
MessagingMicrosoftDriverModule,
MessagingIMAPDriverModule,
MessagingSmtpDriverModule,
MessagingCommonModule,
TypeOrmModule.forFeature(
[Workspace, DataSourceEntity, ObjectMetadataEntity],

View File

@ -12,6 +12,7 @@ import {
import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-client.provider';
import { OAuth2ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/oauth2-client.provider';
import { MicrosoftClientProvider } from 'src/modules/messaging/message-import-manager/drivers/microsoft/providers/microsoft-client.provider';
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
import { isAccessTokenRefreshingError } from 'src/modules/messaging/message-import-manager/drivers/microsoft/utils/is-access-token-refreshing-error.utils';
import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util';
@ -27,6 +28,7 @@ export class MessagingSendMessageService {
private readonly gmailClientProvider: GmailClientProvider,
private readonly oAuth2ClientProvider: OAuth2ClientProvider,
private readonly microsoftClientProvider: MicrosoftClientProvider,
private readonly smtpClientProvider: SmtpClientProvider,
) {}
public async sendMessage(
@ -120,7 +122,16 @@ export class MessagingSendMessageService {
break;
}
case ConnectedAccountProvider.IMAP_SMTP_CALDAV: {
throw new Error('IMAP provider does not support sending messages');
const smtpClient =
await this.smtpClientProvider.getSmtpClient(connectedAccount);
await smtpClient.sendMail({
from: connectedAccount.handle,
to: sendMessageInput.to,
subject: sendMessageInput.subject,
text: sendMessageInput.body,
});
break;
}
default:
assertUnreachable(