Add SMTP admin settings and contact form delivery
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user