email settings

This commit is contained in:
Alex Holliday committed 2025-11-19 23:55:29 +00:00
1 parent 819c0ca05a
commit 3faff5dc54
16 files changed
+665 -33

No files matched your search

@@ -1,4 +1,3 @@
import { Fragment } from "react";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
@@ -46,21 +45,25 @@ export const SplitBox = ({
export const ConfigBox = ({
title,
subtitle,
leftContent,
rightContent,
}: {
title: string;
subtitle: string;
leftContent?: React.ReactNode;
rightContent: React.ReactNode;
}) => {
const theme = useTheme();
return (
<SplitBox
left={
<Fragment>
<Stack spacing={theme.spacing(4)}>
<Typography textTransform={"capitalize"} component="h2" variant="h2">
{title}
</Typography>
<Typography component="p">{subtitle}</Typography>
</Fragment>
{leftContent}
</Stack>
}
right={rightContent}
/>
+37
View File
@@ -0,0 +1,37 @@
import { forwardRef } from "react";
import Switch from "@mui/material/Switch";
import type { SwitchProps } from "@mui/material/Switch";
import { useTheme } from "@mui/material/styles";
export const SwitchComponent = forwardRef<HTMLInputElement, SwitchProps>(
function SwitchComponent({ sx, ...props }, ref) {
const theme = useTheme();
const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : [];
return (
<Switch
{...props}
slotProps={{
input: {
ref: ref,
},
}}
sx={[
{
"& .MuiSwitch-switchBase": {
"&.Mui-checked": {
color: "#E0E0E0",
"& + .MuiSwitch-track": {
backgroundColor: theme.palette.accent.main,
opacity: 1,
border: 0,
},
},
},
},
...additionalSx,
]}
/>
);
}
);
+1
View File
@@ -16,3 +16,4 @@ export { DateTimePicker } from "./DateTimePicker";
export { FormControlLabel } from "./FormControlLabel";
export { FieldLabel } from "./FieldLabel";
export { FormField } from "./FormField";
export { SwitchComponent as Switch } from "./Switch";
+9 -4
View File
@@ -70,7 +70,7 @@ export const usePost = <B = any, R = any>() => {
const currentTeamId = useAppSelector((state) => state.auth.selectedTeamId);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { toastError } = useToast();
const { toastError, toastSuccess } = useToast();
const postFn = async (
endpoint: string,
@@ -88,10 +88,13 @@ export const usePost = <B = any, R = any>() => {
"x-team-id": currentTeamId,
},
});
toastSuccess(res.data?.message || "Operation successful");
return res.data;
} catch (err: any) {
const errMsg =
err?.response?.data?.msg || err.message || "An error occurred";
err?.response?.data?.message || err.message || "An error occurred";
console.error(err);
toastError(errMsg);
setError(errMsg);
return null;
@@ -128,8 +131,9 @@ export const usePatch = <B = any, R = any>() => {
toastSuccess(res.data?.message || "Operation successful");
return res.data;
} catch (err: any) {
console.error(err);
const errMsg =
err?.response?.data?.msg || err.message || "An error occurred";
err?.response?.data?.message || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
return null;
@@ -165,8 +169,9 @@ export const useDelete = <R = any,>() => {
toastSuccess(res.data?.message || "Operation successful");
return res.data;
} catch (err: any) {
console.error(err);
const errMsg =
err?.response?.data?.msg || err.message || "An error occurred";
err?.response?.data?.message || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
return null;
@@ -0,0 +1,29 @@
import { systemSettingsSchema } from "@/validation/zod";
import { useMemo } from "react";
import { z } from "zod";
export const useInitForm = ({
initialData,
}: {
initialData: Partial<z.infer<typeof systemSettingsSchema>> | undefined;
}) => {
return useMemo(() => {
const defaults: z.infer<typeof systemSettingsSchema> = {
systemEmailHost: initialData?.systemEmailHost || "",
systemEmailPort: initialData?.systemEmailPort ?? undefined,
systemEmailAddress: initialData?.systemEmailAddress || "",
systemEmailPassword: initialData?.systemEmailPassword || "",
systemEmailUser: initialData?.systemEmailUser || "",
systemEmailConnectionHost:
initialData?.systemEmailConnectionHost || "localhost",
systemEmailTLSServername: initialData?.systemEmailTLSServername || "",
systemEmailSecure: initialData?.systemEmailSecure ?? false,
systemEmailPool: initialData?.systemEmailPool ?? false,
systemEmailIgnoreTLS: initialData?.systemEmailIgnoreTLS ?? false,
systemEmailRequireTLS: initialData?.systemEmailRequireTLS ?? false,
systemEmailRejectUnauthorized:
initialData?.systemEmailRejectUnauthorized ?? true,
};
return { defaults };
}, [initialData]);
};
+31 -2
View File
@@ -3,16 +3,23 @@ import { BasePage, ConfigBox } from "@/components/design-elements";
import { AutoComplete, Select, LanguageSelector } from "@/components/inputs";
import MenuItem from "@mui/material/MenuItem";
import Typography from "@mui/material/Typography";
import { SettingsForm } from "@/pages/settings/SettingsForm";
import { useTheme } from "@mui/material/styles";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { setTimezone, setMode } from "@/features/uiSlice";
import { useAppSelector, useAppDispatch } from "@/hooks/AppHooks";
import timezones from "@/utils/timezones.json";
import type { ITimeZone } from "@/types/timezone";
import type { SelectChangeEvent } from "@mui/material/Select";
import { useGet, usePatch, usePost } from "@/hooks/UseApi";
import type { ApiResponse } from "@/hooks/UseApi";
import { z } from "zod";
import { systemSettingsSchema } from "@/validation/zod";
const SettingsPage = () => {
type FormValues = z.infer<typeof systemSettingsSchema>;
const theme = useTheme();
const dispatch = useAppDispatch();
const timezone = useAppSelector((state) => state.ui.timezone);
@@ -24,13 +31,29 @@ const SettingsPage = () => {
const tz = timezones.find((tz) => tz._id === timezone);
setSelectedTimezone(tz || null);
}, [timezone]);
const { post, loading: posting } = usePost<Partial<FormValues>, any>();
const { patch, loading: patching } = usePatch<Partial<FormValues>, any>();
const { response, loading } = useGet<ApiResponse<any>>("/settings");
const settings = response?.data || {};
const initialSettings = useMemo(() => settings, [settings]);
const onSubmit = async (data: Partial<FormValues>) => {
const res = await patch("/settings", data);
console.log(res?.data);
};
const onTest = async (data: Partial<FormValues>) => {
const res = await post("/settings/test-transport", data);
};
return (
<BasePage>
<ConfigBox
title="UI Settings"
subtitle="Configure time zone, UI mode, and language preferences"
rightContent={
<Stack spacing={theme.spacing(8)}>
<Stack spacing={theme.spacing(10)}>
<Stack spacing={theme.spacing(2)}>
<Typography>Time zone</Typography>
@@ -62,6 +85,12 @@ const SettingsPage = () => {
</Stack>
}
/>
<SettingsForm
onSubmit={onSubmit}
onTest={onTest}
initialData={initialSettings}
loading={posting || loading || patching}
/>
</BasePage>
);
};
+319
View File
@@ -0,0 +1,319 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { Button, TextInput, Switch, TextLink } from "@/components/inputs";
import { ConfigBox } from "@/components/design-elements";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { systemSettingsSchema } from "@/validation/zod";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm, Controller, type SubmitHandler } from "react-hook-form";
import { useInitForm } from "@/hooks/forms/UseInitSettingsForm";
import { useEffect } from "react";
import { Typography } from "@mui/material";
type FormValues = z.infer<typeof systemSettingsSchema>;
export const SettingsForm = ({
initialData,
onSubmit,
onTest,
loading,
}: {
initialData?: Partial<FormValues>;
onSubmit: SubmitHandler<FormValues>;
onTest: (data: Partial<FormValues>) => Promise<void>;
loading?: boolean;
}) => {
const { defaults } = useInitForm({ initialData: initialData });
const { t } = useTranslation();
const theme = useTheme();
const {
handleSubmit,
control,
reset,
watch,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(systemSettingsSchema) as any,
defaultValues: defaults,
mode: "onChange",
});
const formValues = watch();
useEffect(() => {
reset(defaults);
}, [initialData, reset, defaults]);
return (
<Stack
spacing={theme.spacing(10)}
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("settingsPage.emailSettings.title")}
subtitle={t("settingsPage.emailSettings.description")}
leftContent={
<Stack>
<TextLink
text={t("settingsPage.emailSettings.descriptionTransport")}
linkText={t("settingsPage.emailSettings.linkTransport")}
href="https://nodemailer.com/smtp"
target="_blank"
/>
<Box
component={"pre"}
sx={{
fontFamily: "monospace",
p: 2,
borderRadius: 1,
overflow: "auto",
}}
>
<code>
{JSON.stringify(
{
host: formValues.systemEmailHost,
port: formValues.systemEmailPort,
secure: formValues.systemEmailSecure,
auth: {
user:
formValues.systemEmailUser ||
formValues.systemEmailAddress,
pass: "<your_password>",
},
name: formValues.systemEmailConnectionHost || "localhost",
pool: formValues.systemEmailPool,
tls: {
rejectUnauthorized:
formValues.systemEmailRejectUnauthorized,
ignoreTLS: formValues.systemEmailIgnoreTLS,
requireTLS: formValues.systemEmailRequireTLS,
servername: formValues.systemEmailTLSServername,
},
},
null,
2
)}
</code>
</Box>
</Stack>
}
rightContent={
<Stack spacing={theme.spacing(10)}>
<Controller
name="systemEmailHost"
control={control}
render={({ field }) => (
<TextInput
{...field}
type="text"
fieldLabel={t("settingsPage.emailSettings.labelHost")}
fullWidth
error={!!errors.systemEmailHost}
helperText={
errors.systemEmailHost ? errors.systemEmailHost.message : ""
}
/>
)}
/>
<Controller
name="systemEmailPort"
control={control}
render={({ field }) => (
<TextInput
{...field}
type="number"
fieldLabel={t("settingsPage.emailSettings.labelPort")}
fullWidth
error={!!errors.systemEmailPort}
helperText={
errors.systemEmailPort ? errors.systemEmailPort.message : ""
}
/>
)}
/>
<Controller
name="systemEmailUser"
control={control}
render={({ field }) => (
<TextInput
{...field}
type="text"
fieldLabel={t("settingsPage.emailSettings.labelUser")}
fullWidth
error={!!errors.systemEmailUser}
helperText={
errors.systemEmailUser ? errors.systemEmailUser.message : ""
}
/>
)}
/>
<Controller
name="systemEmailAddress"
control={control}
render={({ field }) => (
<TextInput
{...field}
type="text"
fieldLabel={t("settingsPage.emailSettings.labelAddress")}
fullWidth
error={!!errors.systemEmailAddress}
helperText={
errors.systemEmailAddress
? errors.systemEmailAddress.message
: ""
}
/>
)}
/>
<Controller
name="systemEmailPassword"
control={control}
render={({ field }) => (
<TextInput
{...field}
type="password"
fieldLabel={t("settingsPage.emailSettings.labelPassword")}
fullWidth
error={!!errors.systemEmailPassword}
helperText={
errors.systemEmailPassword
? errors.systemEmailPassword.message
: ""
}
/>
)}
/>
<Controller
name="systemEmailTLSServername"
control={control}
render={({ field }) => (
<TextInput
{...field}
type="text"
fieldLabel={t(
"settingsPage.emailSettings.labelTLSServername"
)}
fullWidth
error={!!errors.systemEmailTLSServername}
helperText={
errors.systemEmailTLSServername
? errors.systemEmailTLSServername.message
: ""
}
/>
)}
/>
<Controller
name="systemEmailConnectionHost"
control={control}
render={({ field }) => (
<TextInput
{...field}
type="text"
fieldLabel={t(
"settingsPage.emailSettings.labelConnectionHost"
)}
fullWidth
error={!!errors.systemEmailConnectionHost}
helperText={
errors.systemEmailConnectionHost
? errors.systemEmailConnectionHost.message
: ""
}
/>
)}
/>
<Controller
name="systemEmailSecure"
control={control}
render={({ field }) => (
<Stack direction="row" alignItems={"center"}>
<Switch {...field} checked={field.value} />
<Typography>
{t("settingsPage.emailSettings.labelSecure")}
</Typography>
</Stack>
)}
/>
<Controller
name="systemEmailPool"
control={control}
render={({ field }) => (
<Stack direction="row" alignItems={"center"}>
<Switch {...field} checked={field.value} />
<Typography>
{t("settingsPage.emailSettings.labelPool")}
</Typography>
</Stack>
)}
/>
<Controller
name="systemEmailIgnoreTLS"
control={control}
render={({ field }) => (
<Stack direction="row" alignItems={"center"}>
<Switch {...field} checked={field.value} />
<Typography>
{t("settingsPage.emailSettings.labelIgnoreTLS")}
</Typography>
</Stack>
)}
/>
<Controller
name="systemEmailRequireTLS"
control={control}
render={({ field }) => (
<Stack direction="row" alignItems={"center"}>
<Switch {...field} checked={field.value} />
<Typography>
{t("settingsPage.emailSettings.labelRequireTLS")}
</Typography>
</Stack>
)}
/>
<Controller
name="systemEmailRejectUnauthorized"
control={control}
render={({ field }) => (
<Stack direction="row" alignItems={"center"}>
<Switch {...field} checked={field.value} />
<Typography>
{t("settingsPage.emailSettings.labelRejectUnauthorized")}
</Typography>
</Stack>
)}
/>
</Stack>
}
/>
<Stack gap={theme.spacing(4)} direction="row" justifyContent="flex-end">
<Button
loading={loading}
variant="contained"
color="accent"
onClick={() => {
onTest(formValues);
}}
>
Test transport config
</Button>
<Button
loading={loading}
type="submit"
variant="contained"
color="accent"
>
{t("settingsSave")}
</Button>
</Stack>
</Stack>
);
};
+14
View File
@@ -0,0 +1,14 @@
export interface ISystemSettings extends Document {
systemEmailHost?: string;
systemEmailPort?: number;
systemEmailAddress?: string;
systemEmailPassword?: string;
systemEmailUser?: string;
systemEmailConnectionHost?: string;
systemEmailTLSServername?: string;
systemEmailSecure: boolean;
systemEmailPool: boolean;
systemEmailIgnoreTLS: boolean;
systemEmailRequireTLS: boolean;
systemEmailRejectUnauthorized: boolean;
}
+70
View File
@@ -81,6 +81,12 @@ const durationSchemaInfra = z
}
});
const optionalString = <T extends z.ZodTypeAny>(schema: T) =>
z.preprocess(
(val) => (val === "" || val === null ? undefined : val),
schema.optional()
);
export const monitorSchema = z.object({
type: z.string().min(1, "You must select an option"),
url: z.string().min(1, "URL is required").regex(urlRegex, "Invalid URL"),
@@ -203,6 +209,70 @@ export const maintenanceSchema = z
}
});
export const systemSettingsSchema = z.object({
systemEmailHost: optionalString(
z
.string()
.trim()
.min(1, { message: "SMTP host is required" })
.max(255, {
message: "SMTP host must be 255 characters or fewer",
})
),
systemEmailPort: optionalString(
z
.union([z.number().int(), z.string().regex(/^\d+$/)])
.transform((val) => (typeof val === "string" ? Number(val) : val))
.refine(
(val) =>
val === undefined ||
(Number.isInteger(val) && val > 0 && val < 65536),
{
message: "Port must be a number between 1 and 65535",
}
)
),
systemEmailAddress: optionalString(
z.string().email({ message: "SMTP email must be valid" })
),
systemEmailPassword: optionalString(
z
.string()
.max(255, {
message: "SMTP password must be 255 characters or fewer",
})
),
systemEmailUser: optionalString(
z
.string()
.trim()
.max(255, {
message: "SMTP username must be 255 characters or fewer",
})
),
systemEmailConnectionHost: optionalString(
z
.string()
.trim()
.max(255, {
message: "Connection host must be 255 characters or fewer",
})
),
systemEmailTLSServername: optionalString(
z
.string()
.trim()
.max(255, {
message: "TLS server name must be 255 characters or fewer",
})
),
systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(),
systemEmailIgnoreTLS: z.boolean().optional(),
systemEmailRequireTLS: z.boolean().optional(),
systemEmailRejectUnauthorized: z.boolean().optional(),
});
const statusPageUrlRegex = /^[A-Za-z0-9]+$/;
export const statusPageSchema = z.object({
+23 -2
View File
@@ -1,16 +1,22 @@
import { Request, Response, NextFunction } from "express";
import ApiError from "@/utils/ApiError.js";
import SettingsService from "@/services/system/SettingsService.js";
import { NotificationService, SettingsService } from "@/services/index.js";
export interface ISettingsController {
get: (req: Request, res: Response, next: NextFunction) => void;
update: (req: Request, res: Response, next: NextFunction) => void;
testTransport: (req: Request, res: Response, next: NextFunction) => void;
}
class SettingsController implements ISettingsController {
private settingsService: SettingsService;
constructor(settingsService: SettingsService) {
private notificationService: NotificationService;
constructor(
settingsService: SettingsService,
notificationService: NotificationService
) {
this.settingsService = settingsService;
this.notificationService = notificationService;
}
get = async (req: Request, res: Response, next: NextFunction) => {
@@ -36,6 +42,21 @@ class SettingsController implements ISettingsController {
next(error);
}
};
testTransport = async (req: Request, res: Response, next: NextFunction) => {
try {
const testResult = await this.notificationService.testEmailTransport(
req.body
);
res.status(200).json({
message: "Test transport successful",
data: testResult,
});
} catch (error) {
next(error);
}
};
}
export default SettingsController;
+2 -1
View File
@@ -67,7 +67,8 @@ export const initControllers = (services: any) => {
);
controllers.settingsController = new SettingsController(
services.settingsService
services.settingsService,
services.notificationService
);
return controllers;
+8
View File
@@ -34,6 +34,14 @@ class SettingsRoutes {
validateBody(systemSettingsSchema),
this.controller.update
);
this.router.post(
"/test-transport",
verifyToken,
addUserContext,
verifyTeamPermission([PERMISSIONS.master]),
validateBody(systemSettingsSchema),
this.controller.testTransport
);
};
getRouter() {
@@ -14,6 +14,7 @@ import {
} from "./NotificationServices/index.js";
import ApiError from "@/utils/ApiError.js";
import { getChildLogger } from "@/logger/Logger.js";
import type { IEmailTransport } from "./NotificationServices/Email.js";
const SERVICE_NAME = "NotificationService";
const logger = getChildLogger(SERVICE_NAME);
@@ -36,6 +37,7 @@ export interface INotificationService {
testNotificationChannel: (
notificationChannel: INotificationChannel
) => Promise<Boolean>;
testTransport: (transport: IEmailTransport) => Promise<boolean>;
}
class NotificationService implements INotificationService {
@@ -181,6 +183,10 @@ class NotificationService implements INotificationService {
}
return false;
};
testEmailTransport = async (transport: IEmailTransport) => {
return this.emailService.testTransport(transport);
};
}
export default NotificationService;
@@ -8,8 +8,24 @@ import nodemailer, { Transporter } from "nodemailer";
import { config } from "@/config/index.js";
import UserService from "../../business/UserService.js";
import ApiError from "@/utils/ApiError.js";
import { getChildLogger } from "@/logger/Logger.js";
const SERVICE_NAME = "EmailService";
const logger = getChildLogger(SERVICE_NAME);
export interface IEmailTransport {
systemEmailHost?: string;
systemEmailPort?: number;
systemEmailAddress?: string;
systemEmailPassword?: string;
systemEmailUser?: string;
systemEmailConnectionHost?: string;
systemEmailTLSServername?: string;
systemEmailSecure: boolean;
systemEmailPool: boolean;
systemEmailIgnoreTLS: boolean;
systemEmailRequireTLS: boolean;
systemEmailRejectUnauthorized: boolean;
}
export interface IEmailService extends IMessageService {
sendGeneric: (
@@ -17,6 +33,7 @@ export interface IEmailService extends IMessageService {
subject: string,
content: string
) => Promise<boolean>;
testTransport: (transport: IEmailTransport) => Promise<boolean>;
}
class EmailService implements IEmailService {
@@ -108,6 +125,51 @@ class EmailService implements IEmailService {
throw error;
}
};
testTransport = async (transport: IEmailTransport) => {
const host = (transport.systemEmailHost ?? "").trim();
const baseOptions = {
port: Number(transport.systemEmailPort),
secure: transport.systemEmailSecure,
auth: {
user: transport.systemEmailUser || transport.systemEmailAddress,
pass: transport.systemEmailPassword,
},
name: transport.systemEmailConnectionHost || undefined,
connectionTimeout: 5000,
tls: {
rejectUnauthorized: transport.systemEmailRejectUnauthorized,
ignoreTLS: transport.systemEmailIgnoreTLS,
requireTLS: transport.systemEmailRequireTLS,
servername: transport.systemEmailTLSServername,
},
};
const transportOptions = transport.systemEmailPool
? {
...baseOptions,
pool: true,
host,
}
: {
...baseOptions,
host: host || undefined,
};
const testTransport = nodemailer.createTransport(transportOptions);
try {
await testTransport.sendMail({
from: `"Checkmate Test" <${transport.systemEmailAddress}>`,
to: transport.systemEmailUser || transport.systemEmailAddress,
subject: "SMTP transport test",
text: "This is a transport verification message.",
});
return true;
} catch (error: any) {
logger.error(error);
throw new ApiError(`Email transport test failed`, 400);
}
};
}
export default EmailService;
@@ -15,12 +15,18 @@ class SettingsService implements ISettingsService {
}
get = async (): Promise<ISystemSettings> => {
let settings = await SystemSettings.findById("global");
let settings = await SystemSettings.findById("global").select(
"-_id -__v -createdAt -updatedAt"
);
if (!settings) {
settings = await SystemSettings.create({ _id: "global" });
await SystemSettings.create({ _id: "global" });
}
settings = await SystemSettings.findById("global").select(
"-_id -__v -createdAt -updatedAt"
);
if (!settings) {
throw new ApiError("Unable to load system settings", 500);
}
+39 -18
View File
@@ -537,24 +537,45 @@ export const patchIncidentsBodySchema = z.object({
export const systemSettingsSchema = z
.object({
systemEmailHost: z.string().trim().min(1).max(255).optional(),
systemEmailPort: z
.union([z.number().int(), z.string().regex(/^\d+$/)])
.optional()
.transform((val) => (typeof val === "string" ? Number(val) : val))
.refine(
(val) =>
val === undefined ||
(Number.isInteger(val) && val > 0 && val < 65536),
{
message: "Port must be between 1 and 65535",
}
),
systemEmailAddress: z.email().optional(),
systemEmailPassword: z.string().optional(),
systemEmailUser: z.string().trim().max(255).optional(),
systemEmailConnectionHost: z.string().trim().max(255).optional(),
systemEmailTLSServername: z.string().trim().max(255).optional(),
systemEmailHost: z.preprocess(
(val) => (val === "" || val === null ? undefined : val),
z.string().trim().min(1).max(255).optional()
),
systemEmailPort: z.preprocess(
(val) => (val === "" || val === null ? undefined : val),
z
.union([z.number().int(), z.string().regex(/^\d+$/)])
.transform((port) => (typeof port === "string" ? Number(port) : port))
.refine(
(port) =>
port === undefined ||
(Number.isInteger(port) && port > 0 && port < 65536),
{
message: "Port must be between 1 and 65535",
}
)
.optional()
),
systemEmailAddress: z.preprocess(
(val) => (val === "" || val === null ? undefined : val),
z.string().email().optional()
),
systemEmailPassword: z.preprocess(
(val) => (val === "" || val === null ? undefined : val),
z.string().trim().max(255).optional()
),
systemEmailUser: z.preprocess(
(val) => (val === "" || val === null ? undefined : val),
z.string().trim().max(255).optional()
),
systemEmailConnectionHost: z.preprocess(
(val) => (val === "" || val === null ? undefined : val),
z.string().trim().max(255).optional()
),
systemEmailTLSServername: z.preprocess(
(val) => (val === "" || val === null ? undefined : val),
z.string().trim().max(255).optional()
),
systemEmailSecure: z
.union([z.boolean(), z.string()])
.optional()