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:
13 files changed
+183
-39
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Settings {
|
||||
systemEmailHost?: string;
|
||||
systemEmailPort?: number;
|
||||
systemEmailAddress?: string;
|
||||
systemEmailDisplayName?: string;
|
||||
systemEmailUser?: string;
|
||||
systemEmailConnectionHost?: string;
|
||||
systemEmailTLSServername?: string;
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ export type EmailTransportConfig = Pick<
|
||||
| "systemEmailHost"
|
||||
| "systemEmailPort"
|
||||
| "systemEmailAddress"
|
||||
| "systemEmailDisplayName"
|
||||
| "systemEmailPassword"
|
||||
| "systemEmailUser"
|
||||
| "systemEmailConnectionHost"
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user