feat: enhance SMTP configuration options in settings

- Added new fields for smtpSecure and smtpNoAuth in the email settings, allowing users to specify the connection security method and disable authentication for internal servers.
- Updated the EmailService to handle the new configuration options, improving flexibility in SMTP setup.
- Enhanced the UI components to include the new fields, ensuring proper user interaction and validation.
- Updated translation files to include descriptions and titles for the new SMTP settings, improving localization support.
This commit is contained in:
Daniel Luiz Alves
2025-06-24 11:15:44 -03:00
parent 1ab0504288
commit ab071916b8
11 changed files with 288 additions and 57 deletions

View File

@@ -117,6 +117,18 @@ const defaultConfigs = [
type: "string",
group: "email",
},
{
key: "smtpSecure",
value: "auto",
type: "string",
group: "email",
},
{
key: "smtpNoAuth",
value: "false",
type: "boolean",
group: "email",
},
{
key: "passwordResetTokenExpiration",
value: "3600",

View File

@@ -148,6 +148,11 @@ export async function appRoutes(app: FastifyInstance) {
.describe("SMTP server port (typically 587 for TLS, 25 for non-secure)"),
smtpUser: z.string().describe("Username for SMTP authentication (e.g., email address)"),
smtpPass: z.string().describe("Password for SMTP authentication (for Gmail, use App Password)"),
smtpSecure: z
.string()
.optional()
.describe("Connection security method ('auto', 'ssl', 'tls', or 'none')"),
smtpNoAuth: z.string().optional().describe("Disable SMTP authentication ('true' or 'false')"),
})
.optional()
.describe("SMTP configuration to test. If not provided, uses currently saved configuration"),

View File

@@ -7,6 +7,8 @@ interface SmtpConfig {
smtpPort: string;
smtpUser: string;
smtpPass: string;
smtpSecure?: string;
smtpNoAuth?: string;
}
export class EmailService {
@@ -18,15 +20,46 @@ export class EmailService {
return null;
}
return nodemailer.createTransport({
const port = Number(await this.configService.getValue("smtpPort"));
const smtpSecure = (await this.configService.getValue("smtpSecure")) || "auto";
const smtpNoAuth = await this.configService.getValue("smtpNoAuth");
let secure = false;
let requireTLS = false;
if (smtpSecure === "ssl") {
secure = true;
} else if (smtpSecure === "tls") {
requireTLS = true;
} else if (smtpSecure === "none") {
secure = false;
requireTLS = false;
} else if (smtpSecure === "auto") {
if (port === 465) {
secure = true;
} else if (port === 587 || port === 25) {
requireTLS = true;
}
}
const transportConfig: any = {
host: await this.configService.getValue("smtpHost"),
port: Number(await this.configService.getValue("smtpPort")),
secure: false,
auth: {
port: port,
secure: secure,
requireTLS: requireTLS,
tls: {
rejectUnauthorized: false,
},
};
if (smtpNoAuth !== "true") {
transportConfig.auth = {
user: await this.configService.getValue("smtpUser"),
pass: await this.configService.getValue("smtpPass"),
},
});
};
}
return nodemailer.createTransport(transportConfig);
}
async testConnection(config?: SmtpConfig) {
@@ -43,6 +76,8 @@ export class EmailService {
smtpPort: await this.configService.getValue("smtpPort"),
smtpUser: await this.configService.getValue("smtpUser"),
smtpPass: await this.configService.getValue("smtpPass"),
smtpSecure: (await this.configService.getValue("smtpSecure")) || "auto",
smtpNoAuth: await this.configService.getValue("smtpNoAuth"),
};
}
@@ -50,15 +85,46 @@ export class EmailService {
throw new Error("SMTP is not enabled");
}
const transporter = nodemailer.createTransport({
const port = Number(smtpConfig.smtpPort);
const smtpSecure = smtpConfig.smtpSecure || "auto";
const smtpNoAuth = smtpConfig.smtpNoAuth;
let secure = false;
let requireTLS = false;
if (smtpSecure === "ssl") {
secure = true;
} else if (smtpSecure === "tls") {
requireTLS = true;
} else if (smtpSecure === "none") {
secure = false;
requireTLS = false;
} else if (smtpSecure === "auto") {
if (port === 465) {
secure = true;
} else if (port === 587 || port === 25) {
requireTLS = true;
}
}
const transportConfig: any = {
host: smtpConfig.smtpHost,
port: Number(smtpConfig.smtpPort),
secure: false,
auth: {
port: port,
secure: secure,
requireTLS: requireTLS,
tls: {
rejectUnauthorized: false,
},
};
if (smtpNoAuth !== "true") {
transportConfig.auth = {
user: smtpConfig.smtpUser,
pass: smtpConfig.smtpPass,
},
});
};
}
const transporter = nodemailer.createTransport(transportConfig);
try {
await transporter.verify();

View File

@@ -465,6 +465,20 @@
"title": "Sender Email",
"description": "Sender email address"
},
"smtpSecure": {
"title": "Connection Security",
"description": "SMTP connection security method - Auto (recommended), SSL, STARTTLS, or None (insecure)",
"options": {
"auto": "Auto (Recommended)",
"ssl": "SSL (Port 465)",
"tls": "STARTTLS (Port 587)",
"none": "None (Insecure)"
}
},
"smtpNoAuth": {
"title": "No Authentication",
"description": "Enable this for internal servers that don't require username/password (hides auth fields)"
},
"testSmtp": {
"title": "Test SMTP Connection",
"description": "Test if the SMTP configuration is valid"
@@ -548,7 +562,10 @@
"updateSuccess": "{group} settings updated successfully",
"smtpTestSuccess": "SMTP connection successful! Your email configuration is working correctly.",
"smtpTestFailed": "SMTP connection failed: {error}",
"smtpTestGenericError": "Failed to test SMTP connection. Please check your settings and try again."
"smtpTestGenericError": "Failed to test SMTP connection. Please check your settings and try again.",
"smtpNotEnabled": "SMTP is not enabled. Please enable SMTP first.",
"smtpMissingHostPort": "Please fill in SMTP Host and Port before testing.",
"smtpMissingAuth": "Please fill in SMTP Username and Password, or enable 'No Authentication' option."
},
"title": "Settings",
"breadcrumb": "Settings",
@@ -556,7 +573,8 @@
"tooltips": {
"oidcScope": "Enter a scope and press Enter to add",
"oidcAdminEmailDomains": "Enter a domain and press Enter to add",
"testSmtp": "Tests the SMTP connection with the values currently entered in the form. To make changes permanent, remember to save your settings after testing."
"testSmtp": "Tests the SMTP connection with the values currently entered in the form. To make changes permanent, remember to save your settings after testing.",
"defaultPlaceholder": "Enter and press Enter"
},
"redirectUri": {
"placeholder": "https://mysite.com",

View File

@@ -51,42 +51,71 @@ export function SettingsGroup({ group, configs, form, isCollapsed, onToggleColla
<div className="flex flex-col gap-4">
{configs
.filter((config) => !isFieldHidden(config.key))
.map((config) => (
<div key={config.key} className="space-y-2 mb-3">
<SettingsInput
config={config}
error={form.formState.errors.configs?.[config.key]}
register={form.register}
setValue={form.setValue}
smtpEnabled={form.watch("configs.smtpEnabled")}
oidcEnabled={form.watch("configs.oidcEnabled")}
watch={form.watch}
/>
<p className="text-xs text-muted-foreground ml-1">
{t(`settings.fields.${config.key}.description`, {
defaultValue:
FIELD_DESCRIPTIONS[config.key as keyof typeof FIELD_DESCRIPTIONS] ||
config.description ||
t("settings.fields.noDescription"),
})}
</p>
</div>
))}
.map((config) => {
const smtpEnabled = form.watch("configs.smtpEnabled");
const smtpNoAuth = form.watch("configs.smtpNoAuth");
const isSmtpAuthField = config.key === "smtpUser" || config.key === "smtpPass";
const smtpFields = [
"smtpHost",
"smtpPort",
"smtpUser",
"smtpPass",
"smtpSecure",
"smtpNoAuth",
"smtpFromName",
"smtpFromEmail",
];
if (smtpEnabled !== "true" && smtpFields.includes(config.key)) {
return null;
}
if (isSmtpAuthField && smtpNoAuth === "true") {
return null;
}
return (
<div key={config.key} className="space-y-2 mb-3">
<SettingsInput
config={config}
error={form.formState.errors.configs?.[config.key]}
register={form.register}
setValue={form.setValue}
smtpEnabled={form.watch("configs.smtpEnabled")}
oidcEnabled={form.watch("configs.oidcEnabled")}
watch={form.watch}
/>
<p className="text-xs text-muted-foreground ml-1">
{t(`settings.fields.${config.key}.description`, {
defaultValue:
FIELD_DESCRIPTIONS[config.key as keyof typeof FIELD_DESCRIPTIONS] ||
config.description ||
t("settings.fields.noDescription"),
})}
</p>
</div>
);
})}
</div>
<div className="flex justify-between items-center mt-4">
{isEmailGroup && (
<SmtpTestButton
smtpEnabled={form.watch("configs.smtpEnabled") || "false"}
getFormValues={() => ({
smtpEnabled: form.getValues("configs.smtpEnabled") || "false",
smtpHost: form.getValues("configs.smtpHost") || "",
smtpPort: form.getValues("configs.smtpPort") || "",
smtpUser: form.getValues("configs.smtpUser") || "",
smtpPass: form.getValues("configs.smtpPass") || "",
})}
/>
)}
<div className={`flex ${isEmailGroup ? "ml-auto" : ""}`}>
<div className="flex">
{isEmailGroup && form.watch("configs.smtpEnabled") === "true" && (
<SmtpTestButton
smtpEnabled={form.watch("configs.smtpEnabled") || "false"}
getFormValues={() => ({
smtpEnabled: form.getValues("configs.smtpEnabled") || "false",
smtpHost: form.getValues("configs.smtpHost") || "",
smtpPort: form.getValues("configs.smtpPort") || "",
smtpUser: form.getValues("configs.smtpUser") || "",
smtpPass: form.getValues("configs.smtpPass") || "",
smtpSecure: form.getValues("configs.smtpSecure") || "auto",
smtpNoAuth: form.getValues("configs.smtpNoAuth") || "false",
})}
/>
)}
</div>
<div className="flex">
<Button
variant="default"
disabled={form.formState.isSubmitting}

View File

@@ -2,8 +2,10 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { UseFormRegister, UseFormSetValue, UseFormWatch } from "react-hook-form";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { Switch } from "@/components/ui/switch";
import { TagsInput } from "@/components/ui/tags-input";
import { createFieldTitles } from "../constants";
import { Config } from "../types";
@@ -129,7 +131,7 @@ export function SettingsInput({
? "openid profile email"
: config.key === "oidcAdminEmailDomains"
? "admin.com company.org"
: "Digite e pressione Enter"
: t("settings.tooltips.defaultPlaceholder", { defaultValue: "Enter and press Enter" })
}
/>
{error && <p className="text-danger text-xs mt-1">{error.message}</p>}
@@ -137,6 +139,73 @@ export function SettingsInput({
);
}
if (config.key === "smtpEnabled") {
const currentValue = watch(`configs.${config.key}`) === "true";
return (
<div className="space-y-2">
<div className="flex items-center space-x-3">
<Switch
id={`configs.${config.key}`}
checked={currentValue}
onCheckedChange={(checked) => {
setValue(`configs.${config.key}`, checked ? "true" : "false", { shouldDirty: true });
}}
disabled={isDisabled}
/>
<label htmlFor={`configs.${config.key}`} className="text-sm font-semibold cursor-pointer">
{friendlyLabel}
</label>
</div>
{config.description && <p className="text-xs text-muted-foreground ml-11">{config.description}</p>}
{error && <p className="text-danger text-xs mt-1 ml-11">{error.message}</p>}
</div>
);
}
if (config.key === "smtpSecure") {
return (
<div className="space-y-2">
<label className="block text-sm font-semibold">{friendlyLabel}</label>
<select
{...register(`configs.${config.key}`)}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm"
disabled={isDisabled}
>
<option value="auto">{t("settings.fields.smtpSecure.options.auto")}</option>
<option value="ssl">{t("settings.fields.smtpSecure.options.ssl")}</option>
<option value="tls">{t("settings.fields.smtpSecure.options.tls")}</option>
<option value="none">{t("settings.fields.smtpSecure.options.none")}</option>
</select>
{error && <p className="text-danger text-xs mt-1">{error.message}</p>}
</div>
);
}
if (config.key === "smtpNoAuth") {
const currentValue = watch(`configs.${config.key}`) === "true";
return (
<div className="space-y-2">
<div className="flex items-center space-x-3">
<Checkbox
id={`configs.${config.key}`}
checked={currentValue}
onCheckedChange={(checked) => {
setValue(`configs.${config.key}`, checked ? "true" : "false", { shouldDirty: true });
}}
disabled={isDisabled}
/>
<label htmlFor={`configs.${config.key}`} className="text-sm font-semibold cursor-pointer">
{friendlyLabel}
</label>
</div>
{config.description && <p className="text-xs text-muted-foreground ml-7">{config.description}</p>}
{error && <p className="text-danger text-xs mt-1 ml-7">{error.message}</p>}
</div>
);
}
switch (config.type) {
case "boolean":
return (

View File

@@ -16,6 +16,8 @@ interface SmtpTestButtonProps {
smtpPort: string;
smtpUser: string;
smtpPass: string;
smtpSecure: string;
smtpNoAuth: string;
};
}
@@ -27,13 +29,17 @@ export function SmtpTestButton({ smtpEnabled, getFormValues }: SmtpTestButtonPro
const formValues = getFormValues();
if (formValues.smtpEnabled !== "true") {
toast.error("SMTP is not enabled. Please enable SMTP first.");
toast.error(t("settings.messages.smtpNotEnabled"));
return;
}
// Check if required fields are filled
if (!formValues.smtpHost || !formValues.smtpPort || !formValues.smtpUser || !formValues.smtpPass) {
toast.error("Please fill in all SMTP configuration fields before testing.");
if (!formValues.smtpHost || !formValues.smtpPort) {
toast.error(t("settings.messages.smtpMissingHostPort"));
return;
}
if (formValues.smtpNoAuth !== "true" && (!formValues.smtpUser || !formValues.smtpPass)) {
toast.error(t("settings.messages.smtpMissingAuth"));
return;
}
@@ -46,6 +52,8 @@ export function SmtpTestButton({ smtpEnabled, getFormValues }: SmtpTestButtonPro
smtpPort: formValues.smtpPort,
smtpUser: formValues.smtpUser,
smtpPass: formValues.smtpPass,
smtpSecure: formValues.smtpSecure,
smtpNoAuth: formValues.smtpNoAuth,
},
});
@@ -55,7 +63,7 @@ export function SmtpTestButton({ smtpEnabled, getFormValues }: SmtpTestButtonPro
toast.error(t("settings.messages.smtpTestGenericError"));
}
} catch (error: any) {
const errorMessage = error?.response?.data?.error || error?.message || "Unknown error";
const errorMessage = error?.response?.data?.error || error?.message || t("common.unexpectedError");
toast.error(t("settings.messages.smtpTestFailed", { error: errorMessage }));
} finally {
setIsLoading(false);

View File

@@ -46,6 +46,8 @@ export const createFieldDescriptions = (t: ReturnType<typeof createTranslator>)
smtpPass: t("settings.fields.smtpPass.description"),
smtpFromName: t("settings.fields.smtpFromName.description"),
smtpFromEmail: t("settings.fields.smtpFromEmail.description"),
smtpSecure: t("settings.fields.smtpSecure.description"),
smtpNoAuth: t("settings.fields.smtpNoAuth.description"),
// OIDC settings (nomes corretos do seed)
oidcEnabled: t("settings.fields.oidcEnabled.description"),
@@ -85,6 +87,8 @@ export const createFieldTitles = (t: ReturnType<typeof createTranslator>) => ({
smtpPass: t("settings.fields.smtpPass.title"),
smtpFromName: t("settings.fields.smtpFromName.title"),
smtpFromEmail: t("settings.fields.smtpFromEmail.title"),
smtpSecure: t("settings.fields.smtpSecure.title"),
smtpNoAuth: t("settings.fields.smtpNoAuth.title"),
// OIDC settings
oidcEnabled: t("settings.fields.oidcEnabled.title"),

View File

@@ -77,8 +77,26 @@ export function useSettings() {
}
if (group === "email") {
if (a.key === "smtpEnabled") return -1;
if (b.key === "smtpEnabled") return 1;
const smtpOrder = [
"smtpEnabled",
"smtpHost",
"smtpPort",
"smtpSecure",
"smtpNoAuth",
"smtpUser",
"smtpPass",
"smtpFromName",
"smtpFromEmail",
];
const aIndex = smtpOrder.indexOf(a.key);
const bIndex = smtpOrder.indexOf(b.key);
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex;
}
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
}
if (group === "oidc") {

View File

@@ -90,6 +90,8 @@ export interface TestSmtpConnectionBody {
smtpPort: string;
smtpUser: string;
smtpPass: string;
smtpSecure: string;
smtpNoAuth: string;
};
}

View File

@@ -23,7 +23,7 @@ docker buildx build \
--no-cache \
-t kyantech/palmr:latest \
-t kyantech/palmr:$TAG \
--load \
--push \
.
if [ $? -eq 0 ]; then