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
+108
View File
@@ -16,6 +16,24 @@ export {
type SiteSettings,
type SiteSettingsMediaBindings,
} from "./site-settings";
export {
MAIL_SETTINGS_KEY,
buildDefaultMailSettings,
parseMailSettingsValue,
toMailSettingsFormValues,
type MailSettings,
type MailSettingsFormValues,
} from "./mail-settings";
export {
CONTACT_PROTECTION_SETTINGS_KEY,
buildDefaultContactProtectionSettings,
parseContactProtectionValue,
toContactProtectionFormValues,
toPublicContactProtectionSettings,
type ContactProtectionSettings,
type ContactProtectionFormValues,
type PublicContactProtectionSettings,
} from "./contact-protection";
import {
DEFAULT_SITE_NAME,
SITE_NAME_KEY,
@@ -32,6 +50,24 @@ import {
type SiteSettings,
type SiteSettingsMediaBindings,
} from "./site-settings";
import {
MAIL_SETTINGS_KEY,
buildDefaultMailSettings,
parseMailSettingsValue,
toMailSettingsFormValues,
type MailSettings,
type MailSettingsFormValues,
} from "./mail-settings";
import {
CONTACT_PROTECTION_SETTINGS_KEY,
buildDefaultContactProtectionSettings,
parseContactProtectionValue,
toContactProtectionFormValues,
toPublicContactProtectionSettings,
type ContactProtectionSettings,
type ContactProtectionFormValues,
type PublicContactProtectionSettings,
} from "./contact-protection";
export async function getMaintenanceMode(): Promise<boolean> {
try {
@@ -95,6 +131,78 @@ export async function updateSiteSettings(settings: SiteSettings): Promise<void>
});
}
export async function getMailSettings(): Promise<MailSettings> {
try {
const config = await prisma.appConfig.findUnique({
where: { key: MAIL_SETTINGS_KEY },
select: { value: true },
});
return parseMailSettingsValue(config?.value);
} catch {
return buildDefaultMailSettings();
}
}
export async function getMailSettingsFormValues(): Promise<MailSettingsFormValues> {
const settings = await getMailSettings();
return toMailSettingsFormValues(settings);
}
export async function updateMailSettings(settings: MailSettings): Promise<void> {
await prisma.appConfig.upsert({
where: { key: MAIL_SETTINGS_KEY },
update: {
value: JSON.stringify(settings),
},
create: {
key: MAIL_SETTINGS_KEY,
value: JSON.stringify(settings),
},
});
}
export async function getContactProtectionSettings(): Promise<ContactProtectionSettings> {
try {
const config = await prisma.appConfig.findUnique({
where: { key: CONTACT_PROTECTION_SETTINGS_KEY },
select: { value: true },
});
return parseContactProtectionValue(config?.value);
} catch {
return buildDefaultContactProtectionSettings();
}
}
export async function getContactProtectionFormValues(): Promise<ContactProtectionFormValues> {
const settings = await getContactProtectionSettings();
return toContactProtectionFormValues(settings);
}
export async function getPublicContactProtectionSettings(): Promise<PublicContactProtectionSettings> {
const settings = await getContactProtectionSettings();
return toPublicContactProtectionSettings(settings);
}
export async function updateContactProtectionSettings(
settings: ContactProtectionSettings,
): Promise<void> {
await prisma.appConfig.upsert({
where: { key: CONTACT_PROTECTION_SETTINGS_KEY },
update: {
value: JSON.stringify(settings),
},
create: {
key: CONTACT_PROTECTION_SETTINGS_KEY,
value: JSON.stringify(settings),
},
});
}
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
try {
const usages = await prisma.mediaUsage.findMany({
+112
View File
@@ -0,0 +1,112 @@
import { createHash } from "crypto";
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
import {
CONTACT_RATE_LIMIT_KEY_PREFIX,
type ContactProtectionSettings,
} from "@/lib/contact-protection";
function getClientIpFromHeaders() {
const requestHeaders = headers();
const forwardedFor = requestHeaders.get("x-forwarded-for");
if (forwardedFor) {
return forwardedFor.split(",")[0]?.trim() || "unknown";
}
return requestHeaders.get("x-real-ip")?.trim() || "unknown";
}
function getRateLimitKey(ip: string, windowMinutes: number) {
const windowMs = windowMinutes * 60 * 1000;
const windowStart = Math.floor(Date.now() / windowMs) * windowMs;
const ipHash = createHash("sha256").update(ip).digest("hex");
return `${CONTACT_RATE_LIMIT_KEY_PREFIX}:${ipHash}:${windowStart}`;
}
function parseCount(rawValue: string | null | undefined) {
if (!rawValue) {
return 0;
}
const parsed = Number.parseInt(rawValue, 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
}
export async function enforceContactRateLimit(settings: ContactProtectionSettings) {
if (!settings.rateLimit.enabled) {
return;
}
const ip = getClientIpFromHeaders();
const key = getRateLimitKey(ip, settings.rateLimit.windowMinutes);
await prisma.$transaction(async (tx) => {
const current = await tx.appConfig.findUnique({
where: { key },
select: { value: true },
});
const nextCount = parseCount(current?.value) + 1;
if (nextCount > settings.rateLimit.maxRequests) {
throw new Error("Too many contact requests. Please try again later.");
}
await tx.appConfig.upsert({
where: { key },
update: {
value: String(nextCount),
},
create: {
key,
value: "1",
},
});
});
}
export async function verifyTurnstileToken(
settings: ContactProtectionSettings,
token: string,
) {
if (!settings.turnstile.enabled) {
return;
}
if (!settings.turnstile.siteKey || !settings.turnstile.secretKey) {
throw new Error("Turnstile is enabled but not fully configured.");
}
if (!token.trim()) {
throw new Error("Turnstile verification is required.");
}
const body = new URLSearchParams();
body.set("secret", settings.turnstile.secretKey);
body.set("response", token);
body.set("remoteip", getClientIpFromHeaders());
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
cache: "no-store",
});
if (!response.ok) {
throw new Error("Turnstile verification request failed.");
}
const result = await response.json() as {
success?: boolean;
};
if (!result.success) {
throw new Error("Turnstile verification failed.");
}
}
+132
View File
@@ -0,0 +1,132 @@
export const CONTACT_PROTECTION_SETTINGS_KEY = "contact_protection_settings";
export const CONTACT_RATE_LIMIT_KEY_PREFIX = "contact_rate_limit";
export type ContactProtectionSettings = {
turnstile: {
enabled: boolean;
siteKey: string;
secretKey: string;
};
rateLimit: {
enabled: boolean;
maxRequests: number;
windowMinutes: number;
};
};
export type ContactProtectionFormValues = {
turnstile: {
enabled: boolean;
siteKey: string;
secretKey: string;
hasSecretKey: boolean;
};
rateLimit: {
enabled: boolean;
maxRequests: number;
windowMinutes: number;
};
};
export type PublicContactProtectionSettings = {
turnstile: {
enabled: boolean;
siteKey: string;
};
rateLimit: {
enabled: boolean;
};
};
export function buildDefaultContactProtectionSettings(): ContactProtectionSettings {
return {
turnstile: {
enabled: false,
siteKey: "",
secretKey: "",
},
rateLimit: {
enabled: true,
maxRequests: 5,
windowMinutes: 10,
},
};
}
function parsePositiveInt(value: unknown, fallback: number) {
const parsed =
typeof value === "number"
? value
: typeof value === "string"
? Number.parseInt(value, 10)
: fallback;
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
export function parseContactProtectionValue(
rawValue: string | null | undefined,
): ContactProtectionSettings {
const defaults = buildDefaultContactProtectionSettings();
if (!rawValue) {
return defaults;
}
try {
const parsed = JSON.parse(rawValue) as Record<string, unknown>;
const turnstile = parsed.turnstile && typeof parsed.turnstile === "object"
? (parsed.turnstile as Record<string, unknown>)
: {};
const rateLimit = parsed.rateLimit && typeof parsed.rateLimit === "object"
? (parsed.rateLimit as Record<string, unknown>)
: {};
return {
turnstile: {
enabled: Boolean(turnstile.enabled),
siteKey: typeof turnstile.siteKey === "string" ? turnstile.siteKey.trim() : "",
secretKey: typeof turnstile.secretKey === "string" ? turnstile.secretKey : "",
},
rateLimit: {
enabled: rateLimit.enabled === undefined ? defaults.rateLimit.enabled : Boolean(rateLimit.enabled),
maxRequests: parsePositiveInt(rateLimit.maxRequests, defaults.rateLimit.maxRequests),
windowMinutes: parsePositiveInt(rateLimit.windowMinutes, defaults.rateLimit.windowMinutes),
},
};
} catch {
return defaults;
}
}
export function toContactProtectionFormValues(
settings: ContactProtectionSettings,
): ContactProtectionFormValues {
return {
turnstile: {
enabled: settings.turnstile.enabled,
siteKey: settings.turnstile.siteKey,
secretKey: "",
hasSecretKey: Boolean(settings.turnstile.secretKey),
},
rateLimit: {
enabled: settings.rateLimit.enabled,
maxRequests: settings.rateLimit.maxRequests,
windowMinutes: settings.rateLimit.windowMinutes,
},
};
}
export function toPublicContactProtectionSettings(
settings: ContactProtectionSettings,
): PublicContactProtectionSettings {
return {
turnstile: {
enabled: settings.turnstile.enabled && Boolean(settings.turnstile.siteKey),
siteKey: settings.turnstile.siteKey,
},
rateLimit: {
enabled: settings.rateLimit.enabled,
},
};
}
+133
View File
@@ -0,0 +1,133 @@
export const MAIL_SETTINGS_KEY = "mail_settings";
export type MailSettings = {
smtp: {
host: string;
port: number;
secure: boolean;
username: string;
password: string;
};
sender: {
email: string;
name: string;
};
recipients: {
contact: string;
test: string;
};
};
export type MailSettingsFormValues = {
smtp: {
host: string;
port: number;
secure: boolean;
username: string;
password: string;
hasPassword: boolean;
};
sender: {
email: string;
name: string;
};
recipients: {
contact: string;
test: string;
};
};
export function buildDefaultMailSettings(): MailSettings {
return {
smtp: {
host: "",
port: 587,
secure: false,
username: "",
password: "",
},
sender: {
email: "",
name: "",
},
recipients: {
contact: "",
test: "",
},
};
}
function normalizeMailSettings(input: unknown): MailSettings {
const value = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
const smtp = value.smtp && typeof value.smtp === "object"
? (value.smtp as Record<string, unknown>)
: {};
const sender = value.sender && typeof value.sender === "object"
? (value.sender as Record<string, unknown>)
: {};
const recipients = value.recipients && typeof value.recipients === "object"
? (value.recipients as Record<string, unknown>)
: {};
const defaults = buildDefaultMailSettings();
const parsedPort =
typeof smtp.port === "number"
? smtp.port
: typeof smtp.port === "string"
? Number.parseInt(smtp.port, 10)
: defaults.smtp.port;
return {
smtp: {
host: typeof smtp.host === "string" ? smtp.host.trim() : defaults.smtp.host,
port: Number.isFinite(parsedPort) && parsedPort > 0 ? parsedPort : defaults.smtp.port,
secure: Boolean(smtp.secure),
username: typeof smtp.username === "string" ? smtp.username.trim() : defaults.smtp.username,
password: typeof smtp.password === "string" ? smtp.password : defaults.smtp.password,
},
sender: {
email: typeof sender.email === "string" ? sender.email.trim() : defaults.sender.email,
name: typeof sender.name === "string" ? sender.name.trim() : defaults.sender.name,
},
recipients: {
contact:
typeof recipients.contact === "string"
? recipients.contact.trim()
: defaults.recipients.contact,
test:
typeof recipients.test === "string" ? recipients.test.trim() : defaults.recipients.test,
},
};
}
export function parseMailSettingsValue(rawValue: string | null | undefined): MailSettings {
if (!rawValue) {
return buildDefaultMailSettings();
}
try {
return normalizeMailSettings(JSON.parse(rawValue));
} catch {
return buildDefaultMailSettings();
}
}
export function toMailSettingsFormValues(settings: MailSettings): MailSettingsFormValues {
return {
smtp: {
host: settings.smtp.host,
port: settings.smtp.port,
secure: settings.smtp.secure,
username: settings.smtp.username,
password: "",
hasPassword: Boolean(settings.smtp.password),
},
sender: {
email: settings.sender.email,
name: settings.sender.name,
},
recipients: {
contact: settings.recipients.contact,
test: settings.recipients.test,
},
};
}
+172
View File
@@ -0,0 +1,172 @@
import nodemailer, { type SendMailOptions } from "nodemailer";
import { getMailSettings } from "@/lib/app-config";
import type { AppLocale } from "@/lib/locale";
import type { MailSettings } from "@/lib/mail-settings";
type MailTransportOptions = {
host: string;
port: number;
secure: boolean;
auth: {
user: string;
pass: string;
};
};
type MailTransport = {
sendMail: (options: SendMailOptions) => Promise<unknown>;
};
type CreateTransport = (options: MailTransportOptions) => MailTransport;
type SendMailDeps = {
settings?: MailSettings;
createTransport?: CreateTransport;
};
type SendMailInput = {
to: string;
subject: string;
text: string;
replyTo?: string;
};
type ContactMessageInput = {
name: string;
email: string;
phone?: string;
company?: string;
message: string;
locale: AppLocale;
};
function requireValue(value: string, message: string) {
if (!value.trim()) {
throw new Error(message);
}
return value.trim();
}
function getConfiguredTransportSettings(settings: MailSettings) {
const smtpHost = requireValue(settings.smtp.host, "SMTP host is required.");
const smtpUsername = requireValue(settings.smtp.username, "SMTP username is required.");
const smtpPassword = requireValue(settings.smtp.password, "SMTP password is required.");
const fromEmail = requireValue(settings.sender.email, "From email is required.");
return {
smtpHost,
smtpUsername,
smtpPassword,
fromEmail,
};
}
function getConfiguredRecipient(settings: MailSettings, recipientKey: "contact" | "test") {
if (recipientKey === "contact") {
return requireValue(
settings.recipients.contact || settings.recipients.test,
"Contact recipient email is required.",
);
}
return requireValue(
settings.recipients.test || settings.recipients.contact,
"Test recipient email is required.",
);
}
export function createSmtpTransport(
settings: MailSettings,
createTransport: CreateTransport = nodemailer.createTransport,
) {
const { smtpHost, smtpUsername, smtpPassword } = getConfiguredTransportSettings(settings);
return createTransport({
host: smtpHost,
port: settings.smtp.port,
secure: settings.smtp.secure,
auth: {
user: smtpUsername,
pass: smtpPassword,
},
});
}
export async function sendMail(
input: SendMailInput,
deps: SendMailDeps = {},
) {
const settings = deps.settings ?? await getMailSettings();
const { fromEmail } = getConfiguredTransportSettings(settings);
const transport = createSmtpTransport(settings, deps.createTransport);
await transport.sendMail({
from: settings.sender.name ? `${settings.sender.name} <${fromEmail}>` : fromEmail,
to: input.to,
subject: input.subject,
text: input.text,
replyTo: input.replyTo,
});
}
export async function sendContactMessage(
input: ContactMessageInput,
deps: SendMailDeps = {},
) {
const settings = deps.settings ?? await getMailSettings();
const recipient = getConfiguredRecipient(settings, "contact");
await sendMail(
{
to: recipient,
subject: "New contact message",
replyTo: input.email,
text: [
"A new contact message was submitted.",
"",
`Name: ${input.name}`,
`Email: ${input.email}`,
`Phone: ${input.phone?.trim() || "-"}`,
`Company: ${input.company?.trim() || "-"}`,
`Locale: ${input.locale}`,
`Submitted at: ${new Date().toISOString()}`,
"",
"Message:",
input.message,
].join("\n"),
},
{
settings,
createTransport: deps.createTransport,
},
);
}
export async function sendTestEmail(
deps: SendMailDeps = {},
) {
const settings = deps.settings ?? await getMailSettings();
const recipient = getConfiguredRecipient(settings, "test");
const { fromEmail } = getConfiguredTransportSettings(settings);
await sendMail(
{
to: recipient,
subject: "SMTP test email",
text: [
"This is a backend SMTP test email.",
"",
`Timestamp: ${new Date().toISOString()}`,
`Configured from email: ${fromEmail}`,
`Configured recipient: ${recipient}`,
`Environment: ${process.env.NODE_ENV ?? "development"}`,
].join("\n"),
},
{
settings,
createTransport: deps.createTransport,
},
);
}
+26 -1
View File
@@ -3,6 +3,7 @@ import {
Globe2,
ImageIcon,
LayoutDashboard,
Mail,
PlusSquare,
ShieldAlert,
SwatchBook,
@@ -17,6 +18,8 @@ type RootNavigationCopy = {
portfolio: string;
media: string;
siteSettings: string;
smtp?: string;
contactProtection?: string;
};
export type RootNavItem = {
@@ -30,7 +33,8 @@ export type RootNavItem = {
export function getRootNavigation(
copy: RootNavigationCopy,
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings",
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp",
smtpChild?: "settings" | "contact-protection",
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
): RootNavItem[] {
return [
@@ -64,6 +68,27 @@ export function getRootNavigation(
icon: Globe2,
active: active === "site-settings",
},
{
label: copy.smtp ?? "SMTP",
href: "/root/smtp",
icon: Mail,
active: active === "smtp" && !smtpChild,
expanded: active === "smtp",
children: [
{
label: copy.smtp ?? "SMTP",
href: "/root/smtp",
icon: Mail,
active: smtpChild === "settings" || (!smtpChild && active === "smtp"),
},
{
label: copy.contactProtection ?? "Contact Protection",
href: "/root/smtp/contact-protection",
icon: ShieldAlert,
active: smtpChild === "contact-protection",
},
],
},
{
label: copy.portfolio,
href: "/root/portfolio",