feat: enhance SMTP configuration options in settings (#107)

This commit is contained in:
Daniel Luiz Alves
2025-06-24 11:19:22 -03:00
committed by GitHub
11 changed files with 288 additions and 57 deletions

View File

@@ -117,6 +117,18 @@ const defaultConfigs = [
type: "string", type: "string",
group: "email", group: "email",
}, },
{
key: "smtpSecure",
value: "auto",
type: "string",
group: "email",
},
{
key: "smtpNoAuth",
value: "false",
type: "boolean",
group: "email",
},
{ {
key: "passwordResetTokenExpiration", key: "passwordResetTokenExpiration",
value: "3600", 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)"), .describe("SMTP server port (typically 587 for TLS, 25 for non-secure)"),
smtpUser: z.string().describe("Username for SMTP authentication (e.g., email address)"), 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)"), 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() .optional()
.describe("SMTP configuration to test. If not provided, uses currently saved configuration"), .describe("SMTP configuration to test. If not provided, uses currently saved configuration"),

View File

@@ -7,6 +7,8 @@ interface SmtpConfig {
smtpPort: string; smtpPort: string;
smtpUser: string; smtpUser: string;
smtpPass: string; smtpPass: string;
smtpSecure?: string;
smtpNoAuth?: string;
} }
export class EmailService { export class EmailService {
@@ -18,15 +20,46 @@ export class EmailService {
return null; 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"), host: await this.configService.getValue("smtpHost"),
port: Number(await this.configService.getValue("smtpPort")), port: port,
secure: false, secure: secure,
auth: { requireTLS: requireTLS,
tls: {
rejectUnauthorized: false,
},
};
if (smtpNoAuth !== "true") {
transportConfig.auth = {
user: await this.configService.getValue("smtpUser"), user: await this.configService.getValue("smtpUser"),
pass: await this.configService.getValue("smtpPass"), pass: await this.configService.getValue("smtpPass"),
}, };
}); }
return nodemailer.createTransport(transportConfig);
} }
async testConnection(config?: SmtpConfig) { async testConnection(config?: SmtpConfig) {
@@ -43,6 +76,8 @@ export class EmailService {
smtpPort: await this.configService.getValue("smtpPort"), smtpPort: await this.configService.getValue("smtpPort"),
smtpUser: await this.configService.getValue("smtpUser"), smtpUser: await this.configService.getValue("smtpUser"),
smtpPass: await this.configService.getValue("smtpPass"), 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"); 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, host: smtpConfig.smtpHost,
port: Number(smtpConfig.smtpPort), port: port,
secure: false, secure: secure,
auth: { requireTLS: requireTLS,
tls: {
rejectUnauthorized: false,
},
};
if (smtpNoAuth !== "true") {
transportConfig.auth = {
user: smtpConfig.smtpUser, user: smtpConfig.smtpUser,
pass: smtpConfig.smtpPass, pass: smtpConfig.smtpPass,
}, };
}); }
const transporter = nodemailer.createTransport(transportConfig);
try { try {
await transporter.verify(); await transporter.verify();

View File

@@ -465,6 +465,20 @@
"title": "Sender Email", "title": "Sender Email",
"description": "Sender email address" "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": { "testSmtp": {
"title": "Test SMTP Connection", "title": "Test SMTP Connection",
"description": "Test if the SMTP configuration is valid" "description": "Test if the SMTP configuration is valid"
@@ -548,7 +562,10 @@
"updateSuccess": "{group} settings updated successfully", "updateSuccess": "{group} settings updated successfully",
"smtpTestSuccess": "SMTP connection successful! Your email configuration is working correctly.", "smtpTestSuccess": "SMTP connection successful! Your email configuration is working correctly.",
"smtpTestFailed": "SMTP connection failed: {error}", "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", "title": "Settings",
"breadcrumb": "Settings", "breadcrumb": "Settings",
@@ -556,7 +573,8 @@
"tooltips": { "tooltips": {
"oidcScope": "Enter a scope and press Enter to add", "oidcScope": "Enter a scope and press Enter to add",
"oidcAdminEmailDomains": "Enter a domain 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": { "redirectUri": {
"placeholder": "https://mysite.com", "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"> <div className="flex flex-col gap-4">
{configs {configs
.filter((config) => !isFieldHidden(config.key)) .filter((config) => !isFieldHidden(config.key))
.map((config) => ( .map((config) => {
<div key={config.key} className="space-y-2 mb-3"> const smtpEnabled = form.watch("configs.smtpEnabled");
<SettingsInput const smtpNoAuth = form.watch("configs.smtpNoAuth");
config={config} const isSmtpAuthField = config.key === "smtpUser" || config.key === "smtpPass";
error={form.formState.errors.configs?.[config.key]}
register={form.register} const smtpFields = [
setValue={form.setValue} "smtpHost",
smtpEnabled={form.watch("configs.smtpEnabled")} "smtpPort",
oidcEnabled={form.watch("configs.oidcEnabled")} "smtpUser",
watch={form.watch} "smtpPass",
/> "smtpSecure",
<p className="text-xs text-muted-foreground ml-1"> "smtpNoAuth",
{t(`settings.fields.${config.key}.description`, { "smtpFromName",
defaultValue: "smtpFromEmail",
FIELD_DESCRIPTIONS[config.key as keyof typeof FIELD_DESCRIPTIONS] || ];
config.description ||
t("settings.fields.noDescription"), if (smtpEnabled !== "true" && smtpFields.includes(config.key)) {
})} return null;
</p> }
</div>
))} 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>
<div className="flex justify-between items-center mt-4"> <div className="flex justify-between items-center mt-4">
{isEmailGroup && ( <div className="flex">
<SmtpTestButton {isEmailGroup && form.watch("configs.smtpEnabled") === "true" && (
smtpEnabled={form.watch("configs.smtpEnabled") || "false"} <SmtpTestButton
getFormValues={() => ({ smtpEnabled={form.watch("configs.smtpEnabled") || "false"}
smtpEnabled: form.getValues("configs.smtpEnabled") || "false", getFormValues={() => ({
smtpHost: form.getValues("configs.smtpHost") || "", smtpEnabled: form.getValues("configs.smtpEnabled") || "false",
smtpPort: form.getValues("configs.smtpPort") || "", smtpHost: form.getValues("configs.smtpHost") || "",
smtpUser: form.getValues("configs.smtpUser") || "", smtpPort: form.getValues("configs.smtpPort") || "",
smtpPass: form.getValues("configs.smtpPass") || "", smtpUser: form.getValues("configs.smtpUser") || "",
})} smtpPass: form.getValues("configs.smtpPass") || "",
/> smtpSecure: form.getValues("configs.smtpSecure") || "auto",
)} smtpNoAuth: form.getValues("configs.smtpNoAuth") || "false",
<div className={`flex ${isEmailGroup ? "ml-auto" : ""}`}> })}
/>
)}
</div>
<div className="flex">
<Button <Button
variant="default" variant="default"
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}

View File

@@ -2,8 +2,10 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { UseFormRegister, UseFormSetValue, UseFormWatch } from "react-hook-form"; import { UseFormRegister, UseFormSetValue, UseFormWatch } from "react-hook-form";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input"; import { PasswordInput } from "@/components/ui/password-input";
import { Switch } from "@/components/ui/switch";
import { TagsInput } from "@/components/ui/tags-input"; import { TagsInput } from "@/components/ui/tags-input";
import { createFieldTitles } from "../constants"; import { createFieldTitles } from "../constants";
import { Config } from "../types"; import { Config } from "../types";
@@ -129,7 +131,7 @@ export function SettingsInput({
? "openid profile email" ? "openid profile email"
: config.key === "oidcAdminEmailDomains" : config.key === "oidcAdminEmailDomains"
? "admin.com company.org" ? "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>} {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) { switch (config.type) {
case "boolean": case "boolean":
return ( return (

View File

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

View File

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

View File

@@ -77,8 +77,26 @@ export function useSettings() {
} }
if (group === "email") { if (group === "email") {
if (a.key === "smtpEnabled") return -1; const smtpOrder = [
if (b.key === "smtpEnabled") return 1; "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") { if (group === "oidc") {

View File

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

View File

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