feat(mail): add smtp email delivery
This commit is contained in:
parent
faddaa91d0
commit
3456b04ed4
25 changed files with 529 additions and 60 deletions
|
|
@ -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
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
49
README.md
49
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
|
||||
|
|
|
|||
|
|
@ -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<T extends { status: string }>(items: T[]) {
|
||||
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() {
|
||||
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() {
|
|||
<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>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>
|
||||
<section className="admin-card">
|
||||
<h2>Letzte Anfragen</h2>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<p className="eyebrow">E-Mail</p>
|
||||
<h1>SMTP Einstellungen</h1>
|
||||
</div>
|
||||
<form className="admin-card admin-form" action="/api/admin/smtp" method="post">
|
||||
<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>
|
||||
<SmtpSettingsForm initialSettings={toPublicSmtpSettings(settings)} configured={isSmtpConfigured(settings)} />
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<AdminShell>
|
||||
|
|
@ -27,10 +30,13 @@ export default async function AdminSystemPage() {
|
|||
<div><dt>Docker Environment</dt><dd>{isDockerEnvironment() ? "ja" : "nein"}</dd></div>
|
||||
<div><dt>Storage Status</dt><dd>{storage}</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>Admin Konfiguration</dt><dd>{adminConfigurationStatus()}</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>
|
||||
</section>
|
||||
<section className="admin-card">
|
||||
|
|
@ -38,7 +44,7 @@ export default async function AdminSystemPage() {
|
|||
<ul className="list">
|
||||
<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>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>
|
||||
</section>
|
||||
</AdminShell>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
94
components/admin/SmtpSettingsForm.tsx
Normal file
94
components/admin/SmtpSettingsForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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:-}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
26
package-lock.json
generated
26
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
1
storage/config/.gitkeep
Normal file
1
storage/config/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
Loading…
Add table
Add a link
Reference in a new issue