feat(mail): add smtp email delivery
This commit is contained in:
parent
faddaa91d0
commit
3456b04ed4
25 changed files with 529 additions and 60 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { mkdir, readFile, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { dataDirectory } from "@/lib/runtime/config";
|
||||
import type { ContactInquiry, InquiryStatus, RepairInquiry, SiteSettings, SmtpSettings } from "./types";
|
||||
import { configDirectory, dataDirectory } from "@/lib/runtime/config";
|
||||
import type { ContactInquiry, InquiryStatus, RepairInquiry, SiteSettings } from "./types";
|
||||
|
||||
async function ensureDataDir() {
|
||||
await mkdir(dataDirectory, { recursive: true });
|
||||
|
|
@ -112,26 +112,8 @@ export async function saveSiteSettings(form: FormData) {
|
|||
return settings;
|
||||
}
|
||||
|
||||
export async function getSmtpSettings() {
|
||||
return readJson<SmtpSettings>("smtp-settings.json", {
|
||||
host: "",
|
||||
port: "587",
|
||||
username: "",
|
||||
fromAddress: "",
|
||||
replyToAddress: "",
|
||||
tls: true,
|
||||
});
|
||||
export async function ensureConfigDir() {
|
||||
await mkdir(configDirectory, { recursive: true });
|
||||
}
|
||||
|
||||
export async function saveSmtpSettings(form: FormData) {
|
||||
const settings: SmtpSettings = {
|
||||
host: text(form.get("host")),
|
||||
port: text(form.get("port")),
|
||||
username: text(form.get("username")),
|
||||
fromAddress: text(form.get("fromAddress")),
|
||||
replyToAddress: text(form.get("replyToAddress")),
|
||||
tls: form.get("tls") === "on",
|
||||
};
|
||||
await writeJson("smtp-settings.json", settings);
|
||||
return settings;
|
||||
}
|
||||
export { text };
|
||||
|
|
|
|||
|
|
@ -42,7 +42,15 @@ export type SmtpSettings = {
|
|||
host: string;
|
||||
port: string;
|
||||
username: string;
|
||||
password?: string;
|
||||
security: "starttls" | "tls" | "none";
|
||||
fromAddress: string;
|
||||
replyToAddress: string;
|
||||
tls: boolean;
|
||||
recipientAddress: string;
|
||||
bccAddress?: string;
|
||||
lastTestAt?: string;
|
||||
lastTestStatus?: "success" | "error";
|
||||
lastDeliveryAt?: string;
|
||||
lastDeliveryStatus?: "success" | "error";
|
||||
lastError?: string;
|
||||
};
|
||||
|
|
|
|||
109
lib/mail/config.ts
Normal file
109
lib/mail/config.ts
Normal 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
100
lib/mail/smtp.ts
Normal 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
74
lib/mail/templates.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import type { ContactMailInput, RepairMailInput } from "./types";
|
||||
|
||||
function escapeHtml(value?: string) {
|
||||
return (value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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
13
lib/mail/types.ts
Normal 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;
|
||||
|
|
@ -5,6 +5,7 @@ import { appVersion } from "./version";
|
|||
|
||||
export const dataDirectory = path.join(process.cwd(), "data");
|
||||
export const storageDirectory = path.join(process.cwd(), "storage");
|
||||
export const configDirectory = path.join(storageDirectory, "config");
|
||||
export const uploadDirectory = path.join(storageDirectory, "uploads", "images");
|
||||
export const legacyPublicUploadDirectory = path.join(process.cwd(), "public", "uploads", "images");
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ export function isDockerEnvironment() {
|
|||
|
||||
export async function ensureRuntimeDirectories() {
|
||||
await mkdir(dataDirectory, { recursive: true });
|
||||
await mkdir(configDirectory, { recursive: true });
|
||||
await mkdir(uploadDirectory, { recursive: true });
|
||||
await migrateLegacyUploads();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export const appVersion = "0.2.2";
|
||||
export const appVersion = "0.3.0";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue