fix(messaging): honor IMAP/SMTP encryption setting instead of inferring it from the port (#21562)

This pull request makes the IMAP and SMTP encryption setting actually
honor what the user selects.

As per spec there's 3 modes: SSL/TLS (implicit TLS from the start),
STARTTLS (it will attempt TLS but if the server doesn't support it, it
gracefully falls back to plaintext), NONE (plaintext)

Current implementation had a boolean flag for this, this replaces it
with the 3 modes

Upgrade command to migrate all existing accounts, to not risk breaking
anyone's existing account in production we map each account to the mode
that matches its current behavior, so nothing changes on the wire

/closes #21300

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21562?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
neo773
2026-06-16 21:39:04 +05:30
committed by GitHub
parent ff03e935ef
commit d8d5991977
33 changed files with 943 additions and 487 deletions
@@ -2,6 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { EmailConnectionSecurity } from 'src/engine/core-modules/imap-smtp-caldav-connection/enums/email-connection-security.enum';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('ImapSmtpCaldavPublicConnectionParams')
@@ -15,8 +17,8 @@ class PublicConnectionParamsDTO {
@Field(() => String, { nullable: true })
username?: string;
@Field(() => Boolean, { nullable: true })
secure?: boolean;
@Field(() => EmailConnectionSecurity, { nullable: true })
connectionSecurity?: EmailConnectionSecurity;
}
@ObjectType('ImapSmtpCaldavPublicConnectionParameters')
@@ -1,4 +1,10 @@
import { Field, InputType } from '@nestjs/graphql';
import { Field, InputType, registerEnumType } from '@nestjs/graphql';
import { EmailConnectionSecurity } from 'src/engine/core-modules/imap-smtp-caldav-connection/enums/email-connection-security.enum';
registerEnumType(EmailConnectionSecurity, {
name: 'EmailConnectionSecurity',
});
@InputType()
export class AccountType {
@@ -20,8 +26,8 @@ export class ConnectionParametersInput {
@Field(() => String, { nullable: true })
password?: string;
@Field(() => Boolean, { nullable: true })
secure?: boolean;
@Field(() => EmailConnectionSecurity, { nullable: true })
connectionSecurity?: EmailConnectionSecurity;
}
@InputType('EmailAccountConnectionParameters')
@@ -0,0 +1,5 @@
export enum EmailConnectionSecurity {
NONE = 'NONE',
STARTTLS = 'STARTTLS',
SSL_TLS = 'SSL_TLS',
}
@@ -1,3 +1,4 @@
import { EmailConnectionSecurity } from 'src/engine/core-modules/imap-smtp-caldav-connection/enums/email-connection-security.enum';
import { plaintextStringSchema } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { z } from 'zod';
@@ -6,5 +7,7 @@ export const connectionParametersSchema = z.object({
port: z.int().positive('Port must be a positive number'),
username: z.string().optional(),
password: plaintextStringSchema.min(1, 'Password is required'),
secure: z.boolean().optional(),
connectionSecurity: z
.nativeEnum(EmailConnectionSecurity)
.default(EmailConnectionSecurity.SSL_TLS),
});
@@ -1,7 +1,9 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { createTransport } from 'nodemailer';
import { type DAVClient } from 'tsdav';
import { EmailConnectionSecurity } from 'src/engine/core-modules/imap-smtp-caldav-connection/enums/email-connection-security.enum';
import { ImapSmtpCaldavValidatorService } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service';
import { ImapSmtpCaldavService } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service';
import { type ConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
@@ -10,11 +12,20 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
import { CalDavClientService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client.service';
import { CalDavFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service';
jest.mock('nodemailer', () => ({
createTransport: jest.fn(),
}));
describe('ImapSmtpCaldavService', () => {
let service: ImapSmtpCaldavService;
const mockClient = {} as DAVClient;
const mockVerify = jest.fn().mockResolvedValue(true);
const mockGetValidatedHost = jest
.fn()
.mockImplementation((host: string) => Promise.resolve(host));
const mockCalDavClientService = {
getClient: jest.fn(),
};
@@ -25,6 +36,8 @@ describe('ImapSmtpCaldavService', () => {
beforeEach(async () => {
jest.clearAllMocks();
(createTransport as jest.Mock).mockReturnValue({ verify: mockVerify });
mockVerify.mockResolvedValue(true);
mockCalDavClientService.getClient.mockResolvedValue(mockClient);
mockCalDavFetchEventsService.listEventCalendars.mockResolvedValue([
{ url: 'https://caldav.example.com/calendars/user/default/' },
@@ -33,7 +46,10 @@ describe('ImapSmtpCaldavService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ImapSmtpCaldavService,
{ provide: SecureHttpClientService, useValue: {} },
{
provide: SecureHttpClientService,
useValue: { getValidatedHost: mockGetValidatedHost },
},
{ provide: ImapSmtpCaldavValidatorService, useValue: {} },
{
provide: TwentyConfigService,
@@ -59,6 +75,7 @@ describe('ImapSmtpCaldavService', () => {
port: 443,
username: 'user@example.com',
password: 'password123',
connectionSecurity: EmailConnectionSecurity.SSL_TLS,
};
it('builds a CalDAV client and lists its event calendars', async () => {
@@ -93,4 +110,48 @@ describe('ImapSmtpCaldavService', () => {
).rejects.toThrow('No calendar with event support found');
});
});
describe('testSmtpConnection', () => {
const params: ConnectionParameters = {
host: 'smtp.example.com',
port: 587,
username: 'user@example.com',
password: 'password123',
connectionSecurity: EmailConnectionSecurity.STARTTLS,
};
it('uses implicit TLS when connectionSecurity is SSL_TLS', async () => {
await service.testSmtpConnection('user@example.com', {
...params,
port: 465,
connectionSecurity: EmailConnectionSecurity.SSL_TLS,
});
expect(createTransport).toHaveBeenCalledWith(
expect.objectContaining({ secure: true }),
);
});
it('upgrades opportunistically via STARTTLS', async () => {
await service.testSmtpConnection('user@example.com', {
...params,
connectionSecurity: EmailConnectionSecurity.STARTTLS,
});
expect(createTransport).toHaveBeenCalledWith(
expect.objectContaining({ secure: false }),
);
});
it('disables TLS when connectionSecurity is NONE', async () => {
await service.testSmtpConnection('user@example.com', {
...params,
connectionSecurity: EmailConnectionSecurity.NONE,
});
expect(createTransport).toHaveBeenCalledWith(
expect.objectContaining({ secure: false, ignoreTLS: true }),
);
});
});
});
@@ -14,6 +14,8 @@ import {
type ConnectionParameters,
type PlaintextImapSmtpCaldavParams,
} from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import { buildImapTlsOptions } from 'src/engine/core-modules/imap-smtp-caldav-connection/utils/build-imap-tls-options.util';
import { buildSmtpTlsOptions } from 'src/engine/core-modules/imap-smtp-caldav-connection/utils/build-smtp-tls-options.util';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { CalDavClientService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client.service';
@@ -41,7 +43,7 @@ export class ImapSmtpCaldavService {
const client = new ImapFlow({
host: validatedHost,
port: params.port,
secure: params.secure ?? true,
...buildImapTlsOptions(params.connectionSecurity),
auth: {
user: params.username ?? handle,
pass: params.password,
@@ -114,6 +116,7 @@ export class ImapSmtpCaldavService {
const transport = createTransport({
host: validatedHost,
port: params.port,
...buildSmtpTlsOptions(params.connectionSecurity),
auth: {
user: params.username ?? handle,
pass: params.password,
@@ -24,6 +24,8 @@ export type ConnectionParametersUpdate<Pwd extends string = string> = Omit<
export type AccountType = (typeof ACCOUNT_TYPES)[number];
export { EmailConnectionSecurity } from 'src/engine/core-modules/imap-smtp-caldav-connection/enums/email-connection-security.enum';
export type ImapSmtpCaldavParams<Pwd extends string = string> = {
IMAP?: ConnectionParameters<Pwd>;
SMTP?: ConnectionParameters<Pwd>;
@@ -0,0 +1,23 @@
import { EmailConnectionSecurity } from 'src/engine/core-modules/imap-smtp-caldav-connection/enums/email-connection-security.enum';
import { buildImapTlsOptions } from 'src/engine/core-modules/imap-smtp-caldav-connection/utils/build-imap-tls-options.util';
describe('buildImapTlsOptions', () => {
it('negotiates TLS at connection time for SSL_TLS', () => {
expect(buildImapTlsOptions(EmailConnectionSecurity.SSL_TLS)).toEqual({
secure: true,
});
});
it('upgrades opportunistically via STARTTLS when the server offers it', () => {
expect(buildImapTlsOptions(EmailConnectionSecurity.STARTTLS)).toEqual({
secure: false,
});
});
it('forces plaintext for NONE by disabling the STARTTLS upgrade', () => {
expect(buildImapTlsOptions(EmailConnectionSecurity.NONE)).toEqual({
secure: false,
doSTARTTLS: false,
});
});
});
@@ -0,0 +1,23 @@
import { EmailConnectionSecurity } from 'src/engine/core-modules/imap-smtp-caldav-connection/enums/email-connection-security.enum';
import { buildSmtpTlsOptions } from 'src/engine/core-modules/imap-smtp-caldav-connection/utils/build-smtp-tls-options.util';
describe('buildSmtpTlsOptions', () => {
it('negotiates TLS at connection time for SSL_TLS', () => {
expect(buildSmtpTlsOptions(EmailConnectionSecurity.SSL_TLS)).toEqual({
secure: true,
});
});
it('upgrades opportunistically via STARTTLS when the server offers it', () => {
expect(buildSmtpTlsOptions(EmailConnectionSecurity.STARTTLS)).toEqual({
secure: false,
});
});
it('forces plaintext for NONE by skipping the STARTTLS upgrade entirely', () => {
expect(buildSmtpTlsOptions(EmailConnectionSecurity.NONE)).toEqual({
secure: false,
ignoreTLS: true,
});
});
});
@@ -0,0 +1,19 @@
import { EmailConnectionSecurity } from 'src/engine/core-modules/imap-smtp-caldav-connection/enums/email-connection-security.enum';
type ImapTlsOptions = {
secure: boolean;
doSTARTTLS?: boolean;
};
export const buildImapTlsOptions = (
connectionSecurity: EmailConnectionSecurity,
): ImapTlsOptions => {
switch (connectionSecurity) {
case EmailConnectionSecurity.SSL_TLS:
return { secure: true };
case EmailConnectionSecurity.STARTTLS:
return { secure: false };
case EmailConnectionSecurity.NONE:
return { secure: false, doSTARTTLS: false };
}
};
@@ -1,13 +1,16 @@
import { ACCOUNT_TYPES } from 'twenty-shared/constants';
import { 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 EmailConnectionSecurity,
type ImapSmtpCaldavParams,
} from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
type PublicConnectionParams = {
host: string;
port: number;
username?: string;
secure?: boolean;
connectionSecurity?: EmailConnectionSecurity;
};
type PublicConnectionParameters = {
@@ -0,0 +1,19 @@
import { EmailConnectionSecurity } from 'src/engine/core-modules/imap-smtp-caldav-connection/enums/email-connection-security.enum';
type SmtpTlsOptions = {
secure: boolean;
ignoreTLS?: boolean;
};
export const buildSmtpTlsOptions = (
connectionSecurity: EmailConnectionSecurity,
): SmtpTlsOptions => {
switch (connectionSecurity) {
case EmailConnectionSecurity.SSL_TLS:
return { secure: true };
case EmailConnectionSecurity.STARTTLS:
return { secure: false };
case EmailConnectionSecurity.NONE:
return { secure: false, ignoreTLS: true };
}
};