Add SMTP admin settings and contact form delivery

This commit is contained in:
MOH
2026-03-08 06:27:09 +01:00
parent 993a4673d8
commit 5ca4819bcb
28 changed files with 2087 additions and 55 deletions
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
buildDefaultContactProtectionSettings,
parseContactProtectionValue,
toContactProtectionFormValues,
} from "../lib/contact-protection";
describe("contact protection helpers", () => {
it("builds safe defaults", () => {
expect(buildDefaultContactProtectionSettings()).toEqual({
turnstile: {
enabled: false,
siteKey: "",
secretKey: "",
},
rateLimit: {
enabled: true,
maxRequests: 5,
windowMinutes: 10,
},
});
});
it("parses stored settings and trims keys", () => {
const settings = parseContactProtectionValue(
JSON.stringify({
turnstile: {
enabled: true,
siteKey: " 0x4AAAAA ",
secretKey: "secret",
},
rateLimit: {
enabled: true,
maxRequests: "8",
windowMinutes: "15",
},
}),
);
expect(settings.turnstile.enabled).toBe(true);
expect(settings.turnstile.siteKey).toBe("0x4AAAAA");
expect(settings.turnstile.secretKey).toBe("secret");
expect(settings.rateLimit.maxRequests).toBe(8);
expect(settings.rateLimit.windowMinutes).toBe(15);
});
it("hides the stored secret in form values", () => {
const values = toContactProtectionFormValues({
turnstile: {
enabled: true,
siteKey: "site-key",
secretKey: "secret-key",
},
rateLimit: {
enabled: true,
maxRequests: 5,
windowMinutes: 10,
},
});
expect(values.turnstile.secretKey).toBe("");
expect(values.turnstile.hasSecretKey).toBe(true);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import {
buildDefaultMailSettings,
parseMailSettingsValue,
toMailSettingsFormValues,
} from "../lib/mail-settings";
describe("mail settings helpers", () => {
it("builds safe defaults", () => {
expect(buildDefaultMailSettings()).toEqual({
smtp: {
host: "",
port: 587,
secure: false,
username: "",
password: "",
},
sender: {
email: "",
name: "",
},
recipients: {
contact: "",
test: "",
},
});
});
it("parses stored values and trims strings", () => {
const settings = parseMailSettingsValue(
JSON.stringify({
smtp: {
host: " smtp.example.com ",
port: "465",
secure: true,
username: " mailer ",
password: "secret",
},
sender: {
email: " hello@example.com ",
name: " Studio Moh ",
},
recipients: {
contact: " inbox@example.com ",
test: " test@example.com ",
},
}),
);
expect(settings.smtp.host).toBe("smtp.example.com");
expect(settings.smtp.port).toBe(465);
expect(settings.smtp.secure).toBe(true);
expect(settings.smtp.username).toBe("mailer");
expect(settings.smtp.password).toBe("secret");
expect(settings.sender.email).toBe("hello@example.com");
expect(settings.sender.name).toBe("Studio Moh");
expect(settings.recipients.contact).toBe("inbox@example.com");
expect(settings.recipients.test).toBe("test@example.com");
});
it("falls back when json is invalid", () => {
expect(parseMailSettingsValue("{invalid-json")).toEqual(buildDefaultMailSettings());
});
it("hides the saved password in form values", () => {
const formValues = toMailSettingsFormValues({
smtp: {
host: "smtp.example.com",
port: 587,
secure: false,
username: "mailer",
password: "secret",
},
sender: {
email: "hello@example.com",
name: "Studio Moh",
},
recipients: {
contact: "inbox@example.com",
test: "test@example.com",
},
});
expect(formValues.smtp.password).toBe("");
expect(formValues.smtp.hasPassword).toBe(true);
});
});
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it, vi } from "vitest";
import type { MailSettings } from "../lib/mail-settings";
import { sendContactMessage, sendTestEmail } from "../lib/mail";
function createMailSettings(overrides: Partial<MailSettings> = {}): MailSettings {
return {
smtp: {
host: "smtp.example.com",
port: 587,
secure: false,
username: "mailer@example.com",
password: "secret",
...overrides.smtp,
},
sender: {
email: "hello@example.com",
name: "Studio Moh",
...overrides.sender,
},
recipients: {
contact: "contact@example.com",
test: "test@example.com",
...overrides.recipients,
},
};
}
describe("mail delivery", () => {
it("sends contact messages with reply-to", async () => {
const sendMailMock = vi.fn().mockResolvedValue({});
const createTransport = vi.fn().mockReturnValue({
sendMail: sendMailMock,
});
await sendContactMessage(
{
locale: "en",
name: "Jane Doe",
email: "jane@example.com",
message: "Hello from the website contact form.",
},
{
settings: createMailSettings(),
createTransport,
},
);
expect(createTransport).toHaveBeenCalledWith({
host: "smtp.example.com",
port: 587,
secure: false,
auth: {
user: "mailer@example.com",
pass: "secret",
},
});
expect(sendMailMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "contact@example.com",
subject: "New contact message",
replyTo: "jane@example.com",
}),
);
expect(sendMailMock.mock.calls[0]?.[0].text).toContain("Hello from the website contact form.");
});
it("falls back to the test recipient when contact recipient is empty", async () => {
const sendMailMock = vi.fn().mockResolvedValue({});
const createTransport = vi.fn().mockReturnValue({
sendMail: sendMailMock,
});
await sendContactMessage(
{
locale: "de",
name: "Jane Doe",
email: "jane@example.com",
message: "Fallback recipient should still work.",
},
{
settings: createMailSettings({
recipients: {
contact: "",
test: "fallback@example.com",
},
}),
createTransport,
},
);
expect(sendMailMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "fallback@example.com",
}),
);
});
it("sends backend test emails to the configured recipient", async () => {
const sendMailMock = vi.fn().mockResolvedValue({});
const createTransport = vi.fn().mockReturnValue({
sendMail: sendMailMock,
});
await sendTestEmail({
settings: createMailSettings(),
createTransport,
});
expect(sendMailMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "test@example.com",
subject: "SMTP test email",
}),
);
});
it("fails when the transport rejects the test email", async () => {
const createTransport = vi.fn().mockReturnValue({
sendMail: vi.fn().mockRejectedValue(new Error("Authentication failed.")),
});
await expect(
sendTestEmail({
settings: createMailSettings(),
createTransport,
}),
).rejects.toThrow("Authentication failed.");
});
});
+5 -3
View File
@@ -23,18 +23,20 @@ describe("metadata helpers", () => {
favicon: {
assetId: "fav",
url: "/uploads/media/site-settings/favicon.svg",
version: "v1",
},
defaultOgImage: {
assetId: "og",
url: "/uploads/media/site-settings/default-og.png",
version: "v1",
},
});
expect(metadata.title).toBe("Studio Moh");
expect(metadata.icons).toEqual({
icon: [{ url: "/favicon.ico?v=default" }],
shortcut: [{ url: "/favicon.ico?v=default" }],
apple: [{ url: "/apple-icon.png?v=default" }],
icon: [{ url: "/favicon.ico?v=v1" }],
shortcut: [{ url: "/favicon.ico?v=v1" }],
apple: [{ url: "/apple-icon.png?v=v1" }],
});
expect(metadata.twitter).toMatchObject({
card: "summary_large_image",