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

View file

@ -7,6 +7,8 @@ npm-debug.log*
README.md README.md
data/*.json data/*.json
storage/uploads/images/* storage/uploads/images/*
storage/config/*
!storage/config/.gitkeep
!storage/uploads/images/.gitkeep !storage/uploads/images/.gitkeep
*.tmp *.tmp
*.temp *.temp

2
.gitignore vendored
View file

@ -10,7 +10,9 @@ data/repair-inquiries.json
data/site-settings.json data/site-settings.json
data/smtp-settings.json data/smtp-settings.json
storage/uploads/images/* storage/uploads/images/*
storage/config/*
!storage/.gitkeep !storage/.gitkeep
!storage/config/.gitkeep
!storage/uploads/.gitkeep !storage/uploads/.gitkeep
!storage/uploads/images/.gitkeep !storage/uploads/images/.gitkeep
.DS_Store .DS_Store

View file

@ -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/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/scripts/runtime-start.js ./runtime-start.js 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 && chown -R nextjs:nodejs /app/data /app/storage /app/.next/cache
USER nextjs USER nextjs

View file

@ -13,7 +13,7 @@ Eigenständige öffentliche Firmenwebsite für Funktechnik Schubert. Dieses Proj
- Nginx/Reverse-Proxy-fähig - Nginx/Reverse-Proxy-fähig
- SEO Metadata, Sitemap und robots.txt - SEO Metadata, Sitemap und robots.txt
Version: `0.2.2` Version: `0.3.0`
## Seiten ## Seiten
@ -140,12 +140,12 @@ Funktionen:
- Reparaturanfragen verwalten - Reparaturanfragen verwalten
- Website-Inhaltsverwaltung vorbereitet - Website-Inhaltsverwaltung vorbereitet
- Firmendaten pflegen - Firmendaten pflegen
- SMTP-Konfiguration vorbereiten - SMTP-Konfiguration speichern und Testmail senden
- Medien hochladen - Medien hochladen
- SEO-Übersicht - SEO-Übersicht
- Systemü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 ## Runtime Data
@ -163,9 +163,10 @@ Echte Runtime-Dateien werden nicht committed:
data/contact-inquiries.json data/contact-inquiries.json
data/repair-inquiries.json data/repair-inquiries.json
data/site-settings.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. 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. 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 ```json
{ {
"status": "ok", "status": "ok",
"version": "0.2.2", "version": "0.3.0",
"storage": "ok", "storage": "ok",
"admin": "configured", "admin": "configured",
"smtp": "configured",
"timestamp": "..." "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 ## Deployment
```bash ```bash
@ -259,6 +297,5 @@ certbot --nginx -d funktechnik-schubert.de -d www.funktechnik-schubert.de
## Offene manuelle Punkte ## Offene manuelle Punkte
- TODO: Rechtliche Angaben ergänzen - TODO: Rechtliche Angaben ergänzen
- Produktives Kontakt-/Mail-System anbinden
- Spätere Olympus-Reparaturannahme serverseitig anbinden - Spätere Olympus-Reparaturannahme serverseitig anbinden
- Finale Domain und HTTPS-Konfiguration setzen - Finale Domain und HTTPS-Konfiguration setzen

View file

@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell"; import AdminShell from "@/components/admin/AdminShell";
import { requireAdminSession } from "@/lib/admin/auth"; import { requireAdminSession } from "@/lib/admin/auth";
import { getContactInquiries, getRepairInquiries } from "@/lib/admin/store"; import { getContactInquiries, getRepairInquiries } from "@/lib/admin/store";
import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config";
function countNew<T extends { status: string }>(items: T[]) { function countNew<T extends { status: string }>(items: T[]) {
return items.filter((item) => item.status === "new").length; return items.filter((item) => item.status === "new").length;
@ -9,7 +10,7 @@ function countNew<T extends { status: string }>(items: T[]) {
export default async function AdminDashboardPage() { export default async function AdminDashboardPage() {
if (!await requireAdminSession()) redirect("/admin/login"); 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); const latest = [...contacts, ...repairs].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 6);
return ( return (
@ -22,7 +23,7 @@ export default async function AdminDashboardPage() {
<div className="admin-kpi"><span>Kontaktanfragen</span><strong>{countNew(contacts)}</strong><small>neu</small></div> <div className="admin-kpi"><span>Kontaktanfragen</span><strong>{countNew(contacts)}</strong><small>neu</small></div>
<div className="admin-kpi"><span>Reparaturanfragen</span><strong>{countNew(repairs)}</strong><small>neu</small></div> <div className="admin-kpi"><span>Reparaturanfragen</span><strong>{countNew(repairs)}</strong><small>neu</small></div>
<div className="admin-kpi"><span>Website</span><strong>Online</strong><small>OK</small></div> <div className="admin-kpi"><span>Website</span><strong>Online</strong><small>OK</small></div>
<div className="admin-kpi"><span>SMTP</span><strong>Vorbereitet</strong><small>nicht aktiv</small></div> <div className="admin-kpi"><span>SMTP</span><strong>{isSmtpConfigured(smtpSettings) ? "OK" : "Fehlt"}</strong><small>{smtpSettings.lastTestStatus ?? "kein Test"}</small></div>
</div> </div>
<section className="admin-card"> <section className="admin-card">
<h2>Letzte Anfragen</h2> <h2>Letzte Anfragen</h2>

View file

@ -1,7 +1,8 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell"; import AdminShell from "@/components/admin/AdminShell";
import SmtpSettingsForm from "@/components/admin/SmtpSettingsForm";
import { requireAdminSession } from "@/lib/admin/auth"; 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() { export default async function AdminSmtpPage() {
if (!await requireAdminSession()) redirect("/admin/login"); if (!await requireAdminSession()) redirect("/admin/login");
@ -13,16 +14,7 @@ export default async function AdminSmtpPage() {
<p className="eyebrow">E-Mail</p> <p className="eyebrow">E-Mail</p>
<h1>SMTP Einstellungen</h1> <h1>SMTP Einstellungen</h1>
</div> </div>
<form className="admin-card admin-form" action="/api/admin/smtp" method="post"> <SmtpSettingsForm initialSettings={toPublicSmtpSettings(settings)} configured={isSmtpConfigured(settings)} />
<label>SMTP Server<input name="host" defaultValue={settings.host} /></label>
<label>Port<input name="port" defaultValue={settings.port} inputMode="numeric" /></label>
<label>Benutzername<input name="username" defaultValue={settings.username} /></label>
<label>Passwort<input name="password" type="password" placeholder="wird in dieser Foundation noch nicht gespeichert" disabled /></label>
<label>Absenderadresse<input name="fromAddress" type="email" defaultValue={settings.fromAddress} /></label>
<label>Antwortadresse<input name="replyToAddress" type="email" defaultValue={settings.replyToAddress} /></label>
<label className="admin-checkbox"><input name="tls" type="checkbox" defaultChecked={settings.tls} /> TLS aktivieren</label>
<button className="button" type="submit">Speichern</button>
</form>
</AdminShell> </AdminShell>
); );
} }

View file

@ -1,7 +1,8 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell"; import AdminShell from "@/components/admin/AdminShell";
import { requireAdminSession } from "@/lib/admin/auth"; 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() { export default async function AdminSystemPage() {
if (!await requireAdminSession()) redirect("/admin/login"); if (!await requireAdminSession()) redirect("/admin/login");
@ -12,6 +13,8 @@ export default async function AdminSystemPage() {
} catch { } catch {
storage = "error"; storage = "error";
} }
const smtpSettings = await getSmtpSettings();
const smtpConfigured = isSmtpConfigured(smtpSettings);
return ( return (
<AdminShell> <AdminShell>
@ -27,10 +30,13 @@ export default async function AdminSystemPage() {
<div><dt>Docker Environment</dt><dd>{isDockerEnvironment() ? "ja" : "nein"}</dd></div> <div><dt>Docker Environment</dt><dd>{isDockerEnvironment() ? "ja" : "nein"}</dd></div>
<div><dt>Storage Status</dt><dd>{storage}</dd></div> <div><dt>Storage Status</dt><dd>{storage}</dd></div>
<div><dt>Data Directory</dt><dd>{dataDirectory}</dd></div> <div><dt>Data Directory</dt><dd>{dataDirectory}</dd></div>
<div><dt>Config Directory</dt><dd>{configDirectory}</dd></div>
<div><dt>Upload Directory</dt><dd>{uploadDirectory}</dd></div> <div><dt>Upload Directory</dt><dd>{uploadDirectory}</dd></div>
<div><dt>Admin Konfiguration</dt><dd>{adminConfigurationStatus()}</dd></div> <div><dt>Admin Konfiguration</dt><dd>{adminConfigurationStatus()}</dd></div>
<div><dt>Olympus Verbindung</dt><dd>{olympusStatus()}</dd></div> <div><dt>Olympus Verbindung</dt><dd>{olympusStatus()}</dd></div>
<div><dt>SMTP</dt><dd>{smtpStatus()}</dd></div> <div><dt>SMTP</dt><dd>{smtpConfigured ? "configured" : "missing"}</dd></div>
<div><dt>Letzte SMTP-Testmail</dt><dd>{smtpSettings.lastTestAt ? `${new Date(smtpSettings.lastTestAt).toLocaleString("de-DE")} (${smtpSettings.lastTestStatus})` : "keine"}</dd></div>
<div><dt>Letzter SMTP-Formularversand</dt><dd>{smtpSettings.lastDeliveryAt ? `${new Date(smtpSettings.lastDeliveryAt).toLocaleString("de-DE")} (${smtpSettings.lastDeliveryStatus})` : "keiner"}</dd></div>
</dl> </dl>
</section> </section>
<section className="admin-card"> <section className="admin-card">
@ -38,7 +44,7 @@ export default async function AdminSystemPage() {
<ul className="list"> <ul className="list">
<li>Runtime-Daten liegen unter <code>data/</code> und werden nicht versioniert.</li> <li>Runtime-Daten liegen unter <code>data/</code> und werden nicht versioniert.</li>
<li>Uploads liegen unter <code>storage/uploads/images/</code> und werden nicht versioniert.</li> <li>Uploads liegen unter <code>storage/uploads/images/</code> und werden nicht versioniert.</li>
<li>Olympus- und SMTP-Integration sind vorbereitet, aber nicht aktiv implementiert.</li> <li>SMTP-Versand ist serverseitig aktiv, sofern eine vollständige Konfiguration gespeichert ist.</li>
</ul> </ul>
</section> </section>
</AdminShell> </AdminShell>

View file

@ -1,9 +1,26 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireAdminSession } from "@/lib/admin/auth"; 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) { export async function POST(request: Request) {
if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 });
await saveSmtpSettings(await request.formData()); const form = await request.formData();
return new NextResponse(null, { status: 303, headers: { Location: "/admin/smtp?saved=1" } }); 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),
});
} }

View file

@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { addContactInquiry } from "@/lib/admin/store"; import { addContactInquiry } from "@/lib/admin/store";
import { sendContactInquiryMail } from "@/lib/mail/smtp";
function text(value: FormDataEntryValue | null) { function text(value: FormDataEntryValue | null) {
return typeof value === "string" ? value.trim() : ""; 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 }); 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({ return NextResponse.json({
message: "Ihre Nachricht wurde erfasst. Sie erhalten eine Rückmeldung.", message: "Ihre Nachricht wurde erfasst. Sie erhalten eine Rückmeldung.",

View file

@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { adminConfigurationStatus, appVersion, checkStorage } from "@/lib/runtime/config"; import { adminConfigurationStatus, appVersion, checkStorage } from "@/lib/runtime/config";
import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config";
export async function GET() { export async function GET() {
let storage = "ok"; let storage = "ok";
@ -10,11 +11,14 @@ export async function GET() {
storage = "error"; storage = "error";
} }
const smtpSettings = await getSmtpSettings();
return NextResponse.json({ return NextResponse.json({
status: storage === "ok" ? "ok" : "error", status: storage === "ok" ? "ok" : "error",
version: appVersion, version: appVersion,
storage, storage,
admin: adminConfigurationStatus(), admin: adminConfigurationStatus(),
smtp: isSmtpConfigured(smtpSettings) ? "configured" : "missing",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} }

View file

@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { addRepairInquiry } from "@/lib/admin/store"; import { addRepairInquiry } from "@/lib/admin/store";
import { sendRepairInquiryMail } from "@/lib/mail/smtp";
function required(value: FormDataEntryValue | null) { function required(value: FormDataEntryValue | null) {
return typeof value === "string" && value.trim().length > 0; 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 }); 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({ return NextResponse.json({
message: "Ihre Reparaturanfrage wurde erfasst. Sie erhalten nach Prüfung eine Rückmeldung.", message: "Ihre Reparaturanfrage wurde erfasst. Sie erhalten nach Prüfung eine Rückmeldung.",

View file

@ -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<HTMLFormElement>(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<HTMLFormElement>) {
event.preventDefault();
void submitForm(event.currentTarget, "save");
}
function test() {
if (formRef.current) void submitForm(formRef.current, "test");
}
return (
<>
<section className="admin-card">
<h2>Status</h2>
<p className="admin-muted">SMTP ist {isConfigured ? "konfiguriert" : "nicht konfiguriert"}.</p>
{settings.lastTestAt && <p>Letzte Testmail: {new Date(settings.lastTestAt).toLocaleString("de-DE")} ({settings.lastTestStatus})</p>}
{settings.lastDeliveryAt && <p>Letzter Formularversand: {new Date(settings.lastDeliveryAt).toLocaleString("de-DE")} ({settings.lastDeliveryStatus})</p>}
{settings.lastError && <p className="error">Letzter Fehler: {settings.lastError}</p>}
<p className="notice">Für Apple Mail/iCloud bitte smtp.mail.me.com, Port 587, STARTTLS und ein app-spezifisches Passwort verwenden.</p>
</section>
{message && <p className="success admin-message">{message}</p>}
{error && <p className="error admin-message">{error}</p>}
<form ref={formRef} className="admin-card admin-form" onSubmit={submit}>
<label>SMTP Host<input name="host" defaultValue={settings.host} placeholder="smtp.mail.me.com" /></label>
<label>SMTP Port<input name="port" defaultValue={settings.port} inputMode="numeric" placeholder="587" /></label>
<label>SMTP Benutzername<input name="username" defaultValue={settings.username} placeholder="name@example.com" /></label>
<label>
SMTP Passwort
<input name="password" type="password" placeholder={settings.hasPassword ? "Passwort ist gesetzt" : "App-spezifisches Passwort"} autoComplete="new-password" />
</label>
<label>
Verschlüsselung
<select name="security" defaultValue={settings.security}>
<option value="starttls">STARTTLS</option>
<option value="tls">TLS</option>
<option value="none">Keine</option>
</select>
</label>
<label>Absenderadresse<input name="fromAddress" type="email" defaultValue={settings.fromAddress} /></label>
<label>Antwortadresse<input name="replyToAddress" type="email" defaultValue={settings.replyToAddress} /></label>
<label>Empfängeradresse<input name="recipientAddress" type="email" defaultValue={settings.recipientAddress} /></label>
<label>BCC optional<input name="bccAddress" type="email" defaultValue={settings.bccAddress} /></label>
<div className="actions">
<button className="button" type="submit" disabled={pending}>Speichern</button>
<button className="button light" type="button" disabled={pending} onClick={test}>Test-E-Mail senden</button>
</div>
</form>
</>
);
}

View file

@ -8,7 +8,7 @@ services:
- "3010:3010" - "3010:3010"
environment: environment:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://127.0.0.1:3010} 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_EMAIL: ${ADMIN_EMAIL:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-} ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-}

View file

@ -1,7 +1,7 @@
import { mkdir, readFile, writeFile } from "fs/promises"; import { mkdir, readFile, writeFile } from "fs/promises";
import path from "path"; import path from "path";
import { dataDirectory } from "@/lib/runtime/config"; import { configDirectory, dataDirectory } from "@/lib/runtime/config";
import type { ContactInquiry, InquiryStatus, RepairInquiry, SiteSettings, SmtpSettings } from "./types"; import type { ContactInquiry, InquiryStatus, RepairInquiry, SiteSettings } from "./types";
async function ensureDataDir() { async function ensureDataDir() {
await mkdir(dataDirectory, { recursive: true }); await mkdir(dataDirectory, { recursive: true });
@ -112,26 +112,8 @@ export async function saveSiteSettings(form: FormData) {
return settings; return settings;
} }
export async function getSmtpSettings() { export async function ensureConfigDir() {
return readJson<SmtpSettings>("smtp-settings.json", { await mkdir(configDirectory, { recursive: true });
host: "",
port: "587",
username: "",
fromAddress: "",
replyToAddress: "",
tls: true,
});
} }
export async function saveSmtpSettings(form: FormData) { export { text };
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;
}

View file

@ -42,7 +42,15 @@ export type SmtpSettings = {
host: string; host: string;
port: string; port: string;
username: string; username: string;
password?: string;
security: "starttls" | "tls" | "none";
fromAddress: string; fromAddress: string;
replyToAddress: 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
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;

View file

@ -5,6 +5,7 @@ import { appVersion } from "./version";
export const dataDirectory = path.join(process.cwd(), "data"); export const dataDirectory = path.join(process.cwd(), "data");
export const storageDirectory = path.join(process.cwd(), "storage"); 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 uploadDirectory = path.join(storageDirectory, "uploads", "images");
export const legacyPublicUploadDirectory = path.join(process.cwd(), "public", "uploads", "images"); export const legacyPublicUploadDirectory = path.join(process.cwd(), "public", "uploads", "images");
@ -18,6 +19,7 @@ export function isDockerEnvironment() {
export async function ensureRuntimeDirectories() { export async function ensureRuntimeDirectories() {
await mkdir(dataDirectory, { recursive: true }); await mkdir(dataDirectory, { recursive: true });
await mkdir(configDirectory, { recursive: true });
await mkdir(uploadDirectory, { recursive: true }); await mkdir(uploadDirectory, { recursive: true });
await migrateLegacyUploads(); await migrateLegacyUploads();
} }

View file

@ -1 +1 @@
export const appVersion = "0.2.2"; export const appVersion = "0.3.0";

26
package-lock.json generated
View file

@ -1,14 +1,16 @@
{ {
"name": "funktechnik-schubert-website", "name": "funktechnik-schubert-website",
"version": "0.2.2", "version": "0.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "funktechnik-schubert-website", "name": "funktechnik-schubert-website",
"version": "0.2.2", "version": "0.3.0",
"dependencies": { "dependencies": {
"@types/nodemailer": "^8.0.1",
"next": "16.2.10", "next": "16.2.10",
"nodemailer": "^9.0.3",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
}, },
@ -1343,12 +1345,20 @@
"version": "24.13.2", "version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "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": { "node_modules/@types/react": {
"version": "19.2.17", "version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@ -4669,6 +4679,15 @@
"node": ">=18" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -5912,7 +5931,6 @@
"version": "7.18.2", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {

View file

@ -1,6 +1,6 @@
{ {
"name": "funktechnik-schubert-website", "name": "funktechnik-schubert-website",
"version": "0.2.2", "version": "0.3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3010", "dev": "next dev -p 3010",
@ -9,7 +9,9 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@types/nodemailer": "^8.0.1",
"next": "16.2.10", "next": "16.2.10",
"nodemailer": "^9.0.3",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
}, },

View file

@ -5,6 +5,7 @@ const path = require("path");
const requiredEnv = ["ADMIN_EMAIL", "ADMIN_PASSWORD", "ADMIN_SESSION_SECRET"]; const requiredEnv = ["ADMIN_EMAIL", "ADMIN_PASSWORD", "ADMIN_SESSION_SECRET"];
const requiredDirs = [ const requiredDirs = [
path.join(process.cwd(), "data"), path.join(process.cwd(), "data"),
path.join(process.cwd(), "storage", "config"),
path.join(process.cwd(), "storage", "uploads", "images"), path.join(process.cwd(), "storage", "uploads", "images"),
path.join(process.cwd(), ".next", "cache"), 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"}`); 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"); require("./server.js");

1
storage/config/.gitkeep Normal file
View file

@ -0,0 +1 @@