diff --git a/packages/hoppscotch-backend/src/infra-config/dto/onboarding.dto.ts b/packages/hoppscotch-backend/src/infra-config/dto/onboarding.dto.ts index f5613cfc..71c35cfa 100644 --- a/packages/hoppscotch-backend/src/infra-config/dto/onboarding.dto.ts +++ b/packages/hoppscotch-backend/src/infra-config/dto/onboarding.dto.ts @@ -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; } diff --git a/packages/hoppscotch-backend/src/infra-config/helper.ts b/packages/hoppscotch-backend/src/infra-config/helper.ts index 084a78fd..ee583316 100644 --- a/packages/hoppscotch-backend/src/infra-config/helper.ts +++ b/packages/hoppscotch-backend/src/infra-config/helper.ts @@ -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 { value: null, isEncrypted: false, }, + { + name: InfraConfigEnum.MAILER_SMTP_IGNORE_TLS, + value: 'false', + isEncrypted: false, + }, { name: InfraConfigEnum.GOOGLE_CLIENT_ID, value: null, diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts index 3a1b908e..ce43cd68 100644 --- a/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts @@ -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: diff --git a/packages/hoppscotch-backend/src/mailer/helper.ts b/packages/hoppscotch-backend/src/mailer/helper.ts index 0fc0eeda..d38dc71b 100644 --- a/packages/hoppscotch-backend/src/mailer/helper.ts +++ b/packages/hoppscotch-backend/src/mailer/helper.ts @@ -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', }, diff --git a/packages/hoppscotch-backend/src/types/InfraConfig.ts b/packages/hoppscotch-backend/src/types/InfraConfig.ts index 0a86e088..5877ffba 100644 --- a/packages/hoppscotch-backend/src/types/InfraConfig.ts +++ b/packages/hoppscotch-backend/src/types/InfraConfig.ts @@ -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', diff --git a/packages/hoppscotch-sh-admin/locales/en.json b/packages/hoppscotch-sh-admin/locales/en.json index 1869f10b..21029ba4 100644 --- a/packages/hoppscotch-sh-admin/locales/en.json +++ b/packages/hoppscotch-sh-admin/locales/en.json @@ -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", diff --git a/packages/hoppscotch-sh-admin/src/components/onboarding/SmtpSetup.vue b/packages/hoppscotch-sh-admin/src/components/onboarding/SmtpSetup.vue index 794af1c0..e89e367b 100644 --- a/packages/hoppscotch-sh-admin/src/components/onboarding/SmtpSetup.vue +++ b/packages/hoppscotch-sh-admin/src/components/onboarding/SmtpSetup.vue @@ -40,6 +40,13 @@ {{ smtp.SMTP_SECURE.text }} + + {{ smtp.SMTP_IGNORE_TLS.text }} + + (() => { 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, }, diff --git a/packages/hoppscotch-sh-admin/src/components/settings/SmtpConfiguration.vue b/packages/hoppscotch-sh-admin/src/components/settings/SmtpConfiguration.vue index 704d512a..02c02d45 100644 --- a/packages/hoppscotch-sh-admin/src/components/settings/SmtpConfiguration.vue +++ b/packages/hoppscotch-sh-admin/src/components/settings/SmtpConfiguration.vue @@ -171,6 +171,10 @@ const smtpConfigFields = reactive([ 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); }; diff --git a/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts b/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts index d3f6b582..6c42afe2 100644 --- a/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts +++ b/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts @@ -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, }; } diff --git a/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts b/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts index 300c7a66..0ac0bf26 100644 --- a/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts +++ b/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts @@ -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)) ) ); }; diff --git a/packages/hoppscotch-sh-admin/src/helpers/configs.ts b/packages/hoppscotch-sh-admin/src/helpers/configs.ts index 03271bfe..4b915a02 100644 --- a/packages/hoppscotch-sh-admin/src/helpers/configs.ts +++ b/packages/hoppscotch-sh-admin/src/helpers/configs.ts @@ -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', diff --git a/packages/hoppscotch-sh-admin/src/pages/settings.vue b/packages/hoppscotch-sh-admin/src/pages/settings.vue index 200ceeac..2813f099 100644 --- a/packages/hoppscotch-sh-admin/src/pages/settings.vue +++ b/packages/hoppscotch-sh-admin/src/pages/settings.vue @@ -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')); }