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
|
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
2
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
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"
|
- "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:-}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
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 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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"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": {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
1
storage/config/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue