diff --git a/.dockerignore b/.dockerignore index fb3f6f5..145fa92 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,8 @@ npm-debug.log* README.md data/*.json storage/uploads/images/* +storage/config/* +!storage/config/.gitkeep !storage/uploads/images/.gitkeep *.tmp *.temp diff --git a/.gitignore b/.gitignore index cbe8f57..204a542 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,9 @@ data/repair-inquiries.json data/site-settings.json data/smtp-settings.json storage/uploads/images/* +storage/config/* !storage/.gitkeep +!storage/config/.gitkeep !storage/uploads/.gitkeep !storage/uploads/images/.gitkeep .DS_Store diff --git a/Dockerfile b/Dockerfile index 60c8b8b..c0bb863 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/scripts/runtime-start.js ./runtime-start.js -RUN mkdir -p /app/data /app/storage/uploads/images /app/.next/cache \ +RUN mkdir -p /app/data /app/storage/config /app/storage/uploads/images /app/.next/cache \ && chown -R nextjs:nodejs /app/data /app/storage /app/.next/cache USER nextjs diff --git a/README.md b/README.md index 329ed24..4c9a96d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Eigenständige öffentliche Firmenwebsite für Funktechnik Schubert. Dieses Proj - Nginx/Reverse-Proxy-fähig - SEO Metadata, Sitemap und robots.txt -Version: `0.2.2` +Version: `0.3.0` ## Seiten @@ -140,12 +140,12 @@ Funktionen: - Reparaturanfragen verwalten - Website-Inhaltsverwaltung vorbereitet - Firmendaten pflegen -- SMTP-Konfiguration vorbereiten +- SMTP-Konfiguration speichern und Testmail senden - Medien hochladen - SEO-Übersicht - Systemübersicht -SMTP-Versand, Publishing von Website-Inhalten und Olympus-Übernahme sind bewusst noch nicht aktiv gekoppelt. +Publishing von Website-Inhalten und Olympus-Übernahme sind bewusst noch nicht aktiv gekoppelt. SMTP-Versand fuer Kontakt- und Reparaturanfragen ist serverseitig angebunden, sofern `/admin/smtp` vollstaendig konfiguriert ist. ## Runtime Data @@ -163,9 +163,10 @@ Echte Runtime-Dateien werden nicht committed: data/contact-inquiries.json data/repair-inquiries.json data/site-settings.json -data/smtp-settings.json ``` +SMTP-Konfiguration wird unter `storage/config/smtp.json` gespeichert und nicht committed. Das SMTP-Passwort wird nicht im Admin-Formular ausgegeben. + Uploads liegen unter `storage/uploads/images/`, werden über `/api/media/[filename]` ausgeliefert und nicht committed. Alte Uploads aus `public/uploads/images` werden beim Start einmalig nach `storage/uploads/images` migriert, falls sie dort noch nicht vorhanden sind. @@ -187,13 +188,50 @@ Antwort: ```json { "status": "ok", - "version": "0.2.2", + "version": "0.3.0", "storage": "ok", "admin": "configured", + "smtp": "configured", "timestamp": "..." } ``` +## SMTP + +SMTP wird im Adminbereich unter `/admin/smtp` konfiguriert. Die Konfiguration wird persistent unter `storage/config/smtp.json` gespeichert und liegt damit im Docker-Storage-Volume, nicht in `public/`. + +Pflichtfelder: + +- SMTP Host +- SMTP Port +- SMTP Benutzername +- SMTP Passwort +- Verschlüsselung +- Absenderadresse +- Empfängeradresse + +Für Apple Mail/iCloud Mail: + +```text +Host: smtp.mail.me.com +Port: 587 +Verschlüsselung: STARTTLS +Benutzername: vollständige E-Mail-Adresse +Passwort: app-spezifisches Passwort +``` + +Nicht das normale Apple-ID-Passwort verwenden. In der Apple-ID-Verwaltung ein app-spezifisches Passwort erzeugen und dieses als SMTP-Passwort speichern. + +Nach dem Speichern kann im Adminbereich eine Testmail gesendet werden. Kontakt- und Reparaturanfragen werden weiterhin lokal gespeichert. Wenn SMTP konfiguriert ist, wird zusaetzlich eine E-Mail an die konfigurierte Empfaengeradresse gesendet. Schlaegt der Mailversand fehl, bleibt die Anfrage gespeichert; der Besucher sieht keine technische Fehlermeldung. + +Troubleshooting: + +- Host, Port und Verschlüsselung prüfen +- bei Apple Mail STARTTLS und Port 587 verwenden +- vollständige E-Mail-Adresse als Benutzername verwenden +- app-spezifisches Passwort neu erzeugen +- letzte Testmail und letzte Fehlermeldung unter `/admin/smtp` prüfen + ## Deployment ```bash @@ -259,6 +297,5 @@ certbot --nginx -d funktechnik-schubert.de -d www.funktechnik-schubert.de ## Offene manuelle Punkte - TODO: Rechtliche Angaben ergänzen -- Produktives Kontakt-/Mail-System anbinden - Spätere Olympus-Reparaturannahme serverseitig anbinden - Finale Domain und HTTPS-Konfiguration setzen diff --git a/app/admin/page.tsx b/app/admin/page.tsx index b479b0d..9142ef2 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; import { requireAdminSession } from "@/lib/admin/auth"; import { getContactInquiries, getRepairInquiries } from "@/lib/admin/store"; +import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config"; function countNew(items: T[]) { return items.filter((item) => item.status === "new").length; @@ -9,7 +10,7 @@ function countNew(items: T[]) { export default async function AdminDashboardPage() { if (!await requireAdminSession()) redirect("/admin/login"); - const [contacts, repairs] = await Promise.all([getContactInquiries(), getRepairInquiries()]); + const [contacts, repairs, smtpSettings] = await Promise.all([getContactInquiries(), getRepairInquiries(), getSmtpSettings()]); const latest = [...contacts, ...repairs].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 6); return ( @@ -22,7 +23,7 @@ export default async function AdminDashboardPage() {
Kontaktanfragen{countNew(contacts)}neu
Reparaturanfragen{countNew(repairs)}neu
WebsiteOnlineOK
-
SMTPVorbereitetnicht aktiv
+
SMTP{isSmtpConfigured(smtpSettings) ? "OK" : "Fehlt"}{smtpSettings.lastTestStatus ?? "kein Test"}

Letzte Anfragen

diff --git a/app/admin/smtp/page.tsx b/app/admin/smtp/page.tsx index ae13637..662eeb9 100644 --- a/app/admin/smtp/page.tsx +++ b/app/admin/smtp/page.tsx @@ -1,7 +1,8 @@ import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; +import SmtpSettingsForm from "@/components/admin/SmtpSettingsForm"; import { requireAdminSession } from "@/lib/admin/auth"; -import { getSmtpSettings } from "@/lib/admin/store"; +import { getSmtpSettings, isSmtpConfigured, toPublicSmtpSettings } from "@/lib/mail/config"; export default async function AdminSmtpPage() { if (!await requireAdminSession()) redirect("/admin/login"); @@ -13,16 +14,7 @@ export default async function AdminSmtpPage() {

E-Mail

SMTP Einstellungen

-
- - - - - - - - -
+ ); } diff --git a/app/admin/system/page.tsx b/app/admin/system/page.tsx index d71e400..c6f59ff 100644 --- a/app/admin/system/page.tsx +++ b/app/admin/system/page.tsx @@ -1,7 +1,8 @@ import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; import { requireAdminSession } from "@/lib/admin/auth"; -import { adminConfigurationStatus, appVersion, checkStorage, dataDirectory, isDockerEnvironment, olympusStatus, smtpStatus, uploadDirectory } from "@/lib/runtime/config"; +import { adminConfigurationStatus, appVersion, checkStorage, configDirectory, dataDirectory, isDockerEnvironment, olympusStatus, uploadDirectory } from "@/lib/runtime/config"; +import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config"; export default async function AdminSystemPage() { if (!await requireAdminSession()) redirect("/admin/login"); @@ -12,6 +13,8 @@ export default async function AdminSystemPage() { } catch { storage = "error"; } + const smtpSettings = await getSmtpSettings(); + const smtpConfigured = isSmtpConfigured(smtpSettings); return ( @@ -27,10 +30,13 @@ export default async function AdminSystemPage() {
Docker Environment
{isDockerEnvironment() ? "ja" : "nein"}
Storage Status
{storage}
Data Directory
{dataDirectory}
+
Config Directory
{configDirectory}
Upload Directory
{uploadDirectory}
Admin Konfiguration
{adminConfigurationStatus()}
Olympus Verbindung
{olympusStatus()}
-
SMTP
{smtpStatus()}
+
SMTP
{smtpConfigured ? "configured" : "missing"}
+
Letzte SMTP-Testmail
{smtpSettings.lastTestAt ? `${new Date(smtpSettings.lastTestAt).toLocaleString("de-DE")} (${smtpSettings.lastTestStatus})` : "keine"}
+
Letzter SMTP-Formularversand
{smtpSettings.lastDeliveryAt ? `${new Date(smtpSettings.lastDeliveryAt).toLocaleString("de-DE")} (${smtpSettings.lastDeliveryStatus})` : "keiner"}
@@ -38,7 +44,7 @@ export default async function AdminSystemPage() {
  • Runtime-Daten liegen unter data/ und werden nicht versioniert.
  • Uploads liegen unter storage/uploads/images/ und werden nicht versioniert.
  • -
  • Olympus- und SMTP-Integration sind vorbereitet, aber nicht aktiv implementiert.
  • +
  • SMTP-Versand ist serverseitig aktiv, sofern eine vollständige Konfiguration gespeichert ist.
diff --git a/app/api/admin/smtp/route.ts b/app/api/admin/smtp/route.ts index ea05bb2..5b4f368 100644 --- a/app/api/admin/smtp/route.ts +++ b/app/api/admin/smtp/route.ts @@ -1,9 +1,26 @@ import { NextResponse } from "next/server"; import { requireAdminSession } from "@/lib/admin/auth"; -import { saveSmtpSettings } from "@/lib/admin/store"; +import { saveSmtpSettings, toPublicSmtpSettings } from "@/lib/mail/config"; +import { sendTestMail } from "@/lib/mail/smtp"; export async function POST(request: Request) { if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); - await saveSmtpSettings(await request.formData()); - return new NextResponse(null, { status: 303, headers: { Location: "/admin/smtp?saved=1" } }); + const form = await request.formData(); + const action = String(form.get("action") ?? "save"); + + if (action === "test") { + const settings = await saveSmtpSettings(form); + const result = await sendTestMail(); + return NextResponse.json({ + message: result.ok ? "Test-E-Mail wurde gesendet." : result.error ?? "Test-E-Mail fehlgeschlagen.", + ok: result.ok, + settings: toPublicSmtpSettings(settings), + }, { status: result.ok ? 200 : 400 }); + } + + const settings = await saveSmtpSettings(form); + return NextResponse.json({ + message: "SMTP-Konfiguration gespeichert.", + settings: toPublicSmtpSettings(settings), + }); } diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index ee384f7..07555e4 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { addContactInquiry } from "@/lib/admin/store"; +import { sendContactInquiryMail } from "@/lib/mail/smtp"; function text(value: FormDataEntryValue | null) { return typeof value === "string" ? value.trim() : ""; @@ -16,7 +17,8 @@ export async function POST(request: Request) { return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 }); } - await addContactInquiry(form); + const inquiry = await addContactInquiry(form); + await sendContactInquiryMail(inquiry); return NextResponse.json({ message: "Ihre Nachricht wurde erfasst. Sie erhalten eine Rückmeldung.", diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 30791a0..68745af 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { adminConfigurationStatus, appVersion, checkStorage } from "@/lib/runtime/config"; +import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config"; export async function GET() { let storage = "ok"; @@ -10,11 +11,14 @@ export async function GET() { storage = "error"; } + const smtpSettings = await getSmtpSettings(); + return NextResponse.json({ status: storage === "ok" ? "ok" : "error", version: appVersion, storage, admin: adminConfigurationStatus(), + smtp: isSmtpConfigured(smtpSettings) ? "configured" : "missing", timestamp: new Date().toISOString(), }); } diff --git a/app/api/repair/route.ts b/app/api/repair/route.ts index 6b98806..5fb6c1b 100644 --- a/app/api/repair/route.ts +++ b/app/api/repair/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { addRepairInquiry } from "@/lib/admin/store"; +import { sendRepairInquiryMail } from "@/lib/mail/smtp"; function required(value: FormDataEntryValue | null) { return typeof value === "string" && value.trim().length > 0; @@ -19,7 +20,8 @@ export async function POST(request: Request) { return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 }); } - await addRepairInquiry(form); + const inquiry = await addRepairInquiry(form); + await sendRepairInquiryMail(inquiry); return NextResponse.json({ message: "Ihre Reparaturanfrage wurde erfasst. Sie erhalten nach Prüfung eine Rückmeldung.", diff --git a/components/admin/SmtpSettingsForm.tsx b/components/admin/SmtpSettingsForm.tsx new file mode 100644 index 0000000..359cdae --- /dev/null +++ b/components/admin/SmtpSettingsForm.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useRef, useState, type FormEvent } from "react"; +import type { PublicSmtpSettings } from "@/lib/mail/types"; + +type ApiResponse = { + message?: string; + ok?: boolean; + settings?: PublicSmtpSettings; +}; + +export default function SmtpSettingsForm({ initialSettings, configured }: { initialSettings: PublicSmtpSettings; configured: boolean }) { + const formRef = useRef(null); + const [settings, setSettings] = useState(initialSettings); + const [isConfigured, setIsConfigured] = useState(configured); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + async function submitForm(form: HTMLFormElement, action: "save" | "test") { + setMessage(""); + setError(""); + setPending(true); + + const formData = new FormData(form); + formData.set("action", action); + + try { + const response = await fetch("/api/admin/smtp", { method: "POST", body: formData }); + const result = await response.json() as ApiResponse; + if (result.settings) { + setSettings(result.settings); + setIsConfigured(Boolean(result.settings.host && result.settings.username && result.settings.hasPassword && result.settings.fromAddress && result.settings.recipientAddress)); + } + if (!response.ok) throw new Error(result.message ?? "SMTP-Aktion fehlgeschlagen."); + setMessage(result.message ?? "Aktion erfolgreich."); + } catch (err) { + setError(err instanceof Error ? err.message : "SMTP-Aktion fehlgeschlagen."); + } finally { + setPending(false); + } + } + + function submit(event: FormEvent) { + event.preventDefault(); + void submitForm(event.currentTarget, "save"); + } + + function test() { + if (formRef.current) void submitForm(formRef.current, "test"); + } + + return ( + <> +
+

Status

+

SMTP ist {isConfigured ? "konfiguriert" : "nicht konfiguriert"}.

+ {settings.lastTestAt &&

Letzte Testmail: {new Date(settings.lastTestAt).toLocaleString("de-DE")} ({settings.lastTestStatus})

} + {settings.lastDeliveryAt &&

Letzter Formularversand: {new Date(settings.lastDeliveryAt).toLocaleString("de-DE")} ({settings.lastDeliveryStatus})

} + {settings.lastError &&

Letzter Fehler: {settings.lastError}

} +

Für Apple Mail/iCloud bitte smtp.mail.me.com, Port 587, STARTTLS und ein app-spezifisches Passwort verwenden.

+
+ + {message &&

{message}

} + {error &&

{error}

} + +
+ + + + + + + + + +
+ + +
+
+ + ); +} diff --git a/docker-compose.yml b/docker-compose.yml index 4b670a7..b31f6ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: - "3010:3010" environment: NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://127.0.0.1:3010} - NEXT_PUBLIC_APP_VERSION: "0.2.2" + NEXT_PUBLIC_APP_VERSION: "0.3.0" ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-} diff --git a/lib/admin/store.ts b/lib/admin/store.ts index 02757a8..28182c2 100644 --- a/lib/admin/store.ts +++ b/lib/admin/store.ts @@ -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("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 }; diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 11ae88b..3411cde 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -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; }; diff --git a/lib/mail/config.ts b/lib/mail/config.ts new file mode 100644 index 0000000..c08a6ff --- /dev/null +++ b/lib/mail/config.ts @@ -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 { + 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) { + 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) { + 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 }; diff --git a/lib/mail/smtp.ts b/lib/mail/smtp.ts new file mode 100644 index 0000000..4c3925a --- /dev/null +++ b/lib/mail/smtp.ts @@ -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 { + 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; +} diff --git a/lib/mail/templates.ts b/lib/mail/templates.ts new file mode 100644 index 0000000..fd1ba44 --- /dev/null +++ b/lib/mail/templates.ts @@ -0,0 +1,74 @@ +import type { ContactMailInput, RepairMailInput } from "./types"; + +function escapeHtml(value?: string) { + return (value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function rows(entries: Array<[string, string | undefined]>) { + return entries + .filter(([, value]) => value) + .map(([label, value]) => `${escapeHtml(label)}${escapeHtml(value)}`) + .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: `

Neue Kontaktanfrage

${rows(entries)}
`, + }; +} + +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: `

Neue Reparaturanfrage

${rows(entries)}
`, + }; +} + +export function testTemplate() { + return { + subject: "SMTP Testmail - Funktechnik Schubert", + text: "Diese Testmail wurde aus der Funktechnik Schubert Website-Administration gesendet.", + html: "

SMTP Testmail

Diese Testmail wurde aus der Funktechnik Schubert Website-Administration gesendet.

", + }; +} diff --git a/lib/mail/types.ts b/lib/mail/types.ts new file mode 100644 index 0000000..c9448bb --- /dev/null +++ b/lib/mail/types.ts @@ -0,0 +1,13 @@ +import type { ContactInquiry, RepairInquiry, SmtpSettings } from "@/lib/admin/types"; + +export type PublicSmtpSettings = Omit & { + hasPassword: boolean; +}; + +export type MailSendResult = { + ok: boolean; + error?: string; +}; + +export type ContactMailInput = ContactInquiry; +export type RepairMailInput = RepairInquiry; diff --git a/lib/runtime/config.ts b/lib/runtime/config.ts index 42eab92..84f0f29 100644 --- a/lib/runtime/config.ts +++ b/lib/runtime/config.ts @@ -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(); } diff --git a/lib/runtime/version.ts b/lib/runtime/version.ts index e8563d7..dcc838e 100644 --- a/lib/runtime/version.ts +++ b/lib/runtime/version.ts @@ -1 +1 @@ -export const appVersion = "0.2.2"; +export const appVersion = "0.3.0"; diff --git a/package-lock.json b/package-lock.json index 01bef7c..bed248b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "funktechnik-schubert-website", - "version": "0.2.2", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "funktechnik-schubert-website", - "version": "0.2.2", + "version": "0.3.0", "dependencies": { + "@types/nodemailer": "^8.0.1", "next": "16.2.10", + "nodemailer": "^9.0.3", "react": "19.2.3", "react-dom": "19.2.3" }, @@ -1343,12 +1345,20 @@ "version": "24.13.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -4669,6 +4679,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5912,7 +5931,6 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { diff --git a/package.json b/package.json index 23eedb3..b917003 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "funktechnik-schubert-website", - "version": "0.2.2", + "version": "0.3.0", "private": true, "scripts": { "dev": "next dev -p 3010", @@ -9,7 +9,9 @@ "lint": "eslint" }, "dependencies": { + "@types/nodemailer": "^8.0.1", "next": "16.2.10", + "nodemailer": "^9.0.3", "react": "19.2.3", "react-dom": "19.2.3" }, diff --git a/scripts/runtime-start.js b/scripts/runtime-start.js index c9c835f..805ae13 100644 --- a/scripts/runtime-start.js +++ b/scripts/runtime-start.js @@ -5,6 +5,7 @@ const path = require("path"); const requiredEnv = ["ADMIN_EMAIL", "ADMIN_PASSWORD", "ADMIN_SESSION_SECRET"]; const requiredDirs = [ path.join(process.cwd(), "data"), + path.join(process.cwd(), "storage", "config"), path.join(process.cwd(), "storage", "uploads", "images"), path.join(process.cwd(), ".next", "cache"), ]; @@ -53,5 +54,5 @@ try { fail(`Start abgebrochen: Runtime-Verzeichnis ist nicht beschreibbar. Details: ${error instanceof Error ? error.message : "unbekannter Fehler"}`); } -process.stdout.write(`[funktechnik-website] Runtime checks ok. Starting version ${process.env.NEXT_PUBLIC_APP_VERSION ?? "0.2.2"}.\n`); +process.stdout.write(`[funktechnik-website] Runtime checks ok. Starting version ${process.env.NEXT_PUBLIC_APP_VERSION ?? "0.3.0"}.\n`); require("./server.js"); diff --git a/storage/config/.gitkeep b/storage/config/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/storage/config/.gitkeep @@ -0,0 +1 @@ +