feat(mail): add smtp email delivery

This commit is contained in:
Schubert Ferenc 2026-07-04 00:06:20 +02:00
parent faddaa91d0
commit 3456b04ed4
25 changed files with 529 additions and 60 deletions

109
lib/mail/config.ts Normal file
View file

@ -0,0 +1,109 @@
import { mkdir, readFile, writeFile } from "fs/promises";
import path from "path";
import { configDirectory } from "@/lib/runtime/config";
import type { SmtpSettings } from "@/lib/admin/types";
import type { PublicSmtpSettings } from "./types";
const smtpConfigFile = path.join(configDirectory, "smtp.json");
const defaultSmtpSettings: SmtpSettings = {
host: "",
port: "587",
username: "",
password: "",
security: "starttls",
fromAddress: "",
replyToAddress: "",
recipientAddress: "",
bccAddress: "",
};
function text(value: FormDataEntryValue | null) {
return typeof value === "string" ? value.trim() : "";
}
function normalizeSecurity(value: string): SmtpSettings["security"] {
if (value === "tls" || value === "none") return value;
return "starttls";
}
async function ensureConfigDirectory() {
await mkdir(configDirectory, { recursive: true });
}
export async function getSmtpSettings(): Promise<SmtpSettings> {
await ensureConfigDirectory();
try {
const file = await readFile(smtpConfigFile, "utf8");
return { ...defaultSmtpSettings, ...JSON.parse(file) as SmtpSettings };
} catch {
return defaultSmtpSettings;
}
}
export function toPublicSmtpSettings(settings: SmtpSettings): PublicSmtpSettings {
return {
host: settings.host,
port: settings.port,
username: settings.username,
security: settings.security,
fromAddress: settings.fromAddress,
replyToAddress: settings.replyToAddress,
recipientAddress: settings.recipientAddress,
bccAddress: settings.bccAddress,
lastTestAt: settings.lastTestAt,
lastTestStatus: settings.lastTestStatus,
lastError: settings.lastError,
hasPassword: Boolean(settings.password),
};
}
export function isSmtpConfigured(settings: SmtpSettings) {
return Boolean(
settings.host &&
settings.port &&
settings.username &&
settings.password &&
settings.fromAddress &&
settings.recipientAddress,
);
}
export async function saveSmtpSettings(form: FormData) {
const current = await getSmtpSettings();
const nextPassword = text(form.get("password"));
const settings: SmtpSettings = {
...current,
host: text(form.get("host")),
port: text(form.get("port")) || "587",
username: text(form.get("username")),
password: nextPassword || current.password || "",
security: normalizeSecurity(text(form.get("security"))),
fromAddress: text(form.get("fromAddress")),
replyToAddress: text(form.get("replyToAddress")),
recipientAddress: text(form.get("recipientAddress")),
bccAddress: text(form.get("bccAddress")),
};
await ensureConfigDirectory();
await writeFile(smtpConfigFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
return settings;
}
export async function updateSmtpTestStatus(status: Pick<SmtpSettings, "lastTestAt" | "lastTestStatus" | "lastError">) {
const current = await getSmtpSettings();
const settings: SmtpSettings = { ...current, ...status };
await ensureConfigDirectory();
await writeFile(smtpConfigFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
return settings;
}
export async function updateSmtpDeliveryStatus(status: Pick<SmtpSettings, "lastDeliveryAt" | "lastDeliveryStatus" | "lastError">) {
const current = await getSmtpSettings();
const settings: SmtpSettings = { ...current, ...status };
await ensureConfigDirectory();
await writeFile(smtpConfigFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
return settings;
}
export { smtpConfigFile };

100
lib/mail/smtp.ts Normal file
View file

@ -0,0 +1,100 @@
import nodemailer from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
import type { SmtpSettings } from "@/lib/admin/types";
import { getSmtpSettings, isSmtpConfigured, updateSmtpDeliveryStatus, updateSmtpTestStatus } from "./config";
import { contactTemplate, repairTemplate, testTemplate } from "./templates";
import type { ContactMailInput, MailSendResult, RepairMailInput } from "./types";
function sanitizeMailError(error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter SMTP-Fehler";
return message
.replace(/AUTH PLAIN\s+\S+/gi, "AUTH PLAIN [redacted]")
.replace(/AUTH LOGIN\s+\S+/gi, "AUTH LOGIN [redacted]")
.replace(/password[=:]\S+/gi, "password=[redacted]")
.slice(0, 500);
}
function createTransport(settings: SmtpSettings) {
const secure = settings.security === "tls";
const requireTLS = settings.security === "starttls";
const options: SMTPTransport.Options = {
host: settings.host,
port: Number(settings.port),
secure,
requireTLS,
auth: {
user: settings.username,
pass: settings.password,
},
};
if (settings.security === "none") {
options.auth = settings.username && settings.password ? options.auth : undefined;
options.ignoreTLS = true;
}
return nodemailer.createTransport(options);
}
async function sendConfiguredMail(input: {
subject: string;
text: string;
html: string;
replyTo?: string;
}): Promise<MailSendResult> {
const settings = await getSmtpSettings();
if (!isSmtpConfigured(settings)) return { ok: false, error: "SMTP nicht konfiguriert." };
try {
const transport = createTransport(settings);
await transport.sendMail({
from: settings.fromAddress,
to: settings.recipientAddress,
bcc: settings.bccAddress || undefined,
replyTo: input.replyTo || settings.replyToAddress || undefined,
subject: input.subject,
text: input.text,
html: input.html,
});
return { ok: true };
} catch (error) {
return { ok: false, error: sanitizeMailError(error) };
}
}
export async function sendTestMail() {
const template = testTemplate();
const result = await sendConfiguredMail(template);
await updateSmtpTestStatus({
lastTestAt: new Date().toISOString(),
lastTestStatus: result.ok ? "success" : "error",
lastError: result.ok ? "" : result.error,
});
return result;
}
export async function sendContactInquiryMail(input: ContactMailInput) {
const template = contactTemplate(input);
const result = await sendConfiguredMail({ ...template, replyTo: input.email });
if (result.error !== "SMTP nicht konfiguriert.") {
await updateSmtpDeliveryStatus({
lastDeliveryAt: new Date().toISOString(),
lastDeliveryStatus: result.ok ? "success" : "error",
lastError: result.ok ? "" : result.error,
});
}
return result;
}
export async function sendRepairInquiryMail(input: RepairMailInput) {
const template = repairTemplate(input);
const result = await sendConfiguredMail({ ...template, replyTo: input.email });
if (result.error !== "SMTP nicht konfiguriert.") {
await updateSmtpDeliveryStatus({
lastDeliveryAt: new Date().toISOString(),
lastDeliveryStatus: result.ok ? "success" : "error",
lastError: result.ok ? "" : result.error,
});
}
return result;
}

74
lib/mail/templates.ts Normal file
View file

@ -0,0 +1,74 @@
import type { ContactMailInput, RepairMailInput } from "./types";
function escapeHtml(value?: string) {
return (value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function rows(entries: Array<[string, string | undefined]>) {
return entries
.filter(([, value]) => value)
.map(([label, value]) => `<tr><th align="left" style="padding:6px 12px;border-bottom:1px solid #d9dee6;">${escapeHtml(label)}</th><td style="padding:6px 12px;border-bottom:1px solid #d9dee6;">${escapeHtml(value)}</td></tr>`)
.join("");
}
function textRows(entries: Array<[string, string | undefined]>) {
return entries
.filter(([, value]) => value)
.map(([label, value]) => `${label}: ${value}`)
.join("\n");
}
export function contactTemplate(input: ContactMailInput) {
const subject = `Kontaktanfrage: ${input.subject ?? "Funktechnik Schubert"}`;
const entries: Array<[string, string | undefined]> = [
["Datum", new Date(input.createdAt).toLocaleString("de-DE")],
["Name", input.name],
["E-Mail", input.email],
["Telefon", input.phone],
["Betreff", input.subject],
["Nachricht", input.message],
];
return {
subject,
text: `Neue Kontaktanfrage\n\n${textRows(entries)}`,
html: `<h1>Neue Kontaktanfrage</h1><table cellspacing="0" cellpadding="0">${rows(entries)}</table>`,
};
}
export function repairTemplate(input: RepairMailInput) {
const subject = `Reparaturanfrage: ${input.manufacturer} ${input.model}`;
const entries: Array<[string, string | undefined]> = [
["Datum", new Date(input.createdAt).toLocaleString("de-DE")],
["Name", input.name],
["E-Mail", input.email],
["Telefon", input.phone],
["Hersteller", input.manufacturer],
["Modell", input.model],
["Geräteart", input.deviceType],
["Seriennummer", input.serialNumber],
["Fehlerbeschreibung", input.description],
["Zubehör", input.accessories],
["Gerät geöffnet?", input.opened],
["Vorarbeiten", input.previousWork],
];
return {
subject,
text: `Neue Reparaturanfrage\n\n${textRows(entries)}`,
html: `<h1>Neue Reparaturanfrage</h1><table cellspacing="0" cellpadding="0">${rows(entries)}</table>`,
};
}
export function testTemplate() {
return {
subject: "SMTP Testmail - Funktechnik Schubert",
text: "Diese Testmail wurde aus der Funktechnik Schubert Website-Administration gesendet.",
html: "<h1>SMTP Testmail</h1><p>Diese Testmail wurde aus der Funktechnik Schubert Website-Administration gesendet.</p>",
};
}

13
lib/mail/types.ts Normal file
View file

@ -0,0 +1,13 @@
import type { ContactInquiry, RepairInquiry, SmtpSettings } from "@/lib/admin/types";
export type PublicSmtpSettings = Omit<SmtpSettings, "password"> & {
hasPassword: boolean;
};
export type MailSendResult = {
ok: boolean;
error?: string;
};
export type ContactMailInput = ContactInquiry;
export type RepairMailInput = RepairInquiry;