feat: add MAILER_SMTP_IGNORE_TLS and optional SMTP auth (#5972)

Co-authored-by: nivedin <nivedinp@gmail.com>
Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Mir Arif Hasan 2026-03-26 00:00:46 +06:00 committed by GitHub
parent da3b8c5d37
commit 06bdd7ca6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 194 additions and 41 deletions

View file

@ -110,6 +110,10 @@ export class SaveOnboardingConfigRequest {
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_SMTP_IGNORE_TLS]: string;
}
export class SaveOnboardingConfigResponse {
@ -197,4 +201,7 @@ export class GetOnboardingConfigResponse {
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_SMTP_IGNORE_TLS]: string;
}

View file

@ -71,8 +71,6 @@ export function getAuthProviderRequiredKeys(
InfraConfigEnum.MAILER_SMTP_HOST,
InfraConfigEnum.MAILER_SMTP_PORT,
InfraConfigEnum.MAILER_SMTP_SECURE,
InfraConfigEnum.MAILER_SMTP_USER,
InfraConfigEnum.MAILER_SMTP_PASSWORD,
InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED,
InfraConfigEnum.MAILER_ADDRESS_FROM,
]
@ -240,6 +238,11 @@ export async function getDefaultInfraConfigs(): Promise<DefaultInfraConfig[]> {
value: null,
isEncrypted: false,
},
{
name: InfraConfigEnum.MAILER_SMTP_IGNORE_TLS,
value: 'false',
isEncrypted: false,
},
{
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
value: null,

View file

@ -240,6 +240,10 @@ export class InfraConfigService implements OnModuleInit, OnModuleDestroy {
const isValidate = this.validateEnvValues(infraConfigs);
if (E.isLeft(isValidate)) return E.left(isValidate.left);
// Validate SMTP credentials pair against effective post-update state
const smtpPairCheck = await this.validateSmtpCredentialPair(infraConfigs);
if (E.isLeft(smtpPairCheck)) return E.left(smtpPairCheck.left);
try {
const dbInfraConfig = await this.prisma.infraConfig.findMany({
select: { name: true, isEncrypted: true },
@ -310,8 +314,6 @@ export class InfraConfigService implements OnModuleInit, OnModuleDestroy {
configMap.MAILER_SMTP_HOST &&
configMap.MAILER_SMTP_PORT &&
configMap.MAILER_SMTP_SECURE &&
configMap.MAILER_SMTP_USER &&
configMap.MAILER_SMTP_PASSWORD &&
configMap.MAILER_TLS_REJECT_UNAUTHORIZED &&
configMap.MAILER_ADDRESS_FROM
);
@ -656,6 +658,58 @@ export class InfraConfigService implements OnModuleInit, OnModuleDestroy {
}
}
/**
* Validate that SMTP user and password are both provided or both empty,
* checking the effective post-update state (incoming merged with DB).
*/
private async validateSmtpCredentialPair(
infraConfigs: { name: InfraConfigEnum; value: string }[],
) {
const incoming = new Map(infraConfigs.map((c) => [c.name, c.value]));
const smtpKeys = [
InfraConfigEnum.MAILER_SMTP_USER,
InfraConfigEnum.MAILER_SMTP_PASSWORD,
];
if (!smtpKeys.some((key) => incoming.has(key))) {
return E.right(true);
}
const missingKeys = smtpKeys.filter((key) => !incoming.has(key));
const dbRows =
missingKeys.length === 0
? []
: await this.prisma.infraConfig.findMany({
where: { name: { in: missingKeys } },
select: { name: true, value: true, isEncrypted: true },
});
const dbValues = new Map(
dbRows.map((row) => [
row.name,
row.value ? (row.isEncrypted ? decrypt(row.value) : row.value) : '',
]),
);
const smtpUser =
incoming.get(InfraConfigEnum.MAILER_SMTP_USER) ??
dbValues.get(InfraConfigEnum.MAILER_SMTP_USER) ??
'';
const smtpPass =
incoming.get(InfraConfigEnum.MAILER_SMTP_PASSWORD) ??
dbValues.get(InfraConfigEnum.MAILER_SMTP_PASSWORD) ??
'';
const hasUser = smtpUser.trim() !== '';
const hasPass = smtpPass.trim() !== '';
return hasUser !== hasPass
? E.left(INFRA_CONFIG_INVALID_INPUT)
: E.right(true);
}
/**
* Validate the values of the InfraConfigs
*/
@ -678,6 +732,7 @@ export class InfraConfigService implements OnModuleInit, OnModuleDestroy {
case InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS:
case InfraConfigEnum.MAILER_SMTP_SECURE:
case InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED:
case InfraConfigEnum.MAILER_SMTP_IGNORE_TLS:
if (value !== 'true' && value !== 'false') return fail();
break;
@ -702,8 +757,6 @@ export class InfraConfigService implements OnModuleInit, OnModuleDestroy {
case InfraConfigEnum.MAILER_SMTP_HOST:
case InfraConfigEnum.MAILER_SMTP_PORT:
case InfraConfigEnum.MAILER_SMTP_USER:
case InfraConfigEnum.MAILER_SMTP_PASSWORD:
case InfraConfigEnum.GOOGLE_CLIENT_ID:
case InfraConfigEnum.GOOGLE_CLIENT_SECRET:
case InfraConfigEnum.GOOGLE_SCOPE:

View file

@ -1,9 +1,5 @@
import { TransportType } from '@nestjs-modules/mailer/dist/interfaces/mailer-options.interface';
import {
MAILER_SMTP_PASSWORD_UNDEFINED,
MAILER_SMTP_URL_UNDEFINED,
MAILER_SMTP_USER_UNDEFINED,
} from 'src/errors';
import { MAILER_SMTP_URL_UNDEFINED } from 'src/errors';
import { throwErr } from 'src/utils';
function isSMTPCustomConfigsEnabled(value) {
@ -24,17 +20,28 @@ export function getTransportOption(env): TransportType {
return env.INFRA.MAILER_SMTP_URL ?? throwErr(MAILER_SMTP_URL_UNDEFINED);
} else {
console.log('Using advanced mailer configuration');
const smtpUser = env.INFRA.MAILER_SMTP_USER?.trim() || undefined;
const smtpPass = env.INFRA.MAILER_SMTP_PASSWORD?.trim() || undefined;
// Both credentials must be provided together or both omitted
const hasUser = !!smtpUser;
const hasPass = !!smtpPass;
if (hasUser !== hasPass) {
throw new Error(
'SMTP auth requires both MAILER_SMTP_USER and MAILER_SMTP_PASSWORD. Provide both or leave both empty for unauthenticated relay.',
);
}
const auth =
smtpUser && smtpPass ? { user: smtpUser, pass: smtpPass } : undefined;
return {
host: env.INFRA.MAILER_SMTP_HOST,
port: +env.INFRA.MAILER_SMTP_PORT,
secure: env.INFRA.MAILER_SMTP_SECURE === 'true',
auth: {
user:
env.INFRA.MAILER_SMTP_USER ?? throwErr(MAILER_SMTP_USER_UNDEFINED),
pass:
env.INFRA.MAILER_SMTP_PASSWORD ??
throwErr(MAILER_SMTP_PASSWORD_UNDEFINED),
},
...(auth && { auth }),
ignoreTLS: env.INFRA.MAILER_SMTP_IGNORE_TLS === 'true',
tls: {
rejectUnauthorized: env.INFRA.MAILER_TLS_REJECT_UNAUTHORIZED === 'true',
},

View file

@ -25,6 +25,7 @@ export enum InfraConfigEnum {
MAILER_SMTP_USER = 'MAILER_SMTP_USER',
MAILER_SMTP_PASSWORD = 'MAILER_SMTP_PASSWORD',
MAILER_TLS_REJECT_UNAUTHORIZED = 'MAILER_TLS_REJECT_UNAUTHORIZED',
MAILER_SMTP_IGNORE_TLS = 'MAILER_SMTP_IGNORE_TLS',
GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID',
GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET',

View file

@ -74,22 +74,24 @@
},
"load_error": "Unable to load server configurations",
"mail_configs": {
"address_from": "MAILER FROM ADDRESS",
"address_from": "Mailer From Address",
"custom_smtp_configs": "Use Custom SMTP Configurations",
"description": " Configure the smtp configurations",
"enable_email_auth": "Enable Email based authentication",
"enable_smtp": "Enable SMTP",
"host": "MAILER HOST",
"host": "SMTP Host",
"input_validation": "SMTP URL should start with smtp(s)://",
"password": "MAILER PASSWORD",
"port": "MAILER PORT",
"secure": "MAILER SECURE",
"smtp_url": "MAILER SMTP URL",
"tls_reject_unauthorized": "TLS REJECT UNAUTHORIZED",
"password": "SMTP Password",
"port": "SMTP Port",
"secure": "SMTP Secure",
"smtp_url": "SMTP URL",
"ignore_tls": "SMTP Ignore TLS",
"tls_reject_unauthorized": "TLS Reject Unauthorized",
"title": "SMTP Configurations",
"smtp_auth_incomplete": "SMTP username and password must both be provided or both left empty",
"toggle_failure": "Failed to toggle smtp!!",
"update_failure": "Failed to update smtp configurations!!",
"user": "MAILER USER"
"user": "SMTP User"
},
"history_configs": {
"description": "Disable or enable tracking history of sent requests from the Hoppscotch app",

View file

@ -40,6 +40,13 @@
{{ smtp.SMTP_SECURE.text }}
</HoppSmartCheckbox>
<HoppSmartCheckbox
:on="smtp.SMTP_IGNORE_TLS.enabled"
@change="toggleConfig('SMTP_IGNORE_TLS')"
>
{{ smtp.SMTP_IGNORE_TLS.text }}
</HoppSmartCheckbox>
<HoppSmartCheckbox
:on="smtp.TLS_REJECT_UNAUTHORIZED.enabled"
@change="toggleConfig('TLS_REJECT_UNAUTHORIZED')"
@ -101,55 +108,61 @@ const smtp = computed<MailerConfig>(() => {
return {
SMTP_URL: {
id: 'MAILER_SMTP_URL',
text: 'SMTP URL',
text: t('configs.mail_configs.smtp_url'),
value: cfg.MAILER_SMTP_URL,
enabled: !isCustom,
},
ADDRESS_FROM: {
id: 'MAILER_ADDRESS_FROM',
text: 'Address From',
text: t('configs.mail_configs.address_from'),
value: cfg.MAILER_ADDRESS_FROM,
enabled: true,
},
USE_CUSTOM_CONFIGS: {
id: 'MAILER_USE_CUSTOM_CONFIGS',
text: 'Use Custom Configs',
text: t('configs.mail_configs.custom_smtp_configs'),
value: cfg.MAILER_USE_CUSTOM_CONFIGS,
enabled: isCustom,
},
SMTP_SECURE: {
id: 'MAILER_SMTP_SECURE',
text: 'SMTP Secure',
text: t('configs.mail_configs.secure'),
value: cfg.MAILER_SMTP_SECURE,
enabled: isCustom && cfg.MAILER_SMTP_SECURE === 'true',
},
SMTP_IGNORE_TLS: {
id: 'MAILER_SMTP_IGNORE_TLS',
text: t('configs.mail_configs.ignore_tls'),
value: cfg.MAILER_SMTP_IGNORE_TLS,
enabled: isCustom && cfg.MAILER_SMTP_IGNORE_TLS === 'true',
},
TLS_REJECT_UNAUTHORIZED: {
id: 'MAILER_TLS_REJECT_UNAUTHORIZED',
text: 'TLS Reject Unauthorized',
text: t('configs.mail_configs.tls_reject_unauthorized'),
value: cfg.MAILER_TLS_REJECT_UNAUTHORIZED,
enabled: isCustom && cfg.MAILER_TLS_REJECT_UNAUTHORIZED === 'true',
},
SMTP_USER: {
id: 'MAILER_SMTP_USER',
text: 'SMTP User',
text: t('configs.mail_configs.user'),
value: cfg.MAILER_SMTP_USER,
enabled: isCustom,
},
SMTP_PASSWORD: {
id: 'MAILER_SMTP_PASSWORD',
text: 'SMTP Password',
text: t('configs.mail_configs.password'),
value: cfg.MAILER_SMTP_PASSWORD,
enabled: isCustom,
},
SMTP_HOST: {
id: 'MAILER_SMTP_HOST',
text: 'SMTP Host',
text: t('configs.mail_configs.host'),
value: cfg.MAILER_SMTP_HOST,
enabled: isCustom,
},
SMTP_PORT: {
id: 'MAILER_SMTP_PORT',
text: 'SMTP Port',
text: t('configs.mail_configs.port'),
value: cfg.MAILER_SMTP_PORT,
enabled: isCustom,
},

View file

@ -171,6 +171,10 @@ const smtpConfigFields = reactive<Field[]>([
name: t('configs.mail_configs.secure'),
key: 'mailer_smtp_secure',
},
{
name: t('configs.mail_configs.ignore_tls'),
key: 'mailer_smtp_ignore_tls',
},
{
name: t('configs.mail_configs.tls_reject_unauthorized'),
key: 'mailer_tls_reject_unauthorized',
@ -200,6 +204,7 @@ const fieldCondition = (field: Field) => {
'mailer_smtp_user',
'mailer_smtp_password',
'mailer_smtp_secure',
'mailer_smtp_ignore_tls',
'mailer_tls_reject_unauthorized',
];
const basicFields = ['mailer_smtp_url'];
@ -216,7 +221,11 @@ const fieldCondition = (field: Field) => {
};
const isCheckboxField = (field: Field) => {
const checkboxKeys = ['mailer_smtp_secure', 'mailer_tls_reject_unauthorized'];
const checkboxKeys = [
'mailer_smtp_secure',
'mailer_smtp_ignore_tls',
'mailer_tls_reject_unauthorized',
];
return checkboxKeys.includes(field.key);
};

View file

@ -137,6 +137,8 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
),
mailer_smtp_secure:
getFieldValue(InfraConfigEnum.MailerSmtpSecure) === 'true',
mailer_smtp_ignore_tls:
getFieldValue(InfraConfigEnum.MailerSmtpIgnoreTls) === 'true',
mailer_tls_reject_unauthorized:
getFieldValue(InfraConfigEnum.MailerTlsRejectUnauthorized) ===
'true',
@ -273,8 +275,10 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
if (section.name === 'email') {
const { mailer_use_custom_configs, ...otherFields } = section.fields;
// SMTP user and password are optional as a pair (both or neither)
const optionalMailerKeys = ['mailer_smtp_user', 'mailer_smtp_password'];
const excludeKeys = mailer_use_custom_configs
? ['mailer_smtp_url']
? ['mailer_smtp_url', ...optionalMailerKeys]
: [
'mailer_smtp_host',
'mailer_smtp_port',
@ -285,7 +289,8 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
return (
section.enabled &&
Object.entries(otherFields).some(
([key, value]) => isFieldEmpty(value) && !excludeKeys.includes(key)
([key, value]) =>
isFieldEmpty(value) && !excludeKeys.includes(key)
)
);
}
@ -312,6 +317,18 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
return hasSectionWithEmptyFields;
};
const hasPartialSmtpCredentials = (config: ServerConfigs): boolean => {
if (!config.mailConfigs.enabled) return false;
const fields = config.mailConfigs.fields;
if (!fields.mailer_use_custom_configs) return false;
const hasUser = fields.mailer_smtp_user.trim() !== '';
const hasPass = fields.mailer_smtp_password.trim() !== '';
return hasUser !== hasPass;
};
// Extract the mail config fields (excluding the custom mail config fields)
const mailConfigFields = Object.fromEntries(
Object.entries(updatedConfigs?.mailConfigs.fields ?? {}).filter(([key]) => {
@ -659,5 +676,6 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
infraConfigsError,
allowedAuthProvidersError,
AreAnyConfigFieldsEmpty,
hasPartialSmtpCredentials,
};
}

View file

@ -30,6 +30,7 @@ export type MailerConfigKeys =
| 'SMTP_SECURE'
| 'SMTP_USER'
| 'SMTP_PASSWORD'
| 'SMTP_IGNORE_TLS'
| 'TLS_REJECT_UNAUTHORIZED';
export type Configs = {
@ -89,6 +90,7 @@ function mapMailerConfigs(
MAILER_SMTP_SECURE: configs.MAILER_SMTP_SECURE || 'false',
MAILER_SMTP_USER: configs.MAILER_SMTP_USER ?? '',
MAILER_SMTP_PASSWORD: configs.MAILER_SMTP_PASSWORD ?? '',
MAILER_SMTP_IGNORE_TLS: configs.MAILER_SMTP_IGNORE_TLS || 'false',
MAILER_TLS_REJECT_UNAUTHORIZED:
configs.MAILER_TLS_REJECT_UNAUTHORIZED || 'false',
};
@ -229,6 +231,7 @@ export function useOnboardingConfigHandler() {
'MAILER_ADDRESS_FROM',
'MAILER_USE_CUSTOM_CONFIGS',
'MAILER_SMTP_SECURE',
'MAILER_SMTP_IGNORE_TLS',
'MAILER_TLS_REJECT_UNAUTHORIZED',
'MAILER_SMTP_ENABLE',
].includes(key);
@ -253,11 +256,21 @@ export function useOnboardingConfigHandler() {
);
const neededKeys = filterNeededConfigs(relevantKeys);
const allFilled = neededKeys.every((key) => configs[key]);
// SMTP auth is optional (both blank = no-auth SMTP server).
// These keys are excluded from mandatory "filled" checks, but are still
// included in the returned payload even when empty so the backend can
// explicitly clear previously stored credentials on re-visits.
const optionalSmtpKeys = new Set([
'MAILER_SMTP_USER',
'MAILER_SMTP_PASSWORD',
]);
const allFilled = neededKeys.every(
(key) => configs[key] || optionalSmtpKeys.has(key)
);
if (!allFilled) {
neededKeys.forEach((key) => {
if (!configs[key])
if (!configs[key] && !optionalSmtpKeys.has(key))
toast.error(
t('onboarding.please_fill_configurations', {
fieldName: makeReadableKey(key),
@ -267,11 +280,28 @@ export function useOnboardingConfigHandler() {
return;
}
// SMTP credentials must be provided together or both left empty.
// Only enforce when custom SMTP mode is active (not simple URL mode).
if (
enabledConfigs.value.includes('MAILER') &&
configs['MAILER_USE_CUSTOM_CONFIGS'] === 'true'
) {
const smtpUser = configs['MAILER_SMTP_USER']?.trim();
const smtpPass = configs['MAILER_SMTP_PASSWORD']?.trim();
if (!!smtpUser !== !!smtpPass) {
toast.error(t('configs.mail_configs.smtp_auth_incomplete'));
return;
}
}
// Allow empty strings through for optional SMTP keys so the backend
// receives an explicit clear rather than silently retaining old values
return Object.fromEntries(
Object.entries(configs).filter(
([key, val]) =>
enabledConfigs.value.includes(key.split('_')[0] as EnabledConfig) &&
val
(val || optionalSmtpKeys.has(key))
)
);
};

View file

@ -53,6 +53,7 @@ export type ServerConfigs = {
mailer_smtp_user: string;
mailer_smtp_password: string;
mailer_smtp_secure: boolean;
mailer_smtp_ignore_tls: boolean;
mailer_tls_reject_unauthorized: boolean;
mailer_use_custom_configs: boolean;
};
@ -221,6 +222,10 @@ export const CUSTOM_MAIL_CONFIGS: Config[] = [
name: InfraConfigEnum.MailerSmtpSecure,
key: 'mailer_smtp_secure',
},
{
name: InfraConfigEnum.MailerSmtpIgnoreTls,
key: 'mailer_smtp_ignore_tls',
},
{
name: InfraConfigEnum.MailerTlsRejectUnauthorized,
key: 'mailer_tls_reject_unauthorized',

View file

@ -95,6 +95,7 @@ const {
fetchingAllowedAuthProviders,
allowedAuthProvidersError,
AreAnyConfigFieldsEmpty,
hasPartialSmtpCredentials,
} = useConfigHandler();
// Check if the configs have been updated
@ -114,6 +115,10 @@ const triggerSaveChangesModal = () => {
return toast.error(t('configs.input_empty'));
}
if (workingConfigs.value && hasPartialSmtpCredentials(workingConfigs.value)) {
return toast.error(t('configs.mail_configs.smtp_auth_incomplete'));
}
if (hasInputValidationFailed.value) {
return toast.error(t('configs.input_validation_error'));
}