diff --git a/client/src/Hooks/useSettingsForm.ts b/client/src/Hooks/useSettingsForm.ts index 4b0768d79..643e9caca 100644 --- a/client/src/Hooks/useSettingsForm.ts +++ b/client/src/Hooks/useSettingsForm.ts @@ -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, diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 3c6e8a3a1..e14366e0f 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -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" /> - - - {JSON.stringify( - { - host: form.watch("systemEmailHost") || "", - port: form.watch("systemEmailPort") || "", - secure: form.watch("systemEmailSecure") ?? false, - auth: { - user: - form.watch("systemEmailUser") || - form.watch("systemEmailAddress") || - "", - pass: "", - }, - 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 - )} - - + {(() => { + const address = form.watch("systemEmailAddress") || ""; + const displayName = form.watch("systemEmailDisplayName")?.trim(); + return ( + <> + + + {JSON.stringify( + { + host: form.watch("systemEmailHost") || "", + port: form.watch("systemEmailPort") || "", + secure: form.watch("systemEmailSecure") ?? false, + auth: { + user: form.watch("systemEmailUser") || address, + pass: "", + }, + 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 + )} + + + {address && ( + + + {`From: ${displayName ? `"${displayName}" <${address}>` : address}`} + + + )} + + ); + })()} } rightContent={ @@ -680,6 +704,24 @@ export const SettingsPage = () => { )} /> + {/* Email Display Name (Optional) */} + ( + + )} + /> + {/* Email User (Optional) */} (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())) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 3c632ef96..d6e8bb4f3 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -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" diff --git a/server/src/controllers/settingsController.ts b/server/src/controllers/settingsController.ts index 03bc91898..485a90d64 100644 --- a/server/src/controllers/settingsController.ts +++ b/server/src/controllers/settingsController.ts @@ -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, diff --git a/server/src/db/models/AppSettings.ts b/server/src/db/models/AppSettings.ts index 9eeaa640a..195f43044 100644 --- a/server/src/db/models/AppSettings.ts +++ b/server/src/db/models/AppSettings.ts @@ -26,6 +26,7 @@ const AppSettingsSchema = new Schema( systemEmailHost: { type: String }, systemEmailPort: { type: Number }, systemEmailAddress: { type: String }, + systemEmailDisplayName: { type: String }, systemEmailPassword: { type: String }, systemEmailUser: { type: String }, systemEmailConnectionHost: { type: String, default: "localhost" }, diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/infrastructure/emailService.ts index 6cf8caf4f..4428b0477 100755 --- a/server/src/service/infrastructure/emailService.ts +++ b/server/src/service/infrastructure/emailService.ts @@ -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, }); diff --git a/server/src/types/email.ts b/server/src/types/email.ts index 2ffae187b..6e884319f 100644 --- a/server/src/types/email.ts +++ b/server/src/types/email.ts @@ -5,6 +5,7 @@ export type EmailTransportConfig = Pick< | "systemEmailHost" | "systemEmailPort" | "systemEmailAddress" + | "systemEmailDisplayName" | "systemEmailPassword" | "systemEmailUser" | "systemEmailConnectionHost" diff --git a/server/src/types/settings.ts b/server/src/types/settings.ts index eb8452866..3d0d0d931 100644 --- a/server/src/types/settings.ts +++ b/server/src/types/settings.ts @@ -21,6 +21,7 @@ export interface Settings { systemEmailHost?: string; systemEmailPort?: number; systemEmailAddress?: string; + systemEmailDisplayName?: string; systemEmailPassword?: string; systemEmailUser?: string; systemEmailConnectionHost?: string; diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index cf7478610..523cb2a6e 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -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(), diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts index f2c4e4c94..db58c50ed 100644 --- a/server/src/validation/settingsValidation.ts +++ b/server/src/validation/settingsValidation.ts @@ -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(), diff --git a/server/test/unit/services/emailService.test.ts b/server/test/unit/services/emailService.test.ts index 13b234186..d4d275ad8 100644 --- a/server/test/unit/services/emailService.test.ts +++ b/server/test/unit/services/emailService.test.ts @@ -396,5 +396,80 @@ describe("EmailService", () => { }) ); }); + + // ── from-header formatting ─────────────────────────────────────── + + describe("from header formatting", () => { + const sendAndGetFrom = async (overrides: Partial) => { + const transporter = createMockTransporter(); + const nodemailer = createMockNodemailer(transporter); + const { service } = createService({ nodemailer }); + + await service.sendEmail("to@example.com", "Subject", "

body

", 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(""); + }); + }); }); });