Files
InvoiceShelf/resources/scripts/features/company/settings/components/MailConfigurationForm.vue
2026-04-09 10:06:27 +02:00

698 lines
22 KiB
Vue

<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { email, helpers, numeric, required } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import type { MailConfig, MailDriver } from '@/scripts/types/mail-config'
interface SelectOption<TValue extends string = string> {
label: string
value: TValue
}
const props = withDefaults(
defineProps<{
configData?: Partial<MailConfig>
isSaving?: boolean
isFetchingInitialData?: boolean
mailDrivers?: MailDriver[]
translationScope?: string
submitLabel?: string
submitIcon?: string
}>(),
{
configData: () => ({}),
isSaving: false,
isFetchingInitialData: false,
mailDrivers: () => [],
translationScope: 'settings.mail',
submitLabel: 'general.save',
submitIcon: 'ArrowDownOnSquareIcon',
}
)
const emit = defineEmits<{
'submit-data': [config: MailConfig]
'on-change-driver': [driver: MailDriver]
}>()
const { t } = useI18n()
const visibleSecrets = reactive<Record<string, boolean>>({})
const showAdvancedFields = ref(false)
const mailConfig = reactive<MailConfig>(createDefaultMailConfig())
const fallbackDrivers: MailDriver[] = ['smtp', 'mail', 'sendmail']
const encryptionOptions: SelectOption[] = [
{ label: 'None', value: 'none' },
{ label: 'TLS', value: 'tls' },
{ label: 'SSL', value: 'ssl' },
]
const smtpSchemeOptions: SelectOption[] = [
{ label: 'SMTP', value: 'smtp' },
{ label: 'SMTPS', value: 'smtps' },
]
const mailgunSchemeOptions: SelectOption[] = [
{ label: 'HTTPS', value: 'https' },
{ label: 'API', value: 'api' },
]
const availableDrivers = computed<MailDriver[]>(() => {
return props.mailDrivers.length ? props.mailDrivers : fallbackDrivers
})
const driverOptions = computed<SelectOption<MailDriver>[]>(() => {
return availableDrivers.value.map((driver) => ({
label: t(`${props.translationScope}.drivers.${driver}`),
value: driver,
}))
})
const currentDriver = computed<MailDriver>({
get: () => normalizeDriver(mailConfig.mail_driver, availableDrivers.value),
set: (driver) => {
mailConfig.mail_driver = driver
},
})
const hasAdvancedFields = computed<boolean>(() => {
return getAdvancedFields(currentDriver.value).length > 0
})
const rules = computed(() => {
const commonRules = {
mail_driver: {
required: helpers.withMessage(t('validation.required'), required),
},
from_mail: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
from_name: {
required: helpers.withMessage(t('validation.required'), required),
},
}
switch (currentDriver.value) {
case 'smtp':
return {
...commonRules,
mail_host: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_port: {
required: helpers.withMessage(t('validation.required'), required),
numeric: helpers.withMessage(t('validation.numbers_only'), numeric),
},
mail_timeout: {
numeric: helpers.withMessage(t('validation.numbers_only'), (value: string) => {
if (!value) {
return true
}
return /^\d+$/.test(value)
}),
},
}
case 'ses':
return {
...commonRules,
mail_ses_key: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_ses_secret: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_ses_region: {
required: helpers.withMessage(t('validation.required'), required),
},
}
case 'mailgun':
return {
...commonRules,
mail_mailgun_domain: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_mailgun_secret: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_mailgun_endpoint: {
required: helpers.withMessage(t('validation.required'), required),
},
}
case 'postmark':
return {
...commonRules,
mail_postmark_token: {
required: helpers.withMessage(t('validation.required'), required),
},
}
default:
return commonRules
}
})
const v$ = useVuelidate(rules, mailConfig)
watch(
() => [props.configData, props.mailDrivers] as const,
() => {
syncMailConfig()
},
{ immediate: true, deep: true }
)
function createDefaultMailConfig(): MailConfig {
return {
mail_driver: 'smtp',
from_mail: '',
from_name: '',
mail_host: '',
mail_port: '587',
mail_username: '',
mail_password: '',
mail_encryption: 'none',
mail_scheme: '',
mail_url: '',
mail_timeout: '',
mail_local_domain: '',
mail_sendmail_path: '/usr/sbin/sendmail -bs -i',
mail_ses_key: '',
mail_ses_secret: '',
mail_ses_region: 'us-east-1',
mail_mailgun_domain: '',
mail_mailgun_secret: '',
mail_mailgun_endpoint: 'api.mailgun.net',
mail_mailgun_scheme: 'https',
mail_postmark_token: '',
mail_postmark_message_stream_id: '',
}
}
function normalizeDriver(driver: MailConfig['mail_driver'], drivers: MailDriver[]): MailDriver {
if (driver && drivers.includes(driver as MailDriver)) {
return driver as MailDriver
}
return drivers[0] ?? 'smtp'
}
function syncMailConfig(): void {
Object.assign(mailConfig, createDefaultMailConfig(), props.configData)
mailConfig.mail_driver = normalizeDriver(mailConfig.mail_driver, availableDrivers.value)
showAdvancedFields.value = hasAdvancedValues(currentDriver.value)
v$.value.$reset()
}
function hasAdvancedValues(driver: MailDriver): boolean {
const defaultMailConfig = createDefaultMailConfig()
return getAdvancedFields(driver).some((field) => {
const value = mailConfig[field]
const defaultValue = defaultMailConfig[field]
return value !== '' && value !== null && value !== undefined && value !== defaultValue
})
}
function getAdvancedFields(driver: MailDriver): Array<keyof MailConfig> {
switch (driver) {
case 'smtp':
return ['mail_scheme', 'mail_url', 'mail_timeout', 'mail_local_domain']
case 'sendmail':
return ['mail_sendmail_path']
case 'mailgun':
return ['mail_mailgun_scheme']
case 'postmark':
return ['mail_postmark_message_stream_id']
default:
return []
}
}
function getFieldError(field: string): string | undefined {
const validationField = v$.value[field as keyof typeof v$.value]
if (!validationField || !('$error' in validationField) || !validationField.$error) {
return undefined
}
return validationField.$errors[0]?.$message as string | undefined
}
function toggleSecret(field: string): void {
visibleSecrets[field] = !visibleSecrets[field]
}
function getSecretInputType(field: string): string {
return visibleSecrets[field] ? 'text' : 'password'
}
function translationKey(key: string): string {
return `${props.translationScope}.${key}`
}
function changeDriver(value: MailDriver): void {
currentDriver.value = value
showAdvancedFields.value = false
v$.value.$reset()
emit('on-change-driver', value)
}
async function saveEmailConfig(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
emit('submit-data', { ...mailConfig, mail_driver: currentDriver.value })
}
</script>
<template>
<form @submit.prevent="saveEmailConfig">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<BaseInputGroup
:label="$t(translationKey('driver'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_driver')"
required
>
<BaseMultiselect
v-model="currentDriver"
:content-loading="isFetchingInitialData"
:options="driverOptions"
label="label"
value-prop="value"
:can-deselect="false"
:can-clear="false"
:invalid="v$.mail_driver.$error"
@update:model-value="changeDriver"
/>
</BaseInputGroup>
</div>
<div class="mt-8">
<h3 class="text-sm font-semibold text-heading">
{{ $t(translationKey('basic_settings')) }}
</h3>
<p
v-if="currentDriver === 'mail' || currentDriver === 'sendmail'"
class="mt-2 text-sm text-muted"
>
{{
currentDriver === 'mail'
? $t(translationKey('native_mail_desc'))
: $t(translationKey('sendmail_desc'))
}}
</p>
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<template v-if="currentDriver === 'smtp'">
<BaseInputGroup
:label="$t(translationKey('host'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_host')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_host"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_host?.$error"
type="text"
@input="v$.mail_host?.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('port'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_port')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_port"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_port?.$error"
type="text"
@input="v$.mail_port?.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('username'))"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="mailConfig.mail_username"
:content-loading="isFetchingInitialData"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('password'))"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="mailConfig.mail_password"
:content-loading="isFetchingInitialData"
:type="getSecretInputType('mail_password')"
autocomplete="off"
>
<template #right>
<BaseIcon
:name="visibleSecrets.mail_password ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="toggleSecret('mail_password')"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('encryption'))"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model="mailConfig.mail_encryption"
:content-loading="isFetchingInitialData"
:options="encryptionOptions"
label="label"
value-prop="value"
:can-clear="false"
:can-deselect="false"
/>
</BaseInputGroup>
</template>
<template v-if="currentDriver === 'ses'">
<BaseInputGroup
:label="$t(translationKey('ses_key'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_ses_key')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_ses_key"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_ses_key?.$error"
type="text"
@input="v$.mail_ses_key?.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('ses_secret'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_ses_secret')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_ses_secret"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_ses_secret?.$error"
:type="getSecretInputType('mail_ses_secret')"
autocomplete="off"
@input="v$.mail_ses_secret?.$touch()"
>
<template #right>
<BaseIcon
:name="visibleSecrets.mail_ses_secret ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="toggleSecret('mail_ses_secret')"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('ses_region'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_ses_region')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_ses_region"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_ses_region?.$error"
type="text"
@input="v$.mail_ses_region?.$touch()"
/>
</BaseInputGroup>
</template>
<template v-if="currentDriver === 'mailgun'">
<BaseInputGroup
:label="$t(translationKey('mailgun_domain'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_mailgun_domain')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_mailgun_domain"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_mailgun_domain?.$error"
type="text"
@input="v$.mail_mailgun_domain?.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('mailgun_secret'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_mailgun_secret')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_mailgun_secret"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_mailgun_secret?.$error"
:type="getSecretInputType('mail_mailgun_secret')"
autocomplete="off"
@input="v$.mail_mailgun_secret?.$touch()"
>
<template #right>
<BaseIcon
:name="visibleSecrets.mail_mailgun_secret ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="toggleSecret('mail_mailgun_secret')"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('mailgun_endpoint'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_mailgun_endpoint')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_mailgun_endpoint"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_mailgun_endpoint?.$error"
type="text"
@input="v$.mail_mailgun_endpoint?.$touch()"
/>
</BaseInputGroup>
</template>
<template v-if="currentDriver === 'postmark'">
<BaseInputGroup
:label="$t(translationKey('postmark_token'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_postmark_token')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_postmark_token"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_postmark_token?.$error"
:type="getSecretInputType('mail_postmark_token')"
autocomplete="off"
@input="v$.mail_postmark_token?.$touch()"
>
<template #right>
<BaseIcon
:name="visibleSecrets.mail_postmark_token ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="toggleSecret('mail_postmark_token')"
/>
</template>
</BaseInput>
</BaseInputGroup>
</template>
<BaseInputGroup
:label="$t(translationKey('from_mail'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('from_mail')"
required
>
<BaseInput
v-model.trim="mailConfig.from_mail"
:content-loading="isFetchingInitialData"
:invalid="v$.from_mail.$error"
type="text"
@input="v$.from_mail.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('from_name'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('from_name')"
required
>
<BaseInput
v-model.trim="mailConfig.from_name"
:content-loading="isFetchingInitialData"
:invalid="v$.from_name.$error"
type="text"
@input="v$.from_name.$touch()"
/>
</BaseInputGroup>
</div>
</div>
<div v-if="hasAdvancedFields" class="mt-8">
<button
type="button"
class="inline-flex items-center gap-2 text-sm font-medium text-primary-600"
@click="showAdvancedFields = !showAdvancedFields"
>
<BaseIcon
:name="showAdvancedFields ? 'ChevronUpIcon' : 'ChevronDownIcon'"
class="h-4 w-4"
/>
{{
showAdvancedFields
? $t(translationKey('hide_advanced_settings'))
: $t(translationKey('show_advanced_settings'))
}}
</button>
<div v-if="showAdvancedFields" class="mt-4 rounded-lg border border-line-default p-4">
<h3 class="text-sm font-semibold text-heading">
{{ $t(translationKey('advanced_settings')) }}
</h3>
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<template v-if="currentDriver === 'smtp'">
<BaseInputGroup
:label="$t(translationKey('scheme'))"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model="mailConfig.mail_scheme"
:content-loading="isFetchingInitialData"
:options="smtpSchemeOptions"
label="label"
value-prop="value"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('url'))"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="mailConfig.mail_url"
:content-loading="isFetchingInitialData"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('timeout'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_timeout')"
>
<BaseInput
v-model.trim="mailConfig.mail_timeout"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_timeout?.$error"
type="text"
@input="v$.mail_timeout?.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('local_domain'))"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="mailConfig.mail_local_domain"
:content-loading="isFetchingInitialData"
type="text"
/>
</BaseInputGroup>
</template>
<template v-if="currentDriver === 'sendmail'">
<BaseInputGroup
:label="$t(translationKey('sendmail_path'))"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="mailConfig.mail_sendmail_path"
:content-loading="isFetchingInitialData"
type="text"
/>
</BaseInputGroup>
</template>
<template v-if="currentDriver === 'mailgun'">
<BaseInputGroup
:label="$t(translationKey('mailgun_scheme'))"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model="mailConfig.mail_mailgun_scheme"
:content-loading="isFetchingInitialData"
:options="mailgunSchemeOptions"
label="label"
value-prop="value"
:can-clear="false"
:can-deselect="false"
/>
</BaseInputGroup>
</template>
<template v-if="currentDriver === 'postmark'">
<BaseInputGroup
:label="$t(translationKey('postmark_message_stream_id'))"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="mailConfig.mail_postmark_message_stream_id"
:content-loading="isFetchingInitialData"
type="text"
/>
</BaseInputGroup>
</template>
</div>
</div>
</div>
<div class="mt-8 flex">
<BaseButton
:disabled="isSaving"
:content-loading="isFetchingInitialData"
:loading="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon v-if="!isSaving" :name="submitIcon" :class="slotProps.class" />
</template>
{{ $t(submitLabel) }}
</BaseButton>
<slot />
</div>
</form>
</template>