feat: add SMTP OAuth2 authentication support (#6141)

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-04-22 17:41:03 +06:00 committed by GitHub
parent 7be31a2986
commit eb801889ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 857 additions and 189 deletions

View file

@ -114,6 +114,31 @@ export class SaveOnboardingConfigRequest {
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_SMTP_IGNORE_TLS]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_SMTP_AUTH_TYPE]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_SMTP_OAUTH2_USER]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_ID]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_SECRET]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_SMTP_OAUTH2_REFRESH_TOKEN]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL]: string;
}
export class SaveOnboardingConfigResponse {
@ -204,4 +229,23 @@ export class GetOnboardingConfigResponse {
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_SMTP_IGNORE_TLS]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_SMTP_AUTH_TYPE]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_SMTP_OAUTH2_USER]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_ID]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_SECRET]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_SMTP_OAUTH2_REFRESH_TOKEN]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL]: string;
}

View file

@ -2,6 +2,7 @@ import { AuthProvider } from 'src/auth/helper';
import { ENV_INVALID_DATA_ENCRYPTION_KEY } from 'src/errors';
import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { SMTPAuthType } from 'src/mailer/helper';
import { decrypt, encrypt } from 'src/utils';
import { randomBytes } from 'crypto';
@ -243,6 +244,36 @@ export async function getDefaultInfraConfigs(): Promise<DefaultInfraConfig[]> {
value: 'false',
isEncrypted: false,
},
{
name: InfraConfigEnum.MAILER_SMTP_AUTH_TYPE,
value: SMTPAuthType.LOGIN,
isEncrypted: false,
},
{
name: InfraConfigEnum.MAILER_SMTP_OAUTH2_USER,
value: null,
isEncrypted: false,
},
{
name: InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_ID,
value: null,
isEncrypted: true,
},
{
name: InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_SECRET,
value: null,
isEncrypted: true,
},
{
name: InfraConfigEnum.MAILER_SMTP_OAUTH2_REFRESH_TOKEN,
value: null,
isEncrypted: true,
},
{
name: InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL,
value: null,
isEncrypted: false,
},
{
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
value: null,

View file

@ -3,6 +3,7 @@ import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfigService } from './infra-config.service';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import {
INFRA_CONFIG_INVALID_INPUT,
INFRA_CONFIG_NOT_FOUND,
INFRA_CONFIG_OPERATION_NOT_ALLOWED,
INFRA_CONFIG_UPDATE_FAILED,
@ -292,4 +293,58 @@ describe('InfraConfigService', () => {
);
});
});
describe('validateEnvValues', () => {
describe('MAILER_SMTP_AUTH_TYPE', () => {
it('should accept an empty value (defaults to login at runtime)', () => {
const result = infraConfigService.validateEnvValues([
{ name: InfraConfigEnum.MAILER_SMTP_AUTH_TYPE, value: '' },
]);
expect(result).toEqualRight(true);
});
it('should accept a known auth type', () => {
const result = infraConfigService.validateEnvValues([
{ name: InfraConfigEnum.MAILER_SMTP_AUTH_TYPE, value: 'oauth2' },
]);
expect(result).toEqualRight(true);
});
it('should reject an unknown auth type', () => {
const result = infraConfigService.validateEnvValues([
{ name: InfraConfigEnum.MAILER_SMTP_AUTH_TYPE, value: 'kerberos' },
]);
expect(result).toEqualLeft(INFRA_CONFIG_INVALID_INPUT);
});
});
describe('MAILER_SMTP_OAUTH2_ACCESS_URL', () => {
it('should accept an empty value', () => {
const result = infraConfigService.validateEnvValues([
{ name: InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL, value: '' },
]);
expect(result).toEqualRight(true);
});
it('should accept a valid HTTPS URL', () => {
const result = infraConfigService.validateEnvValues([
{
name: InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL,
value: 'https://oauth2.googleapis.com/token',
},
]);
expect(result).toEqualRight(true);
});
it('should reject a malformed URL', () => {
const result = infraConfigService.validateEnvValues([
{
name: InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL,
value: 'not-a-url',
},
]);
expect(result).toEqualLeft(INFRA_CONFIG_INVALID_INPUT);
});
});
});
});

View file

@ -4,6 +4,7 @@ import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfig as DBInfraConfig } from 'src/generated/prisma/client';
import * as E from 'fp-ts/Either';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { SMTPAuthType } from 'src/mailer/helper';
import {
AUTH_PROVIDER_NOT_SPECIFIED,
DATABASE_TABLE_NOT_EXIST,
@ -736,6 +737,18 @@ export class InfraConfigService implements OnModuleInit, OnModuleDestroy {
if (value !== 'true' && value !== 'false') return fail();
break;
case InfraConfigEnum.MAILER_SMTP_AUTH_TYPE:
if (
value &&
!Object.values(SMTPAuthType).includes(value as SMTPAuthType)
)
return fail();
break;
case InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL:
if (value && !validateUrl(value)) return fail();
break;
case InfraConfigEnum.MAILER_SMTP_URL:
if (!validateSMTPUrl(value)) return fail();
break;

View file

@ -1,7 +1,13 @@
import { TransportType } from '@nestjs-modules/mailer/dist/interfaces/mailer-options.interface';
import type SMTPConnection from 'nodemailer/lib/smtp-connection';
import { MAILER_SMTP_URL_UNDEFINED } from 'src/errors';
import { throwErr } from 'src/utils';
export enum SMTPAuthType {
LOGIN = 'login',
OAUTH2 = 'oauth2',
}
function isSMTPCustomConfigsEnabled(value) {
return value === 'true';
}
@ -21,26 +27,52 @@ export function getTransportOption(env): TransportType {
} 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;
const authType = env.INFRA.MAILER_SMTP_AUTH_TYPE?.trim();
// 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.',
);
let auth: SMTPConnection.AuthenticationType | undefined;
if (authType === SMTPAuthType.OAUTH2) {
const oauth2User = env.INFRA.MAILER_SMTP_OAUTH2_USER?.trim() || undefined;
const oauth2ClientId =
env.INFRA.MAILER_SMTP_OAUTH2_CLIENT_ID?.trim() || undefined;
const oauth2ClientSecret =
env.INFRA.MAILER_SMTP_OAUTH2_CLIENT_SECRET?.trim() || undefined;
const oauth2RefreshToken =
env.INFRA.MAILER_SMTP_OAUTH2_REFRESH_TOKEN?.trim() || undefined;
const oauth2AccessUrl =
env.INFRA.MAILER_SMTP_OAUTH2_ACCESS_URL?.trim() || undefined;
auth = {
type: SMTPAuthType.OAUTH2,
user: oauth2User,
clientId: oauth2ClientId,
clientSecret: oauth2ClientSecret,
refreshToken: oauth2RefreshToken,
accessUrl: oauth2AccessUrl,
};
} else {
const smtpUser = env.INFRA.MAILER_SMTP_USER?.trim() || undefined;
const smtpPass = env.INFRA.MAILER_SMTP_PASSWORD?.trim() || undefined;
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.',
);
}
auth =
smtpUser && smtpPass
? { type: SMTPAuthType.LOGIN, user: smtpUser, pass: smtpPass }
: undefined;
}
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 && { auth }),
auth,
ignoreTLS: env.INFRA.MAILER_SMTP_IGNORE_TLS === 'true',
tls: {
rejectUnauthorized: env.INFRA.MAILER_TLS_REJECT_UNAUTHORIZED === 'true',

View file

@ -27,6 +27,13 @@ export enum InfraConfigEnum {
MAILER_TLS_REJECT_UNAUTHORIZED = 'MAILER_TLS_REJECT_UNAUTHORIZED',
MAILER_SMTP_IGNORE_TLS = 'MAILER_SMTP_IGNORE_TLS',
MAILER_SMTP_AUTH_TYPE = 'MAILER_SMTP_AUTH_TYPE',
MAILER_SMTP_OAUTH2_USER = 'MAILER_SMTP_OAUTH2_USER',
MAILER_SMTP_OAUTH2_CLIENT_ID = 'MAILER_SMTP_OAUTH2_CLIENT_ID',
MAILER_SMTP_OAUTH2_CLIENT_SECRET = 'MAILER_SMTP_OAUTH2_CLIENT_SECRET',
MAILER_SMTP_OAUTH2_REFRESH_TOKEN = 'MAILER_SMTP_OAUTH2_REFRESH_TOKEN',
MAILER_SMTP_OAUTH2_ACCESS_URL = 'MAILER_SMTP_OAUTH2_ACCESS_URL',
GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID',
GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET',
GOOGLE_CALLBACK_URL = 'GOOGLE_CALLBACK_URL',

View file

@ -89,6 +89,15 @@
"tls_reject_unauthorized": "TLS Reject Unauthorized",
"title": "SMTP Configurations",
"smtp_auth_incomplete": "SMTP username and password must both be provided or both left empty",
"auth_switch_description": "Switching will clear the credentials entered in the current authentication type. Any unsaved data will be lost and cannot be undone.",
"auth_type": "Authentication Type",
"auth_type_login": "Basic Auth (Login)",
"auth_type_oauth2": "OAuth 2.0",
"oauth2_user": "OAuth 2.0 User",
"oauth2_client_id": "OAuth 2.0 Client ID",
"oauth2_client_secret": "OAuth 2.0 Client Secret",
"oauth2_refresh_token": "OAuth 2.0 Refresh Token",
"oauth2_access_url": "OAuth 2.0 Access Token URL",
"toggle_failure": "Failed to toggle smtp!!",
"update_failure": "Failed to update smtp configurations!!",
"user": "SMTP User"

View file

@ -46,7 +46,9 @@ declare module 'vue' {
IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
IconLucideInfo: typeof import('~icons/lucide/info')['default']
IconLucideLock: typeof import('~icons/lucide/lock')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideShield: typeof import('~icons/lucide/shield')['default']
IconLucideUser: typeof import('~icons/lucide/user')['default']
OnboardingAuthProviderCard: typeof import('./components/onboarding/AuthProviderCard.vue')['default']
OnboardingAuthSetup: typeof import('./components/onboarding/AuthSetup.vue')['default']
@ -81,5 +83,6 @@ declare module 'vue' {
UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default']
UsersSharedRequests: typeof import('./components/users/SharedRequests.vue')['default']
UsersSuccessInviteModal: typeof import('./components/users/SuccessInviteModal.vue')['default']
UsersTeams: typeof import('./components/users/Teams.vue')['default']
}
}

View file

@ -172,7 +172,7 @@ const props = defineProps<{ isFirstTimeSetup?: boolean }>();
const emit = defineEmits<{
(
e: 'complete-onboarding',
payload: { submittingConfigs: boolean; summary: OnBoardingSummary }
payload: { submittingConfigs: boolean; summary: OnBoardingSummary },
): void;
}>();
@ -212,10 +212,10 @@ watch(
() => enabledConfigs.value,
(configs) => {
isOAuthEnabled.value = OAuthProviders.some((provider) =>
configs.includes(provider)
configs.includes(provider),
);
},
{ immediate: true }
{ immediate: true },
);
watch(
@ -229,32 +229,41 @@ watch(
selectedOptions.value = ['OAUTH', 'SMTP'];
}
},
{ immediate: true }
{ immediate: true },
);
const isSelected = (option: SelectedOption) =>
selectedOptions.value.includes(option as any);
const isSmtpEnabled = computed(() => enabledConfigs.value.includes('MAILER'));
const isSmtpEnabled = computed(
() =>
enabledConfigs.value.includes('MAILER') ||
enabledConfigs.value.includes('EMAIL'),
);
const updateOAuthEnabled = () => {
isOAuthEnabled.value = OAuthProviders.some((provider) =>
enabledConfigs.value.includes(provider)
enabledConfigs.value.includes(provider),
);
};
// Card clicks reconcile enabled state to match the card's selection, rather
// than blindly flipping. When the backend pre-populated enabledConfigs
// (e.g. a prior partial onboarding), a plain toggle would wipe the existing
// EMAIL/GOOGLE entries the moment the user clicks the card to "select" them.
const toggleSelectedOption = (option: SelectedOption) => {
if (selectedOptions.value.includes(option as any)) {
selectedOptions.value = selectedOptions.value.filter(
(opt) => opt !== option
);
} else {
selectedOptions.value.push(option as any);
}
const willBeSelected = !selectedOptions.value.includes(option);
selectedOptions.value = willBeSelected
? [...selectedOptions.value, option]
: selectedOptions.value.filter((opt) => opt !== option);
if (option === 'SMTP') {
toggleConfig('EMAIL');
toggleSmtpConfig();
} else {
if (willBeSelected !== isSmtpEnabled.value) {
toggleConfig('EMAIL');
toggleSmtpConfig();
}
} else if (willBeSelected !== isOAuthEnabled.value) {
toggleConfig(option);
}
};

View file

@ -2,7 +2,8 @@
<div class="space-y-2 flex flex-col my-4">
<UiAccordion
v-for="[provider, value] in Object.entries(currentConfigs.oAuthProviders)"
:initial-open="enabledConfigs.includes(provider as EnabledConfig)"
:key="provider"
:initial-open="shouldOpenProvider(provider as OAuthProvider)"
>
<template v-slot:header="{ isOpen, toggleAccordion }">
<div class="flex items-center justify-between flex-1">
@ -88,6 +89,21 @@ const emit = defineEmits<{
const currentConfigs = useVModel(props, 'currentConfigs');
// A provider accordion should auto-expand if it's in enabledConfigs OR if the
// backend has returned a saved CLIENT_ID for it. Checking CLIENT_ID is a
// direct, reliable signal independent of enabledConfigs propagation timing
// if the backend has data for the provider, it was configured before.
const shouldOpenProvider = (provider: OAuthProvider): boolean => {
if (props.enabledConfigs.includes(provider)) return true;
// Each provider's config object has a `${provider}_CLIENT_ID` key, but
// TS cannot narrow the union literal here cast is intentional.
const providerConfig = props.currentConfigs.oAuthProviders[provider] as Record<
string,
string
>;
return !!providerConfig[`${provider}_CLIENT_ID`];
};
// check if the key is a callback URL
const isCallbackUrl = (key: string): boolean => {
return key.toLowerCase().includes('callback');

View file

@ -1,14 +1,19 @@
<template>
<div class="my-4">
<!-- Basic SMTP Fields -->
<div class="my-2">
<!-- SMTP URL -->
<div class="flex flex-col space-y-2">
<HoppSmartInput
v-model="currentConfigs.mailerConfigs[smtp.ADDRESS_FROM.id]"
:label="smtp.ADDRESS_FROM.text"
:autofocus="false"
input-styles="floating-input"
class="!my-2 !bg-primaryLight flex-1"
/>
<div class="py-2 mb-4">
<!-- Custom Configs -->
<HoppSmartCheckbox
:on="smtp.USE_CUSTOM_CONFIGS.enabled"
@change="toggleConfig('USE_CUSTOM_CONFIGS')"
>
{{ smtp.USE_CUSTOM_CONFIGS.text }}
</HoppSmartCheckbox>
<p class="text-secondaryLight text-tiny mt-2">
{{ t('onboarding.smtp_advanced_config_enable') }}
</p>
</div>
<HoppSmartInput
v-if="smtp.SMTP_URL.enabled"
v-model="currentConfigs.mailerConfigs[smtp.SMTP_URL.id]"
@ -19,19 +24,69 @@
/>
</div>
<!-- Custom Config Fields -->
<!-- Host & Port -->
<div v-if="smtp.USE_CUSTOM_CONFIGS.enabled" class="flex flex-col space-y-2">
<HoppSmartInput
v-for="key in ['SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASSWORD'] as AllMailerConfigKeys[]"
v-for="key in connectionKeys"
:key="key"
v-model="currentConfigs.mailerConfigs[smtp[key].id]"
:label="smtp[key].text"
input-styles="floating-input"
:type="key === 'SMTP_PASSWORD' ? 'password' : 'text'"
class="!my-2 !bg-primaryLight flex-1"
/>
</div>
<!-- Custom Config Checkboxes -->
<!-- From Address (always visible) -->
<HoppSmartInput
v-model="currentConfigs.mailerConfigs[smtp.ADDRESS_FROM.id]"
:label="smtp.ADDRESS_FROM.text"
:autofocus="false"
input-styles="floating-input"
class="!my-2 !bg-primaryLight flex-1"
/>
<!-- Auth & Security -->
<div v-if="smtp.USE_CUSTOM_CONFIGS.enabled" class="flex flex-col space-y-2">
<!-- Auth Type Tabs + Auth Credentials -->
<HoppSmartTabs v-model="authType" render-inactive-tabs>
<HoppSmartTab
id="login"
:icon="IconLock"
:label="t('configs.mail_configs.auth_type_login')"
>
<div class="flex flex-col space-y-2 pt-4">
<HoppSmartInput
v-for="key in loginAuthKeys"
:key="key"
v-model="currentConfigs.mailerConfigs[smtp[key].id]"
:label="smtp[key].text"
input-styles="floating-input"
:type="key === 'SMTP_PASSWORD' ? 'password' : 'text'"
class="!my-2 !bg-primaryLight flex-1"
/>
</div>
</HoppSmartTab>
<HoppSmartTab
id="oauth2"
:icon="IconShield"
:label="t('configs.mail_configs.auth_type_oauth2')"
>
<div class="flex flex-col space-y-2 pt-4">
<HoppSmartInput
v-for="key in oauth2Keys"
:key="key"
v-model="currentConfigs.mailerConfigs[smtp[key].id]"
:label="smtp[key].text"
input-styles="floating-input"
:type="isOAuth2SecretField(key) ? 'password' : 'text'"
class="!my-2 !bg-primaryLight flex-1"
/>
</div>
</HoppSmartTab>
</HoppSmartTabs>
<!-- TLS checkboxes -->
<div class="flex items-center space-x-2">
<HoppSmartCheckbox
:on="smtp.SMTP_SECURE.enabled"
@ -56,17 +111,12 @@
</div>
</div>
<!-- Toggle: Use Custom Configs -->
<HoppSmartCheckbox
class="mt-4"
:on="smtp.USE_CUSTOM_CONFIGS.enabled"
@change="toggleConfig('USE_CUSTOM_CONFIGS')"
>
{{ smtp.USE_CUSTOM_CONFIGS.text }}
</HoppSmartCheckbox>
<p class="text-secondaryLight text-tiny mt-2">
{{ t('onboarding.smtp_advanced_config_enable') }}
</p>
<HoppSmartConfirmModal
:show="showAuthSwitchModal"
:title="t('configs.mail_configs.auth_switch_description')"
@hide-modal="cancelAuthSwitch"
@resolve="confirmAuthSwitch"
/>
</div>
</template>
@ -78,7 +128,10 @@ import {
EnabledConfig,
MailerConfigKeys,
} from '~/composables/useOnboardingConfigHandler';
import { useSmtpAuthTypeSwitch } from '~/composables/useSmtpAuthTypeSwitch';
import { useI18n } from '~/composables/i18n';
import IconLock from '~icons/lucide/lock';
import IconShield from '~icons/lucide/shield';
const t = useI18n();
@ -124,6 +177,18 @@ const smtp = computed<MailerConfig>(() => {
value: cfg.MAILER_USE_CUSTOM_CONFIGS,
enabled: isCustom,
},
SMTP_HOST: {
id: 'MAILER_SMTP_HOST',
text: t('configs.mail_configs.host'),
value: cfg.MAILER_SMTP_HOST,
enabled: isCustom,
},
SMTP_PORT: {
id: 'MAILER_SMTP_PORT',
text: t('configs.mail_configs.port'),
value: cfg.MAILER_SMTP_PORT,
enabled: isCustom,
},
SMTP_SECURE: {
id: 'MAILER_SMTP_SECURE',
text: t('configs.mail_configs.secure'),
@ -154,25 +219,80 @@ const smtp = computed<MailerConfig>(() => {
value: cfg.MAILER_SMTP_PASSWORD,
enabled: isCustom,
},
SMTP_HOST: {
id: 'MAILER_SMTP_HOST',
text: t('configs.mail_configs.host'),
value: cfg.MAILER_SMTP_HOST,
SMTP_AUTH_TYPE: {
id: 'MAILER_SMTP_AUTH_TYPE',
text: t('configs.mail_configs.auth_type'),
value: cfg.MAILER_SMTP_AUTH_TYPE,
enabled: isCustom,
},
SMTP_PORT: {
id: 'MAILER_SMTP_PORT',
text: t('configs.mail_configs.port'),
value: cfg.MAILER_SMTP_PORT,
enabled: isCustom,
SMTP_OAUTH2_USER: {
id: 'MAILER_SMTP_OAUTH2_USER',
text: t('configs.mail_configs.oauth2_user'),
value: cfg.MAILER_SMTP_OAUTH2_USER,
enabled: isCustom && cfg.MAILER_SMTP_AUTH_TYPE === 'oauth2',
},
SMTP_OAUTH2_CLIENT_ID: {
id: 'MAILER_SMTP_OAUTH2_CLIENT_ID',
text: t('configs.mail_configs.oauth2_client_id'),
value: cfg.MAILER_SMTP_OAUTH2_CLIENT_ID,
enabled: isCustom && cfg.MAILER_SMTP_AUTH_TYPE === 'oauth2',
},
SMTP_OAUTH2_CLIENT_SECRET: {
id: 'MAILER_SMTP_OAUTH2_CLIENT_SECRET',
text: t('configs.mail_configs.oauth2_client_secret'),
value: cfg.MAILER_SMTP_OAUTH2_CLIENT_SECRET,
enabled: isCustom && cfg.MAILER_SMTP_AUTH_TYPE === 'oauth2',
},
SMTP_OAUTH2_REFRESH_TOKEN: {
id: 'MAILER_SMTP_OAUTH2_REFRESH_TOKEN',
text: t('configs.mail_configs.oauth2_refresh_token'),
value: cfg.MAILER_SMTP_OAUTH2_REFRESH_TOKEN,
enabled: isCustom && cfg.MAILER_SMTP_AUTH_TYPE === 'oauth2',
},
SMTP_OAUTH2_ACCESS_URL: {
id: 'MAILER_SMTP_OAUTH2_ACCESS_URL',
text: t('configs.mail_configs.oauth2_access_url'),
value: cfg.MAILER_SMTP_OAUTH2_ACCESS_URL,
enabled: isCustom && cfg.MAILER_SMTP_AUTH_TYPE === 'oauth2',
},
};
});
// Field key groups in logical UX order
const connectionKeys: AllMailerConfigKeys[] = ['SMTP_HOST', 'SMTP_PORT'];
const loginAuthKeys: AllMailerConfigKeys[] = ['SMTP_USER', 'SMTP_PASSWORD'];
const oauth2Keys: AllMailerConfigKeys[] = [
'SMTP_OAUTH2_USER',
'SMTP_OAUTH2_CLIENT_ID',
'SMTP_OAUTH2_CLIENT_SECRET',
'SMTP_OAUTH2_REFRESH_TOKEN',
'SMTP_OAUTH2_ACCESS_URL',
];
const isOAuth2SecretField = (key: AllMailerConfigKeys) =>
['SMTP_OAUTH2_CLIENT_SECRET', 'SMTP_OAUTH2_REFRESH_TOKEN'].includes(key);
const toggleConfig = (key: AllMailerConfigKeys) => {
const id = smtp.value[key].id;
const current = currentConfigs.value.mailerConfigs[id];
currentConfigs.value.mailerConfigs[id] =
current === 'true' ? 'false' : 'true';
};
const LOGIN_KEYS: ConfigKey[] = ['MAILER_SMTP_USER', 'MAILER_SMTP_PASSWORD'];
const OAUTH2_KEYS: ConfigKey[] = [
'MAILER_SMTP_OAUTH2_USER',
'MAILER_SMTP_OAUTH2_CLIENT_ID',
'MAILER_SMTP_OAUTH2_CLIENT_SECRET',
'MAILER_SMTP_OAUTH2_REFRESH_TOKEN',
'MAILER_SMTP_OAUTH2_ACCESS_URL',
];
const { authType, showAuthSwitchModal, confirmAuthSwitch, cancelAuthSwitch } =
useSmtpAuthTypeSwitch<ConfigKey>(
() => currentConfigs.value.mailerConfigs,
'MAILER_SMTP_AUTH_TYPE',
LOGIN_KEYS,
OAUTH2_KEYS,
);
</script>

View file

@ -46,59 +46,191 @@
{{ t('configs.mail_configs.custom_smtp_configs') }}
</HoppSmartCheckbox>
</div>
<div
v-for="field in smtpConfigFields"
:key="field.key"
class="mt-5 ml-12"
>
<div v-if="fieldCondition(field)">
<div
v-if="isCheckboxField(field)"
class="flex flex-col items-start gap-5"
>
<HoppSmartCheckbox
:on="Boolean(smtpConfigs.fields[field.key])"
@change="toggleCheckbox(field)"
>
{{ makeReadableKey(field.name, true) }}
</HoppSmartCheckbox>
</div>
<span v-else>
<label>{{ makeReadableKey(field.name, true) }}</label>
<span class="flex max-w-lg">
<HoppSmartInput
v-model="smtpConfigs.fields[field.key]"
:type="isMasked(field.key) ? 'password' : 'text'"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1 border border-divider rounded"
input-styles="!border-0"
>
<template #button>
<HoppButtonSecondary
:icon="isMasked(field.key) ? IconEye : IconEyeOff"
class="bg-primaryLight rounded"
@click="toggleMask(field.key)"
/>
</template>
</HoppSmartInput>
</span>
<div
v-if="getFieldError(field.key)"
class="flex items-center justify-between bg-red-200 px-2 py-2 font-semibold text-red-700 rounded-lg mt-2 max-w-lg"
<!-- SMTP URL -->
<template v-if="!smtpConfigs.fields.mailer_use_custom_configs">
<div
v-for="field in basicFields"
:key="field.key"
class="mt-5 ml-12"
>
<label>{{ field.name }}</label>
<span class="flex max-w-lg">
<HoppSmartInput
v-model="stringFields[field.key]"
:type="isMasked(field.key) ? 'password' : 'text'"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1 border border-divider rounded"
input-styles="!border-0"
>
<div class="flex items-center">
<icon-lucide-info class="mr-2" />
<span> {{ field.error }} </span>
</div>
<template #button>
<HoppButtonSecondary
:icon="isMasked(field.key) ? IconEye : IconEyeOff"
class="bg-primaryLight rounded"
@click="toggleMask(field.key)"
/>
</template>
</HoppSmartInput>
</span>
<div
v-if="getFieldError(field.key)"
class="flex items-center justify-between bg-red-200 px-2 py-2 font-semibold text-red-700 rounded-lg mt-2 max-w-lg"
>
<div class="flex items-center">
<icon-lucide-info class="mr-2" />
<span>{{ field.error }}</span>
</div>
</div>
</div>
</template>
<!-- CUSTOM MODE -->
<template v-if="smtpConfigs.fields.mailer_use_custom_configs">
<!-- Host & Port -->
<div
v-for="field in connectionFields"
:key="field.key"
class="mt-5 ml-12"
>
<label>{{ field.name }}</label>
<span class="flex max-w-lg">
<HoppSmartInput
v-model="stringFields[field.key]"
:type="isMasked(field.key) ? 'password' : 'text'"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1 border border-divider rounded"
input-styles="!border-0"
>
<template #button>
<HoppButtonSecondary
:icon="isMasked(field.key) ? IconEye : IconEyeOff"
class="bg-primaryLight rounded"
@click="toggleMask(field.key)"
/>
</template>
</HoppSmartInput>
</span>
</div>
</template>
<!-- From Address (always visible) -->
<div class="mt-5 ml-12">
<label>{{ t('configs.mail_configs.address_from') }}</label>
<span class="flex max-w-lg">
<HoppSmartInput
v-model="smtpConfigs.fields.mailer_from_address"
:type="isMasked('mailer_from_address') ? 'password' : 'text'"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1 border border-divider rounded"
input-styles="!border-0"
>
<template #button>
<HoppButtonSecondary
:icon="
isMasked('mailer_from_address') ? IconEye : IconEyeOff
"
class="bg-primaryLight rounded"
@click="toggleMask('mailer_from_address')"
/>
</template>
</HoppSmartInput>
</span>
</div>
<!-- Custom mode: Auth & Security -->
<template v-if="smtpConfigs.fields.mailer_use_custom_configs">
<!-- Auth Type Tabs + Auth Credentials -->
<div class="mt-5 ml-12 max-w-lg">
<HoppSmartTabs v-model="authType" render-inactive-tabs>
<HoppSmartTab
id="login"
:icon="IconLock"
:label="t('configs.mail_configs.auth_type_login')"
>
<div class="space-y-1 pt-4">
<div v-for="field in loginAuthFields" :key="field.key">
<label>{{ field.name }}</label>
<span class="flex">
<HoppSmartInput
v-model="stringFields[field.key]"
:type="isMasked(field.key) ? 'password' : 'text'"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1 border border-divider rounded"
input-styles="!border-0"
>
<template #button>
<HoppButtonSecondary
:icon="
isMasked(field.key) ? IconEye : IconEyeOff
"
class="bg-primaryLight rounded"
@click="toggleMask(field.key)"
/>
</template>
</HoppSmartInput>
</span>
</div>
</div>
</HoppSmartTab>
<HoppSmartTab
id="oauth2"
:icon="IconShield"
:label="t('configs.mail_configs.auth_type_oauth2')"
>
<div class="space-y-1 pt-4">
<div v-for="field in oauth2Fields" :key="field.key">
<label>{{ field.name }}</label>
<span class="flex">
<HoppSmartInput
v-model="stringFields[field.key]"
:type="isMasked(field.key) ? 'password' : 'text'"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1 border border-divider rounded"
input-styles="!border-0"
>
<template #button>
<HoppButtonSecondary
:icon="
isMasked(field.key) ? IconEye : IconEyeOff
"
class="bg-primaryLight rounded"
@click="toggleMask(field.key)"
/>
</template>
</HoppSmartInput>
</span>
</div>
</div>
</HoppSmartTab>
</HoppSmartTabs>
</div>
<!-- TLS checkboxes -->
<div
v-for="field in securityFields"
:key="field.key"
class="mt-5 ml-12 flex flex-col items-start gap-5"
>
<HoppSmartCheckbox
:on="Boolean(smtpConfigs.fields[field.key])"
@change="toggleCheckbox(field)"
>
{{ field.name }}
</HoppSmartCheckbox>
</div>
</template>
</div>
</div>
</section>
</div>
<HoppSmartConfirmModal
:show="showAuthSwitchModal"
:title="t('configs.mail_configs.auth_switch_description')"
@hide-modal="cancelAuthSwitch"
@resolve="confirmAuthSwitch"
/>
</div>
</template>
@ -106,11 +238,13 @@
import { useVModel } from '@vueuse/core';
import { computed, reactive, watch } from 'vue';
import { useI18n } from '~/composables/i18n';
import { useSmtpAuthTypeSwitch } from '~/composables/useSmtpAuthTypeSwitch';
import { hasInputValidationFailed, ServerConfigs } from '~/helpers/configs';
import { makeReadableKey } from '~/helpers/utils/readableKey';
import IconEye from '~icons/lucide/eye';
import IconEyeOff from '~icons/lucide/eye-off';
import IconHelpCircle from '~icons/lucide/help-circle';
import IconLock from '~icons/lucide/lock';
import IconShield from '~icons/lucide/shield';
const t = useI18n();
@ -124,7 +258,6 @@ const emit = defineEmits<{
const workingConfigs = useVModel(props, 'config', emit);
// Get or set smtpConfigs from workingConfigs
const smtpConfigs = computed({
get() {
return workingConfigs.value?.mailConfigs;
@ -134,43 +267,82 @@ const smtpConfigs = computed({
},
});
// Mask sensitive fields
type MailFields = ServerConfigs['mailConfigs']['fields'];
// Extract only the keys whose value type is `string`
type StringFieldKey = {
[K in keyof MailFields]: MailFields[K] extends string ? K : never;
}[keyof MailFields];
type BooleanFieldKey = {
[K in keyof MailFields]: MailFields[K] extends boolean ? K : never;
}[keyof MailFields];
type Field = {
name: string;
key: keyof ServerConfigs['mailConfigs']['fields'];
key: StringFieldKey;
error?: string;
};
const smtpConfigFields = reactive<Field[]>([
type CheckboxField = {
name: string;
key: BooleanFieldKey;
};
// A typed view of `fields` that only exposes string-valued keys,
// so that `v-model="stringFields[field.key]"` resolves to `string`.
const stringFields = computed(
() => smtpConfigs.value.fields as Pick<MailFields, StringFieldKey>,
);
// Basic mode: just the SMTP URL
const basicFields: Field[] = [
{
name: t('configs.mail_configs.smtp_url'),
key: 'mailer_smtp_url',
error: t('configs.mail_configs.input_validation'),
},
];
// Connection
const connectionFields: Field[] = [
{ name: t('configs.mail_configs.host'), key: 'mailer_smtp_host' },
{ name: t('configs.mail_configs.port'), key: 'mailer_smtp_port' },
];
// Login auth credentials
const loginAuthFields: Field[] = [
{ name: t('configs.mail_configs.user'), key: 'mailer_smtp_user' },
{ name: t('configs.mail_configs.password'), key: 'mailer_smtp_password' },
];
// OAuth2 auth credentials
const oauth2Fields: Field[] = [
{
name: t('configs.mail_configs.address_from'),
key: 'mailer_from_address',
name: t('configs.mail_configs.oauth2_user'),
key: 'mailer_smtp_oauth2_user',
},
{
name: t('configs.mail_configs.host'),
key: 'mailer_smtp_host',
name: t('configs.mail_configs.oauth2_client_id'),
key: 'mailer_smtp_oauth2_client_id',
},
{
name: t('configs.mail_configs.port'),
key: 'mailer_smtp_port',
name: t('configs.mail_configs.oauth2_client_secret'),
key: 'mailer_smtp_oauth2_client_secret',
},
{
name: t('configs.mail_configs.user'),
key: 'mailer_smtp_user',
name: t('configs.mail_configs.oauth2_refresh_token'),
key: 'mailer_smtp_oauth2_refresh_token',
},
{
name: t('configs.mail_configs.password'),
key: 'mailer_smtp_password',
},
{
name: t('configs.mail_configs.secure'),
key: 'mailer_smtp_secure',
name: t('configs.mail_configs.oauth2_access_url'),
key: 'mailer_smtp_oauth2_access_url',
},
];
// Security checkboxes
const securityFields: CheckboxField[] = [
{ name: t('configs.mail_configs.secure'), key: 'mailer_smtp_secure' },
{
name: t('configs.mail_configs.ignore_tls'),
key: 'mailer_smtp_ignore_tls',
@ -179,7 +351,7 @@ const smtpConfigFields = reactive<Field[]>([
name: t('configs.mail_configs.tls_reject_unauthorized'),
key: 'mailer_tls_reject_unauthorized',
},
]);
];
const maskState = reactive<Record<string, boolean>>({
mailer_smtp_url: true,
@ -188,6 +360,11 @@ const maskState = reactive<Record<string, boolean>>({
mailer_smtp_port: true,
mailer_smtp_user: true,
mailer_smtp_password: true,
mailer_smtp_oauth2_user: true,
mailer_smtp_oauth2_client_id: true,
mailer_smtp_oauth2_client_secret: true,
mailer_smtp_oauth2_refresh_token: true,
mailer_smtp_oauth2_access_url: true,
});
const toggleMask = (fieldKey: keyof ServerConfigs['mailConfigs']['fields']) => {
@ -197,41 +374,8 @@ const toggleMask = (fieldKey: keyof ServerConfigs['mailConfigs']['fields']) => {
const isMasked = (fieldKey: keyof ServerConfigs['mailConfigs']['fields']) =>
maskState[fieldKey];
const fieldCondition = (field: Field) => {
const advancedFields = [
'mailer_smtp_host',
'mailer_smtp_port',
'mailer_smtp_user',
'mailer_smtp_password',
'mailer_smtp_secure',
'mailer_smtp_ignore_tls',
'mailer_tls_reject_unauthorized',
];
const basicFields = ['mailer_smtp_url'];
if (field.key === 'mailer_from_address') {
return true;
}
if (smtpConfigs.value.fields.mailer_use_custom_configs) {
return (
!basicFields.includes(field.key) && advancedFields.includes(field.key)
);
} else return basicFields.includes(field.key);
};
const isCheckboxField = (field: Field) => {
const checkboxKeys = [
'mailer_smtp_secure',
'mailer_smtp_ignore_tls',
'mailer_tls_reject_unauthorized',
];
return checkboxKeys.includes(field.key);
};
const toggleCheckbox = (field: Field) =>
((smtpConfigs.value.fields[field.key] as boolean) =
!smtpConfigs.value.fields[field.key]);
const toggleCheckbox = (field: CheckboxField) =>
(smtpConfigs.value.fields[field.key] = !smtpConfigs.value.fields[field.key]);
// Input Validation
const fieldErrors = computed(() => {
@ -246,11 +390,29 @@ const fieldErrors = computed(() => {
return errors;
});
const getFieldError = (
fieldKey: keyof ServerConfigs['mailConfigs']['fields']
) => fieldErrors.value[fieldKey];
const getFieldError = (fieldKey: StringFieldKey) => fieldErrors.value[fieldKey];
watch(fieldErrors, (errors) => {
hasInputValidationFailed.value = Object.values(errors).some(Boolean);
});
const LOGIN_KEYS: StringFieldKey[] = [
'mailer_smtp_user',
'mailer_smtp_password',
];
const OAUTH2_KEYS: StringFieldKey[] = [
'mailer_smtp_oauth2_user',
'mailer_smtp_oauth2_client_id',
'mailer_smtp_oauth2_client_secret',
'mailer_smtp_oauth2_refresh_token',
'mailer_smtp_oauth2_access_url',
];
const { authType, showAuthSwitchModal, confirmAuthSwitch, cancelAuthSwitch } =
useSmtpAuthTypeSwitch<StringFieldKey>(
() => stringFields.value,
'mailer_smtp_auth_type',
LOGIN_KEYS,
OAUTH2_KEYS,
);
</script>

View file

@ -41,7 +41,7 @@
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from '~/composables/i18n';
const t = useI18n();
@ -60,18 +60,18 @@ const idSuffix = Math.random().toString(36).substring(2, 9);
const contentId = `accordion-content-${idSuffix}`;
const headerId = `accordion-header-${idSuffix}`;
const isOpen = ref(false);
// `isOpen` follows `initialOpen` reactively until the user toggles manually.
// After a manual toggle, `userOverride` takes precedence so subsequent
// prop changes (e.g. backend data arriving late) don't snap the accordion
// back open/closed against the user's intent.
const userOverride = ref<boolean | null>(null);
watch(
() => props.initialOpen,
(val) => {
isOpen.value = val ?? false;
},
{ immediate: true }
const isOpen = computed(() =>
userOverride.value !== null ? userOverride.value : props.initialOpen ?? false
);
const toggleAccordion = () => {
isOpen.value = !isOpen.value;
emit('toggle', isOpen.value);
userOverride.value = !isOpen.value;
emit('toggle', userOverride.value);
};
</script>

View file

@ -144,6 +144,23 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
'true',
mailer_use_custom_configs:
getFieldValue(InfraConfigEnum.MailerUseCustomConfigs) === 'true',
mailer_smtp_auth_type:
getFieldValue(InfraConfigEnum.MailerSmtpAuthType) || 'login',
mailer_smtp_oauth2_user: getFieldValue(
InfraConfigEnum.MailerSmtpOauth2User
),
mailer_smtp_oauth2_client_id: getFieldValue(
InfraConfigEnum.MailerSmtpOauth2ClientId
),
mailer_smtp_oauth2_client_secret: getFieldValue(
InfraConfigEnum.MailerSmtpOauth2ClientSecret
),
mailer_smtp_oauth2_refresh_token: getFieldValue(
InfraConfigEnum.MailerSmtpOauth2RefreshToken
),
mailer_smtp_oauth2_access_url: getFieldValue(
InfraConfigEnum.MailerSmtpOauth2AccessUrl
),
},
},
tokenConfigs: {
@ -277,13 +294,23 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
// SMTP user and password are optional as a pair (both or neither)
const optionalMailerKeys = ['mailer_smtp_user', 'mailer_smtp_password'];
// OAuth2 fields are always optional and auth_type has a default
const oauth2Keys = [
'mailer_smtp_auth_type',
'mailer_smtp_oauth2_user',
'mailer_smtp_oauth2_client_id',
'mailer_smtp_oauth2_client_secret',
'mailer_smtp_oauth2_refresh_token',
'mailer_smtp_oauth2_access_url',
];
const excludeKeys = mailer_use_custom_configs
? ['mailer_smtp_url', ...optionalMailerKeys]
? ['mailer_smtp_url', ...optionalMailerKeys, ...oauth2Keys]
: [
'mailer_smtp_host',
'mailer_smtp_port',
'mailer_smtp_user',
'mailer_smtp_password',
...oauth2Keys,
];
return (
@ -323,6 +350,10 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
const fields = config.mailConfigs.fields;
if (!fields.mailer_use_custom_configs) return false;
// Enforced regardless of auth_type: the backend validates the pair
// on every save, so stale login values left behind after switching
// to the OAuth2 tab would still be rejected. Surface this in the FE
// toast so users know to clear those fields before saving.
const hasUser = fields.mailer_smtp_user.trim() !== '';
const hasPass = fields.mailer_smtp_password.trim() !== '';

View file

@ -31,7 +31,13 @@ export type MailerConfigKeys =
| 'SMTP_USER'
| 'SMTP_PASSWORD'
| 'SMTP_IGNORE_TLS'
| 'TLS_REJECT_UNAUTHORIZED';
| 'TLS_REJECT_UNAUTHORIZED'
| 'SMTP_AUTH_TYPE'
| 'SMTP_OAUTH2_USER'
| 'SMTP_OAUTH2_CLIENT_ID'
| 'SMTP_OAUTH2_CLIENT_SECRET'
| 'SMTP_OAUTH2_REFRESH_TOKEN'
| 'SMTP_OAUTH2_ACCESS_URL';
export type Configs = {
oAuthProviders: {
@ -52,7 +58,7 @@ export type OnBoardingSummary = {
};
function mapOAuthProviders(
configs: Partial<Record<InfraConfigEnum, string>>
configs: Partial<Record<InfraConfigEnum, string>>,
): Configs['oAuthProviders'] {
return {
GOOGLE: {
@ -78,7 +84,7 @@ function mapOAuthProviders(
}
function mapMailerConfigs(
configs: Partial<Record<InfraConfigEnum, string>>
configs: Partial<Record<InfraConfigEnum, string>>,
): Configs['mailerConfigs'] {
return {
MAILER_SMTP_ENABLE: configs.MAILER_SMTP_ENABLE ?? '',
@ -93,6 +99,14 @@ function mapMailerConfigs(
MAILER_SMTP_IGNORE_TLS: configs.MAILER_SMTP_IGNORE_TLS || 'false',
MAILER_TLS_REJECT_UNAUTHORIZED:
configs.MAILER_TLS_REJECT_UNAUTHORIZED || 'false',
MAILER_SMTP_AUTH_TYPE: configs.MAILER_SMTP_AUTH_TYPE || 'login',
MAILER_SMTP_OAUTH2_USER: configs.MAILER_SMTP_OAUTH2_USER ?? '',
MAILER_SMTP_OAUTH2_CLIENT_ID: configs.MAILER_SMTP_OAUTH2_CLIENT_ID ?? '',
MAILER_SMTP_OAUTH2_CLIENT_SECRET:
configs.MAILER_SMTP_OAUTH2_CLIENT_SECRET ?? '',
MAILER_SMTP_OAUTH2_REFRESH_TOKEN:
configs.MAILER_SMTP_OAUTH2_REFRESH_TOKEN ?? '',
MAILER_SMTP_OAUTH2_ACCESS_URL: configs.MAILER_SMTP_OAUTH2_ACCESS_URL ?? '',
};
}
@ -132,7 +146,7 @@ export function useOnboardingConfigHandler() {
const toggleConfig = (key: EnabledConfig | 'OAUTH' | 'EMAIL') => {
if (key === 'OAUTH') {
enabledConfigs.value = enabledConfigs.value.filter(
(c) => !['GOOGLE', 'GITHUB', 'MICROSOFT'].includes(c)
(c) => !['GOOGLE', 'GITHUB', 'MICROSOFT'].includes(c),
);
}
@ -140,7 +154,7 @@ export function useOnboardingConfigHandler() {
const hasEmail = enabledConfigs.value.includes('EMAIL');
const hasMailer = enabledConfigs.value.includes('MAILER');
enabledConfigs.value = enabledConfigs.value.filter(
(c) => c !== 'EMAIL' && c !== 'MAILER'
(c) => c !== 'EMAIL' && c !== 'MAILER',
);
if (!hasEmail || !hasMailer) {
enabledConfigs.value.push('EMAIL', 'MAILER');
@ -234,6 +248,12 @@ export function useOnboardingConfigHandler() {
'MAILER_SMTP_IGNORE_TLS',
'MAILER_TLS_REJECT_UNAUTHORIZED',
'MAILER_SMTP_ENABLE',
'MAILER_SMTP_AUTH_TYPE',
'MAILER_SMTP_OAUTH2_USER',
'MAILER_SMTP_OAUTH2_CLIENT_ID',
'MAILER_SMTP_OAUTH2_CLIENT_SECRET',
'MAILER_SMTP_OAUTH2_REFRESH_TOKEN',
'MAILER_SMTP_OAUTH2_ACCESS_URL',
].includes(key);
});
};
@ -252,7 +272,7 @@ export function useOnboardingConfigHandler() {
}
const relevantKeys = Object.keys(configs).filter((key) =>
enabledConfigs.value.includes(key.split('_')[0] as EnabledConfig)
enabledConfigs.value.includes(key.split('_')[0] as EnabledConfig),
);
const neededKeys = filterNeededConfigs(relevantKeys);
@ -263,9 +283,14 @@ export function useOnboardingConfigHandler() {
const optionalSmtpKeys = new Set([
'MAILER_SMTP_USER',
'MAILER_SMTP_PASSWORD',
'MAILER_SMTP_OAUTH2_USER',
'MAILER_SMTP_OAUTH2_CLIENT_ID',
'MAILER_SMTP_OAUTH2_CLIENT_SECRET',
'MAILER_SMTP_OAUTH2_REFRESH_TOKEN',
'MAILER_SMTP_OAUTH2_ACCESS_URL',
]);
const allFilled = neededKeys.every(
(key) => configs[key] || optionalSmtpKeys.has(key)
(key) => configs[key] || optionalSmtpKeys.has(key),
);
if (!allFilled) {
@ -274,14 +299,17 @@ export function useOnboardingConfigHandler() {
toast.error(
t('onboarding.please_fill_configurations', {
fieldName: makeReadableKey(key),
})
}),
);
});
return;
}
// SMTP credentials must be provided together or both left empty.
// Only enforce when custom SMTP mode is active (not simple URL mode).
// Enforced regardless of auth_type: the backend validates the pair on
// every save, so stale login values left behind after switching to the
// OAuth2 tab would still be rejected. Surface this in the FE toast so
// users know to clear those fields before saving.
if (
enabledConfigs.value.includes('MAILER') &&
configs['MAILER_USE_CUSTOM_CONFIGS'] === 'true'
@ -301,8 +329,8 @@ export function useOnboardingConfigHandler() {
Object.entries(configs).filter(
([key, val]) =>
enabledConfigs.value.includes(key.split('_')[0] as EnabledConfig) &&
(val || optionalSmtpKeys.has(key))
)
(val || optionalSmtpKeys.has(key)),
),
);
};
@ -331,7 +359,7 @@ export function useOnboardingConfigHandler() {
}
const filteredEnabledConfigs = enabledConfigs.value.filter(
(config) => config !== 'OAUTH' && config !== 'MAILER'
(config) => config !== 'OAUTH' && config !== 'MAILER',
);
const configWithAuth = {
@ -371,7 +399,22 @@ export function useOnboardingConfigHandler() {
const allowed = configs[InfraConfigEnum.ViteAllowedAuthProviders];
if (allowed) {
enabledConfigs.value = allowed.split(',') as EnabledConfig[];
// Trim each entry so whitespace variations ("GOOGLE, EMAIL") don't
// cause provider-name mismatches in downstream `.includes()` checks.
const parsed = allowed
.split(',')
.map((p) => p.trim())
.filter(Boolean) as EnabledConfig[];
// The backend persists only 'EMAIL' in VITE_ALLOWED_AUTH_PROVIDERS,
// but internally we also track 'MAILER' as the signal that MAILER_*
// keys should be kept on save. toggleConfig('EMAIL') pairs them, so
// mirror that invariant on load to keep the two flags in sync.
if (parsed.includes('EMAIL') && !parsed.includes('MAILER')) {
parsed.push('MAILER');
}
enabledConfigs.value = parsed;
}
currentConfigs.value = {
@ -404,7 +447,7 @@ export function useOnboardingConfigHandler() {
enableConfig('EMAIL');
}
},
{ deep: true, immediate: true }
{ deep: true, immediate: true },
);
return {

View file

@ -0,0 +1,63 @@
import { computed, ref } from 'vue';
// Shared auth-type tab switching logic for SMTP configuration UIs.
// When switching between login/oauth2 tabs, if the current tab has data
// we confirm before clearing — so stale credentials don't get persisted
// alongside the active set.
//
// `fields` is a lazy accessor to the reactive record (the two call sites
// nest it differently: `currentConfigs.mailerConfigs` vs `smtpConfigs.fields`).
// Calling it inside computed getters/setters preserves reactivity.
export function useSmtpAuthTypeSwitch<K extends string>(
fields: () => Record<K, string>,
authKey: K,
loginKeys: readonly K[],
oauth2Keys: readonly K[],
) {
const showAuthSwitchModal = ref(false);
const pendingAuthType = ref<string | null>(null);
const hasAnyValue = (keys: readonly K[]) =>
keys.some((k) => (fields()[k] ?? '').trim() !== '');
const authType = computed({
get: () => fields()[authKey],
set: (next: string) => {
const current = fields()[authKey];
if (next === current) return;
const keysToClear = current === 'oauth2' ? oauth2Keys : loginKeys;
if (hasAnyValue(keysToClear)) {
pendingAuthType.value = next;
showAuthSwitchModal.value = true;
return;
}
fields()[authKey] = next;
},
});
const confirmAuthSwitch = () => {
if (pendingAuthType.value === null) return;
const current = fields()[authKey];
const keysToClear = current === 'oauth2' ? oauth2Keys : loginKeys;
keysToClear.forEach((k) => {
fields()[k] = '';
});
fields()[authKey] = pendingAuthType.value;
pendingAuthType.value = null;
showAuthSwitchModal.value = false;
};
const cancelAuthSwitch = () => {
pendingAuthType.value = null;
showAuthSwitchModal.value = false;
};
return {
authType,
showAuthSwitchModal,
confirmAuthSwitch,
cancelAuthSwitch,
};
}

View file

@ -56,6 +56,12 @@ export type ServerConfigs = {
mailer_smtp_ignore_tls: boolean;
mailer_tls_reject_unauthorized: boolean;
mailer_use_custom_configs: boolean;
mailer_smtp_auth_type: string;
mailer_smtp_oauth2_user: string;
mailer_smtp_oauth2_client_id: string;
mailer_smtp_oauth2_client_secret: string;
mailer_smtp_oauth2_refresh_token: string;
mailer_smtp_oauth2_access_url: string;
};
};
@ -230,6 +236,30 @@ export const CUSTOM_MAIL_CONFIGS: Config[] = [
name: InfraConfigEnum.MailerTlsRejectUnauthorized,
key: 'mailer_tls_reject_unauthorized',
},
{
name: InfraConfigEnum.MailerSmtpAuthType,
key: 'mailer_smtp_auth_type',
},
{
name: InfraConfigEnum.MailerSmtpOauth2User,
key: 'mailer_smtp_oauth2_user',
},
{
name: InfraConfigEnum.MailerSmtpOauth2ClientId,
key: 'mailer_smtp_oauth2_client_id',
},
{
name: InfraConfigEnum.MailerSmtpOauth2ClientSecret,
key: 'mailer_smtp_oauth2_client_secret',
},
{
name: InfraConfigEnum.MailerSmtpOauth2RefreshToken,
key: 'mailer_smtp_oauth2_refresh_token',
},
{
name: InfraConfigEnum.MailerSmtpOauth2AccessUrl,
key: 'mailer_smtp_oauth2_access_url',
},
];
const DATA_SHARING_CONFIGS: Omit<Config, 'key'>[] = [