Merge pull request #3607 from bluewave-labs/feat/smtp-from-name

feat(smtp): add optional sender display name to SMTP configuration
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-04-29 13:01:08 -07:00
commit bdf1e13ce8
13 files changed
+183 -39

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
systemEmailHost: data?.systemEmailHost || "",
systemEmailUser: data?.systemEmailUser || "",
systemEmailAddress: data?.systemEmailAddress || "",
systemEmailDisplayName: data?.systemEmailDisplayName || "",
systemEmailConnectionHost: data?.systemEmailConnectionHost || "localhost",
systemEmailTLSServername: data?.systemEmailTLSServername || "",
systemEmailPort: data?.systemEmailPort,
+80 -38
View File
@@ -182,6 +182,9 @@ export const SettingsPage = () => {
systemEmailRequireTLS: formValues.systemEmailRequireTLS,
systemEmailRejectUnauthorized: formValues.systemEmailRejectUnauthorized,
...(formValues.systemEmailUser && { systemEmailUser: formValues.systemEmailUser }),
...(formValues.systemEmailDisplayName && {
systemEmailDisplayName: formValues.systemEmailDisplayName,
}),
...(formValues.systemEmailTLSServername && {
systemEmailTLSServername: formValues.systemEmailTLSServername,
}),
@@ -577,44 +580,65 @@ export const SettingsPage = () => {
href="https://nodemailer.com/smtp/"
target="_blank"
/>
<Box
component="pre"
sx={{
fontFamily: theme.typography.fontFamilyMonospace,
p: 2,
borderRadius: 1,
overflow: "auto",
backgroundColor: theme.palette.action.hover,
}}
>
<code>
{JSON.stringify(
{
host: form.watch("systemEmailHost") || "",
port: form.watch("systemEmailPort") || "",
secure: form.watch("systemEmailSecure") ?? false,
auth: {
user:
form.watch("systemEmailUser") ||
form.watch("systemEmailAddress") ||
"",
pass: "<your_password>",
},
name: form.watch("systemEmailConnectionHost") || "localhost",
pool: form.watch("systemEmailPool") ?? false,
tls: {
rejectUnauthorized:
form.watch("systemEmailRejectUnauthorized") ?? true,
ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false,
requireTLS: form.watch("systemEmailRequireTLS") ?? false,
servername: form.watch("systemEmailTLSServername") || "",
},
},
null,
2
)}
</code>
</Box>
{(() => {
const address = form.watch("systemEmailAddress") || "";
const displayName = form.watch("systemEmailDisplayName")?.trim();
return (
<>
<Box
component="pre"
p={2}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.action.hover}
sx={{
fontFamily: theme.typography.fontFamilyMonospace,
overflow: "auto",
}}
>
<code>
{JSON.stringify(
{
host: form.watch("systemEmailHost") || "",
port: form.watch("systemEmailPort") || "",
secure: form.watch("systemEmailSecure") ?? false,
auth: {
user: form.watch("systemEmailUser") || address,
pass: "<your_password>",
},
name: form.watch("systemEmailConnectionHost") || "localhost",
pool: form.watch("systemEmailPool") ?? false,
tls: {
rejectUnauthorized:
form.watch("systemEmailRejectUnauthorized") ?? true,
ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false,
requireTLS: form.watch("systemEmailRequireTLS") ?? false,
servername: form.watch("systemEmailTLSServername") || "",
},
},
null,
2
)}
</code>
</Box>
{address && (
<Box
component="pre"
p={2}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.action.hover}
sx={{
fontFamily: theme.typography.fontFamilyMonospace,
overflow: "auto",
}}
>
<code>
{`From: ${displayName ? `"${displayName}" <${address}>` : address}`}
</code>
</Box>
)}
</>
);
})()}
</Stack>
}
rightContent={
@@ -680,6 +704,24 @@ export const SettingsPage = () => {
)}
/>
{/* Email Display Name (Optional) */}
<Controller
name="systemEmailDisplayName"
control={form.control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
fieldLabel={t("pages.settings.form.email.option.displayName.label")}
placeholder={t(
"pages.settings.form.email.option.displayName.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
{/* Email User (Optional) */}
<Controller
name="systemEmailUser"
+1
View File
@@ -12,6 +12,7 @@ export interface Settings {
systemEmailHost?: string;
systemEmailPort?: number;
systemEmailAddress?: string;
systemEmailDisplayName?: string;
systemEmailUser?: string;
systemEmailConnectionHost?: string;
systemEmailTLSServername?: string;
+5
View File
@@ -32,6 +32,11 @@ export const settingsSchema = z.object({
.or(z.literal(""))
.transform((val) => (val === "" ? null : val.toLowerCase().trim()))
.optional(),
systemEmailDisplayName: z
.string()
.max(100, "Display name must be 100 characters or fewer")
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailUser: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
+4
View File
@@ -1198,6 +1198,10 @@
"label": "Email address - Used for authentication",
"placeholder": "you@example.com"
},
"displayName": {
"label": "Sender name (optional) - Friendly name shown in the From field",
"placeholder": "Checkmate notifications"
},
"user": {
"label": "Email user - Username for authentication, overrides email address if specified",
"placeholder": "Leave empty if not required"
@@ -89,6 +89,7 @@ class SettingsController implements ISettingsController {
systemEmailHost,
systemEmailPort,
systemEmailAddress,
systemEmailDisplayName,
systemEmailPassword,
systemEmailUser,
systemEmailConnectionHost,
@@ -112,6 +113,7 @@ class SettingsController implements ISettingsController {
systemEmailPort,
systemEmailUser,
systemEmailAddress,
systemEmailDisplayName,
systemEmailPassword,
systemEmailConnectionHost,
systemEmailSecure,
+1
View File
@@ -26,6 +26,7 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
systemEmailHost: { type: String },
systemEmailPort: { type: Number },
systemEmailAddress: { type: String },
systemEmailDisplayName: { type: String },
systemEmailPassword: { type: String },
systemEmailUser: { type: String },
systemEmailConnectionHost: { type: String, default: "localhost" },
@@ -118,6 +118,7 @@ export class EmailService implements IEmailService {
systemEmailPool,
systemEmailUser,
systemEmailAddress,
systemEmailDisplayName,
systemEmailPassword,
systemEmailConnectionHost,
systemEmailTLSServername,
@@ -158,10 +159,13 @@ export class EmailService implements IEmailService {
return false;
}
const trimmedDisplayName = systemEmailDisplayName?.trim();
const from = trimmedDisplayName && systemEmailAddress ? { name: trimmedDisplayName, address: systemEmailAddress } : systemEmailAddress;
try {
const info = await this.transporter.sendMail({
to: to,
from: systemEmailAddress,
from: from,
subject: subject,
html: html,
});
+1
View File
@@ -5,6 +5,7 @@ export type EmailTransportConfig = Pick<
| "systemEmailHost"
| "systemEmailPort"
| "systemEmailAddress"
| "systemEmailDisplayName"
| "systemEmailPassword"
| "systemEmailUser"
| "systemEmailConnectionHost"
+1
View File
@@ -21,6 +21,7 @@ export interface Settings {
systemEmailHost?: string;
systemEmailPort?: number;
systemEmailAddress?: string;
systemEmailDisplayName?: string;
systemEmailPassword?: string;
systemEmailUser?: string;
systemEmailConnectionHost?: string;
@@ -113,6 +113,7 @@ export const sendTestEmailBodyValidation = z.object({
systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(),
systemEmailAddress: z.string().optional(),
systemEmailDisplayName: z.string().optional(),
systemEmailPassword: z.string().optional(),
systemEmailUser: z.string().optional(),
systemEmailConnectionHost: z.union([z.string(), z.literal("")]).optional(),
@@ -14,6 +14,12 @@ export const updateAppSettingsBodyValidation = z
timezone: z.string().optional(),
systemEmailHost: z.string().nullable().optional(),
systemEmailAddress: z.string().nullable().optional(),
systemEmailDisplayName: z
.string()
.max(100)
.transform((val) => (val.trim() === "" ? null : val.trim()))
.nullable()
.optional(),
systemEmailPassword: z.string().nullable().optional(),
systemEmailUser: z.string().nullable().optional(),
systemEmailConnectionHost: z.string().nullable().optional(),
@@ -396,5 +396,80 @@ describe("EmailService", () => {
})
);
});
// ── from-header formatting ───────────────────────────────────────
describe("from header formatting", () => {
const sendAndGetFrom = async (overrides: Partial<EmailTransportConfig>) => {
const transporter = createMockTransporter();
const nodemailer = createMockNodemailer(transporter);
const { service } = createService({ nodemailer });
await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig(overrides));
const call = (transporter.sendMail as jest.Mock).mock.calls[0]?.[0] as { from: unknown } | undefined;
return call?.from;
};
it("uses bare address when systemEmailDisplayName is undefined", async () => {
const from = await sendAndGetFrom({
systemEmailAddress: "noreply@example.com",
systemEmailDisplayName: undefined,
});
expect(from).toBe("noreply@example.com");
});
it("uses bare address when systemEmailDisplayName is empty string", async () => {
const from = await sendAndGetFrom({
systemEmailAddress: "noreply@example.com",
systemEmailDisplayName: "",
});
expect(from).toBe("noreply@example.com");
});
it("uses bare address when systemEmailDisplayName is whitespace-only", async () => {
const from = await sendAndGetFrom({
systemEmailAddress: "noreply@example.com",
systemEmailDisplayName: " ",
});
expect(from).toBe("noreply@example.com");
});
it("uses {name, address} when display name and address are both set", async () => {
const from = await sendAndGetFrom({
systemEmailAddress: "noreply@example.com",
systemEmailDisplayName: "Checkmate notifications",
});
expect(from).toEqual({
name: "Checkmate notifications",
address: "noreply@example.com",
});
});
it("trims surrounding whitespace from display name", async () => {
const from = await sendAndGetFrom({
systemEmailAddress: "noreply@example.com",
systemEmailDisplayName: " Checkmate ",
});
expect(from).toEqual({
name: "Checkmate",
address: "noreply@example.com",
});
});
it("falls back to bare address when display name is set but address is empty", async () => {
const from = await sendAndGetFrom({
systemEmailAddress: "",
systemEmailDisplayName: "Checkmate",
});
expect(from).toBe("");
});
});
});
});