From e71892d0bc946cafc5622c9394ac33a74a5a0b96 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Wed, 29 Apr 2026 15:30:16 -0400 Subject: [PATCH 1/7] feat(smtp): add optional sender display name to SMTP configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3600. Previously the From header on test emails and notifications showed only the raw email address. Adds an optional "Sender name" field so emails appear from a friendly name like "Checkmate notifications" . ## Behaviour - Field is optional. When empty/null/whitespace, the From header falls back to the raw email address — identical to current behaviour, no regression for existing installs. - When set, the email service hands nodemailer { name, address } as the message from field. Nodemailer handles RFC-5322 encoding (quotes, commas, non-ASCII) so the display name is safe for any input. - Unsetting works: clearing the input transforms the value to null in the Zod parse step, and the settings repository already turns null values into a $unset operation. - Both client and server Zod schemas trim and empty-string-to-null, preventing whitespace-only display names from being stored. ## Surfaces - AppSettings schema, Settings type, EmailTransportConfig pick, server Zod validation, client Zod validation, client form defaults, and the Settings page form all carry the new systemEmailDisplayName field. - The transport preview pre-block on /settings now includes a `from` key showing exactly what the email service hands nodemailer — the string when no display name is set, or { name, address } when it is. - en.json carries the new label and placeholder. Other locales fall back until translated (16 locales pending — separate follow-up). ## Conventions conformance (per docs/frontend-conventions.md) - The pre-block I touched had three rule #1 violations (sx p, borderRadius, backgroundColor); hoisted those to native props using theme.shape.borderRadius. Other rule #1 violations elsewhere on this page are pre-existing and intentionally left alone. - No new sx-with-native-equivalents introduced. - No color="text.X" shorthand introduced. - No theme module import introduced. --- client/src/Hooks/useSettingsForm.ts | 1 + client/src/Pages/Settings/index.tsx | 103 +++++++++++------- client/src/Types/Settings.ts | 1 + client/src/Validation/settings.ts | 5 + client/src/locales/en.json | 4 + server/src/db/models/AppSettings.ts | 1 + .../service/infrastructure/emailService.ts | 6 +- server/src/types/email.ts | 1 + server/src/types/settings.ts | 1 + server/src/validation/settingsValidation.ts | 6 + 10 files changed, 90 insertions(+), 39 deletions(-) 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 fc3fceceb..da4960ebb 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,50 @@ 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(); + const from = + displayName && address ? { name: displayName, address } : address; + 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, + from, + tls: { + rejectUnauthorized: + form.watch("systemEmailRejectUnauthorized") ?? true, + ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, + requireTLS: form.watch("systemEmailRequireTLS") ?? false, + servername: form.watch("systemEmailTLSServername") || "", + }, + }, + null, + 2 + )} + + + ); + })()} } rightContent={ @@ -680,6 +689,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/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/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(), From e11553c2184778e71a8ab5258bbf95d0b7ee0060 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Wed, 29 Apr 2026 15:33:15 -0400 Subject: [PATCH 2/7] feat(settings): restore From-header preview block under SMTP transport JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings back the second pre-block I dropped when consolidating the preview. The transport JSON shows what's handed to nodemailer createTransport; the From-header block shows the rendered envelope recipients will see — they answer different questions and both belong. Conditioned on systemEmailAddress so empty configs don't render an empty pre. --- client/src/Pages/Settings/index.tsx | 88 +++++++++++++++++------------ 1 file changed, 53 insertions(+), 35 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index da4960ebb..e12850987 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -586,42 +586,60 @@ export const SettingsPage = () => { const from = displayName && address ? { name: displayName, address } : address; return ( - - - {JSON.stringify( - { - host: form.watch("systemEmailHost") || "", - port: form.watch("systemEmailPort") || "", - secure: form.watch("systemEmailSecure") ?? false, - auth: { - user: form.watch("systemEmailUser") || address, - pass: "", + <> + + + {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, + from, + tls: { + rejectUnauthorized: + form.watch("systemEmailRejectUnauthorized") ?? true, + ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, + requireTLS: form.watch("systemEmailRequireTLS") ?? false, + servername: form.watch("systemEmailTLSServername") || "", + }, }, - name: form.watch("systemEmailConnectionHost") || "localhost", - pool: form.watch("systemEmailPool") ?? false, - from, - tls: { - rejectUnauthorized: - form.watch("systemEmailRejectUnauthorized") ?? true, - ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, - requireTLS: form.watch("systemEmailRequireTLS") ?? false, - servername: form.watch("systemEmailTLSServername") || "", - }, - }, - null, - 2 - )} - - + null, + 2 + )} + + + {address && ( + + + {`From: ${displayName ? `"${displayName}" <${address}>` : address}`} + + + )} + ); })()} From baa4d693aeb44dc2c7ed2eb7f0a5a5db578807a6 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Wed, 29 Apr 2026 15:38:22 -0400 Subject: [PATCH 3/7] refactor(email): set From at transport level instead of per-message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /settings transport preview shows `from` inside the createTransport JSON, but the implementation was passing it per-message via sendMail. Switch to passing it as part of the transport config so what the preview shows matches what is actually built — no behavioural change for outgoing emails since nodemailer treats transport-level `from` as the default for every sendMail call. --- server/src/service/infrastructure/emailService.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/infrastructure/emailService.ts index 4428b0477..137bd9e2b 100755 --- a/server/src/service/infrastructure/emailService.ts +++ b/server/src/service/infrastructure/emailService.ts @@ -127,6 +127,9 @@ export class EmailService implements IEmailService { systemEmailRejectUnauthorized, } = config; + const trimmedDisplayName = systemEmailDisplayName?.trim(); + const from = trimmedDisplayName && systemEmailAddress ? { name: trimmedDisplayName, address: systemEmailAddress } : systemEmailAddress; + const emailConfig = { host: systemEmailHost, port: Number(systemEmailPort), @@ -138,6 +141,7 @@ export class EmailService implements IEmailService { name: systemEmailConnectionHost || "localhost", connectionTimeout: 5000, pool: systemEmailPool, + from, tls: { rejectUnauthorized: systemEmailRejectUnauthorized, ignoreTLS: systemEmailIgnoreTLS, @@ -159,13 +163,9 @@ 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: from, subject: subject, html: html, }); From 8fbbb1dd4bc201d030d57f7c1429aca2da812dfb Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Wed, 29 Apr 2026 15:42:02 -0400 Subject: [PATCH 4/7] Revert "refactor(email): set From at transport level instead of per-message" This reverts commit baa4d693aeb44dc2c7ed2eb7f0a5a5db578807a6. --- server/src/service/infrastructure/emailService.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/infrastructure/emailService.ts index 137bd9e2b..4428b0477 100755 --- a/server/src/service/infrastructure/emailService.ts +++ b/server/src/service/infrastructure/emailService.ts @@ -127,9 +127,6 @@ export class EmailService implements IEmailService { systemEmailRejectUnauthorized, } = config; - const trimmedDisplayName = systemEmailDisplayName?.trim(); - const from = trimmedDisplayName && systemEmailAddress ? { name: trimmedDisplayName, address: systemEmailAddress } : systemEmailAddress; - const emailConfig = { host: systemEmailHost, port: Number(systemEmailPort), @@ -141,7 +138,6 @@ export class EmailService implements IEmailService { name: systemEmailConnectionHost || "localhost", connectionTimeout: 5000, pool: systemEmailPool, - from, tls: { rejectUnauthorized: systemEmailRejectUnauthorized, ignoreTLS: systemEmailIgnoreTLS, @@ -163,9 +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: from, subject: subject, html: html, }); From 6f988cc688af91f19046d5bec102cf92f140c369 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Wed, 29 Apr 2026 15:48:12 -0400 Subject: [PATCH 5/7] fix(settings): drop from field from transport JSON preview The transport JSON preview is strictly for verifying what is handed to nodemailer.createTransport. The From header is built per-message in sendMail, so showing it inside the transport JSON was misleading. The separate From: ... pre block below the JSON is the right place for that preview. --- client/src/Pages/Settings/index.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index e12850987..300d91738 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -583,8 +583,6 @@ export const SettingsPage = () => { {(() => { const address = form.watch("systemEmailAddress") || ""; const displayName = form.watch("systemEmailDisplayName")?.trim(); - const from = - displayName && address ? { name: displayName, address } : address; return ( <> { }, name: form.watch("systemEmailConnectionHost") || "localhost", pool: form.watch("systemEmailPool") ?? false, - from, tls: { rejectUnauthorized: form.watch("systemEmailRejectUnauthorized") ?? true, From d5bf45e2a915b4d766b422d4d3fab4023f0639f0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 29 Apr 2026 12:57:37 -0700 Subject: [PATCH 6/7] allow from --- server/src/controllers/settingsController.ts | 2 ++ server/src/validation/notificationValidation.ts | 1 + 2 files changed, 3 insertions(+) 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/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(), From 4de2ba4b25cb3f6d5ff61430b1a9211f2f7ba08b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 29 Apr 2026 12:59:48 -0700 Subject: [PATCH 7/7] add missing tests --- .../test/unit/services/emailService.test.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) 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(""); + }); + }); }); });