Author SHA1 Message Date
unknownandClaude Opus 5.5 ebab5627de docs: mailserver in der app in anleitung und changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:40:36 +01:00
unknownandClaude Opus 5.5 01bbb3ba9a feat(erinnerungen): mailserver in der app einrichten
SMTP-Server, Port, Verschluesselung, Zugang und Absender unter Mehr ->
Erinnerungen, gespeichert in der neuen Tabelle app_setting. Das Passwort
liegt mit AES-256-GCM verschluesselt (Schluessel aus BETTER_AUTH_SECRET),
wird nie angezeigt und nicht exportiert. Die .env hat weiter Vorrang. Der
Zeitplan laeuft jetzt immer und prueft je Durchlauf, ob ein Server da ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:40:36 +01:00
unknown ebdd6c313a Merge: Mehr-Button beschriften 2026-10-25 21:54:19 +01:00
unknownandClaude Opus 5.5 7d75e4345a fix(navigation): mehr-button beschriften statt nur initialen
Das Kaestchen mit den Initialen oben rechts war als Weg zu Konto, Design
und Verwaltung nicht zu erkennen. Jetzt steht dort "Mehr" mit Symbol.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:54:18 +01:00
unknown 7d7711eb8d Merge: Neue Navigation mit Plus, Mehr und Sammler-Umschalter 2026-10-25 20:34:46 +01:00
unknown a8d933bf5b Merge: Set aus einem Link übernehmen 2026-10-25 20:34:46 +01:00
45 changed files with 2777 additions and 83 deletions
+3 -3
View File
@@ -33,9 +33,9 @@ TZ=Europe/Berlin
# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API) # Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API)
# REBRICKABLE_API_KEY= # REBRICKABLE_API_KEY=
# Optional: Mailversand fuer Erinnerungen an Wunschlisten-Termine. Ohne # Optional: Mailversand fuer Erinnerungen an Wunschlisten-Termine. Einfacher
# SMTP_HOST und SMTP_FROM sind Erinnerungen komplett aus - alles andere # geht es in der App unter Mehr -> Erinnerungen -> Mailserver. Steht der
# funktioniert unveraendert. # Server hier, hat die .env Vorrang und die App zeigt ihn nur an.
# SMTP_HOST=mail.example.de # SMTP_HOST=mail.example.de
# SMTP_PORT=587 # SMTP_PORT=587
# SMTP_SECURE=false # SMTP_SECURE=false
+18 -10
View File
@@ -7,14 +7,31 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased] ## [Unreleased]
### Hinzugefügt
- **Mailserver in der App einrichten**: Unter Mehr → Erinnerungen → Mailserver
lassen sich Server, Port, Verschlüsselung, Zugang und Absender eintragen –
ohne `.env` und ohne Neustart. Das Passwort wird verschlüsselt gespeichert
(AES-256-GCM, Schlüssel aus `BETTER_AUTH_SECRET`), nie wieder angezeigt und
nicht exportiert. Steht der Server in der `.env`, hat sie weiter Vorrang.
- **Set aus einem Link übernehmen**, für alle Hersteller: Amazon, idealo,
geizhals, Hersteller-Shops, Brickfact oder Merlins Bricks. Bricktrack liest
Produktdaten (schema.org, Open Graph, bei Amazon die Produktseite) und, wenn
die Seite blockt, den Link selbst. Lego wird über Rebrickable ergänzt,
BlueBrixx und Cobi über Merlins Bricks. Das Ergebnis füllt das Formular vor,
das Bild wird beim Speichern lokal abgelegt.
### Geändert ### Geändert
- Der Zeitplan für Erinnerungen läuft immer und prüft bei jedem Durchlauf, ob
ein Mailserver eingerichtet ist. Vorher wurde er nur gestartet, wenn SMTP
schon beim Containerstart in der `.env` stand.
- **Neue Navigation**: unten Start, Katalog, Sammlung, Wünsche und in der - **Neue Navigation**: unten Start, Katalog, Sammlung, Wünsche und in der
Mitte ein +-Stein mit allen Wegen zu einem neuen Set (Scannen, aus Link, Mitte ein +-Stein mit allen Wegen zu einem neuen Set (Scannen, aus Link,
Lego-Nummer, von Hand). Lego-Nummer, von Hand).
- **„Mehr" statt Konto-Menü und Verwaltungsreitern**: Konto, Design, - **„Mehr" statt Konto-Menü und Verwaltungsreitern**: Konto, Design,
Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte und Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte und
Export/Import als Kacheln in Gruppen, oben rechts hinter den Initialen. Export/Import als Kacheln in Gruppen, hinter dem Button „Mehr" oben rechts.
- **„Für wen?" in der Kopfzeile**: Bei mehreren Sammlern wird das Kind einmal - **„Für wen?" in der Kopfzeile**: Bei mehreren Sammlern wird das Kind einmal
oben gewählt und gilt für Startseite, Katalog und Sammlung. Jedes Gerät oben gewählt und gilt für Startseite, Katalog und Sammlung. Jedes Gerät
merkt sich die Wahl; ein `?sammler=` im Link hat weiterhin Vorrang. merkt sich die Wahl; ein `?sammler=` im Link hat weiterhin Vorrang.
@@ -25,15 +42,6 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
Standorte heran, obwohl sie diese pflegen dürfen: Der Eintrag Standorte heran, obwohl sie diese pflegen dürfen: Der Eintrag
„Verwaltung" stand nur im Menü des Administrators. „Verwaltung" stand nur im Menü des Administrators.
### Hinzugefügt
- **Set aus einem Link übernehmen**, für alle Hersteller: Amazon, idealo,
geizhals, Hersteller-Shops, Brickfact oder Merlins Bricks. Bricktrack liest
Produktdaten (schema.org, Open Graph, bei Amazon die Produktseite) und, wenn
die Seite blockt, den Link selbst. Lego wird über Rebrickable ergänzt,
BlueBrixx und Cobi über Merlins Bricks. Das Ergebnis füllt das Formular vor,
das Bild wird beim Speichern lokal abgelegt.
### Sicherheit ### Sicherheit
- Abrufe im Auftrag eines Nutzers gehen nur an öffentliche Adressen: Jede - Abrufe im Auftrag eines Nutzers gehen nur an öffentliche Adressen: Jede
+2 -1
View File
@@ -61,6 +61,7 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
| `src/lib/prices.ts` | Wertentwicklung aus notierten Preisen (rein) | | `src/lib/prices.ts` | Wertentwicklung aus notierten Preisen (rein) |
| `src/lib/reminders.ts` | Regeln, wann eine Erinnerung faellig ist (rein) | | `src/lib/reminders.ts` | Regeln, wann eine Erinnerung faellig ist (rein) |
| `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan | | `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
| `src/lib/mail-settings.ts`, `src/lib/secret-box.ts` | SMTP aus .env oder App (`app_setting`), Passwort verschluesselt |
| `src/lib/export.ts` | Export und Import des gesamten Bestands | | `src/lib/export.ts` | Export und Import des gesamten Bestands |
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy | | `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
| `src/components/layout/more-items.ts` | Eintraege der Seite „Mehr" (Konto, Design, Stammdaten), auch Titel der Verwaltungsseiten | | `src/components/layout/more-items.ts` | Eintraege der Seite „Mehr" (Konto, Design, Stammdaten), auch Titel der Verwaltungsseiten |
@@ -205,7 +206,7 @@ Session.
5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`. 5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`.
Rate-Limiting auf den Login-Endpunkten. Rate-Limiting auf den Login-Endpunkten.
6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und 6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und
keine Reservierungen oder Teilen-Token. Das Backup ist `./data`, nicht der keine Reservierungen, Teilen-Token oder `app_setting` (SMTP-Zugang). Das Backup ist `./data`, nicht der
Export. Export.
7. Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals 7. Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals
`/api/auth`, `/api/export` oder den Healthcheck. Beim Abmelden wird der `/api/auth`, `/api/export` oder den Healthcheck. Beim Abmelden wird der
+1 -1
View File
@@ -144,7 +144,7 @@ tut.
## 7. Passkey hinterlegen ## 7. Passkey hinterlegen
Unter **Mehr → Konto & Passkeys → Passkey hinzufügen** (das Kästchen mit deinen Initialen oben rechts). Ab dann meldest du dich per Unter **Mehr → Konto & Passkeys → Passkey hinzufügen** (der Button „Mehr" oben rechts). Ab dann meldest du dich per
Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein
eigener Passkey – Handy, Tablet, Laptop. eigener Passkey – Handy, Tablet, Laptop.
+16 -14
View File
@@ -73,8 +73,8 @@ scannen, aus einem Link übernehmen, Lego-Nummer holen oder von Hand eintragen.
![Die Mehr-Seite](bilder/27-mehr.png) ![Die Mehr-Seite](bilder/27-mehr.png)
Alles, was man nicht täglich braucht, liegt hinter dem Kästchen mit deinen Alles, was man nicht täglich braucht, liegt hinter dem Button
Initialen oben rechts – der Seite **Mehr**: Konto und Passkeys, Design, **„Mehr"** oben rechts: Konto und Passkeys, Design,
Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte sowie Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte sowie
Export und Import. Was nur der Administrator darf, sehen Mitglieder dort gar Export und Import. Was nur der Administrator darf, sehen Mitglieder dort gar
nicht erst. nicht erst.
@@ -538,25 +538,27 @@ schickt Bricktrack dir eine E-Mail – standardmäßig 14 Tage vorher, höchsten
einmal pro Woche je Liste. Ist alles reserviert, bleibt es still: eine einmal pro Woche je Liste. Ist alles reserviert, bleibt es still: eine
Erinnerung ohne Handlungsbedarf ist nur Lärm. Erinnerung ohne Handlungsbedarf ist nur Lärm.
Dafür braucht es einen SMTP-Zugang in der `.env`: Dafür braucht es einen Mailserver (SMTP). Den trägst du direkt in der App ein:
**Mehr → Erinnerungen → Mailserver**. Die Angaben – Server, Port,
Verschlüsselung, Benutzername, Passwort und Absender – findest du bei deinem
Mail-Anbieter unter „SMTP" oder „Postausgangsserver". Ein Neustart ist nicht
nötig.
```bash Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt; zum
SMTP_HOST=mail.example.de Ändern einfach ein neues eintragen. Es landet auch nicht im Export.
SMTP_PORT=587
SMTP_USER=bricktrack@example.de
SMTP_PASSWORD=…
SMTP_FROM=Bricktrack <bricktrack@example.de>
# Optional, Standard ist 14: > Wer lieber mit der `.env` arbeitet, kann den Mailserver weiterhin dort
REMINDER_DAYS_BEFORE=14 > eintragen (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
``` > `SMTP_FROM`). Dann hat die `.env` Vorrang, und die App zeigt die Werte nur
> an. Wie viele Tage vorher erinnert wird, steht in `REMINDER_DAYS_BEFORE`
> (Standard 14).
Unter **Mehr → Erinnerungen** kannst du die Verbindung prüfen, dir eine Unter **Mehr → Erinnerungen** kannst du außerdem die Verbindung prüfen, dir eine
Testnachricht schicken und die Prüfung sofort auslösen, statt auf die nächste Testnachricht schicken und die Prüfung sofort auslösen, statt auf die nächste
Stunde zu warten. Dort steht auch, welche Listen einen Termin haben und wie Stunde zu warten. Dort steht auch, welche Listen einen Termin haben und wie
viel dort noch offen ist. viel dort noch offen ist.
Ohne SMTP-Zugang ist die Funktion vollständig aus – alles andere funktioniert Ohne Mailserver ist die Funktion vollständig aus – alles andere funktioniert
unverändert. unverändert.
--- ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 KiB

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 407 KiB

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 KiB

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 KiB

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 381 KiB

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 345 KiB

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 445 KiB

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 271 KiB

+5
View File
@@ -0,0 +1,5 @@
CREATE TABLE `app_setting` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL,
`updated_at` integer NOT NULL
);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -29,6 +29,13 @@
"when": 1788765478965, "when": 1788765478965,
"tag": "0003_powerful_sally_floyd", "tag": "0003_powerful_sally_floyd",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1792963951232,
"tag": "0004_modern_agent_brand",
"breakpoints": true
} }
] ]
} }
@@ -1,9 +1,23 @@
'use server'; 'use server';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { actionData, actionError, type ActionResult } from '@/lib/action-result'; import {
actionData,
actionError,
actionOk,
formToObject,
parseForm,
type ActionResult,
} from '@/lib/action-result';
import { env } from '@/env'; import { env } from '@/env';
import {
deleteMailSettings,
getMailConfig,
isMailConfigured,
saveMailSettings,
} from '@/lib/mail-settings';
import { sendMail, verifyMailConnection } from '@/lib/mailer'; import { sendMail, verifyMailConnection } from '@/lib/mailer';
import { mailSettingsSchema } from '@/lib/validation';
import { runReminderJob, type ReminderRun } from '@/lib/reminder-job'; import { runReminderJob, type ReminderRun } from '@/lib/reminder-job';
import { requireAdmin } from '@/lib/session'; import { requireAdmin } from '@/lib/session';
@@ -17,7 +31,7 @@ export async function testMailConnection(): Promise<ActionResult> {
export async function sendTestMail(): Promise<ActionResult> { export async function sendTestMail(): Promise<ActionResult> {
const admin = await requireAdmin(); const admin = await requireAdmin();
if (!env.isMailConfigured) { if (!isMailConfigured()) {
return actionError('Es ist kein SMTP-Server konfiguriert.'); return actionError('Es ist kein SMTP-Server konfiguriert.');
} }
@@ -41,7 +55,7 @@ export async function sendTestMail(): Promise<ActionResult> {
export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> { export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> {
await requireAdmin(); await requireAdmin();
if (!env.isMailConfigured) { if (!isMailConfigured()) {
return actionError('Es ist kein SMTP-Server konfiguriert.'); return actionError('Es ist kein SMTP-Server konfiguriert.');
} }
@@ -49,3 +63,26 @@ export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> {
revalidatePath('/verwaltung/erinnerungen'); revalidatePath('/verwaltung/erinnerungen');
return actionData(run); return actionData(run);
} }
/** Stores the SMTP server set up on the reminders page. */
export async function saveMailSettingsAction(formData: FormData): Promise<ActionResult> {
await requireAdmin();
if (getMailConfig()?.source === 'env') {
return actionError('Der Mailserver kommt aus der .env. Änderungen bitte dort vornehmen.');
}
const parsed = parseForm(mailSettingsSchema, formToObject(formData));
if (!parsed.ok) return parsed;
saveMailSettings(parsed.data);
revalidatePath('/verwaltung/erinnerungen');
return actionOk();
}
export async function deleteMailSettingsAction(): Promise<ActionResult> {
await requireAdmin();
deleteMailSettings();
revalidatePath('/verwaltung/erinnerungen');
return actionOk();
}
@@ -3,10 +3,12 @@ import { eq, sql } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { wishlist } from '@/db/schema'; import { wishlist } from '@/db/schema';
import { env } from '@/env'; import { env } from '@/env';
import { MailSettingsForm } from '@/components/verwaltung/mail-settings-form';
import { import {
ReminderSettings, ReminderSettings,
type ReminderListRow, type ReminderListRow,
} from '@/components/verwaltung/reminder-settings'; } from '@/components/verwaltung/reminder-settings';
import { getMailSettingsView } from '@/lib/mail-settings';
import { daysUntil } from '@/lib/reminders'; import { daysUntil } from '@/lib/reminders';
import { requireAdmin } from '@/lib/session'; import { requireAdmin } from '@/lib/session';
@@ -41,11 +43,16 @@ export default async function ErinnerungenPage() {
})) }))
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0)); .sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
const mail = getMailSettingsView();
return ( return (
<div className="space-y-6">
<ReminderSettings <ReminderSettings
configured={env.isMailConfigured} configured={mail.source !== null}
daysBefore={env.REMINDER_DAYS_BEFORE} daysBefore={env.REMINDER_DAYS_BEFORE}
lists={lists} lists={lists}
/> />
<MailSettingsForm view={mail} />
</div>
); );
} }
+6 -10
View File
@@ -1,16 +1,11 @@
import { Suspense } from 'react'; import { Suspense } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Menu } from 'lucide-react';
import { BrickLogo } from '@/components/brick/logo'; import { BrickLogo } from '@/components/brick/logo';
import { DesktopNav } from './bottom-nav'; import { DesktopNav } from './bottom-nav';
import { CollectorSwitch, type CollectorOption } from './collector-switch'; import { CollectorSwitch, type CollectorOption } from './collector-switch';
import type { SessionUser } from '@/lib/session'; import type { SessionUser } from '@/lib/session';
function initials(name: string): string {
const parts = name.trim().split(/\s+/);
const letters = (parts[0]?.[0] ?? '') + (parts.length > 1 ? (parts.at(-1)?.[0] ?? '') : '');
return letters.toUpperCase() || '?';
}
export function AppHeader({ export function AppHeader({
user, user,
collectors, collectors,
@@ -35,13 +30,14 @@ export function AppHeader({
<div className="flex-1" /> <div className="flex-1" />
<DesktopNav legoImport={legoImport} /> <DesktopNav legoImport={legoImport} />
{/* Account, design and master data: everything that is not daily use. */} {/* Account, design and master data: everything that is not daily use. */}
{/* Labelled on purpose: an initials-only box was not recognisable. */}
<Link <Link
href="/mehr" href="/mehr"
aria-label="Mehr: Konto, Design und Verwaltung" title={`Mehr: Konto (${user.name}), Design und Verwaltung`}
title="Mehr" className="flex h-10 items-center gap-2 rounded-[calc(var(--radius)*0.7)] border-[length:max(1px,var(--edge-w))] border-edge bg-card px-3 text-sm font-bold shadow-(--lift-sm) outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
className="flex size-10 items-center justify-center rounded-[calc(var(--radius)*0.7)] border-[length:max(1px,var(--edge-w))] border-edge bg-card text-sm font-bold shadow-(--lift-sm) outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
> >
{initials(user.name)} <Menu className="size-5" aria-hidden />
Mehr
</Link> </Link>
</div> </div>
@@ -0,0 +1,230 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Loader2, Save, Server, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
deleteMailSettingsAction,
saveMailSettingsAction,
} from '@/app/(app)/verwaltung/erinnerungen/actions';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
export type MailSettingsFormView = {
source: 'env' | 'app' | null;
host: string;
port: number;
security: 'starttls' | 'tls' | 'none';
user: string;
from: string;
hasPassword: boolean;
passwordUnreadable: boolean;
};
const SECURITY_LABELS = {
starttls: 'STARTTLS (meist Port 587)',
tls: 'SSL/TLS (meist Port 465)',
none: 'Unverschlüsselt (nur im eigenen Netz)',
} as const;
/** The SMTP server for reminders, set up right here instead of in the .env. */
export function MailSettingsForm({ view }: { view: MailSettingsFormView }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [security, setSecurity] = useState(view.security);
const [port, setPort] = useState(String(view.port));
if (view.source === 'env') {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="size-5 text-primary" aria-hidden />
Mailserver
</CardTitle>
<CardDescription>
Kommt aus der .env und hat dort Vorrang. Ändern lässt er sich nur dort, danach den
Container neu starten.
</CardDescription>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<p>
<span className="text-muted-foreground">Server:</span> {view.host}:{view.port} ·{' '}
{SECURITY_LABELS[view.security].split(' (')[0]}
</p>
<p>
<span className="text-muted-foreground">Absender:</span> {view.from}
</p>
</CardContent>
</Card>
);
}
function changeSecurity(next: 'starttls' | 'tls' | 'none') {
setSecurity(next);
// Follow the usual port while the user has not picked a special one.
if (next === 'tls' && port === '587') setPort('465');
if (next !== 'tls' && port === '465') setPort('587');
}
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const formData = new FormData(form);
startTransition(async () => {
const result = await saveMailSettingsAction(formData);
if (!result.ok) {
toast.error(result.error);
return;
}
// The password never stays visible, not even as dots.
const password = form.elements.namedItem('password');
if (password instanceof HTMLInputElement) password.value = '';
toast.success('Mailserver gespeichert. Prüf ihn gleich mit „Verbindung prüfen“.');
router.refresh();
});
}
function remove() {
startTransition(async () => {
const result = await deleteMailSettingsAction();
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Mailserver entfernt. Erinnerungen sind aus.');
router.refresh();
});
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="size-5 text-primary" aria-hidden />
Mailserver
</CardTitle>
<CardDescription>
Über diesen Server gehen die Erinnerungen raus. Die Angaben findest du bei deinem
Mail-Anbieter unter „SMTP“ oder „Postausgangsserver“. Das Passwort wird verschlüsselt
gespeichert und nie wieder angezeigt.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
<Field label="SMTP-Server" htmlFor="mail-host" required>
<Input
id="mail-host"
name="host"
defaultValue={view.host}
placeholder="mail.example.de"
autoComplete="off"
required
/>
</Field>
<Field label="Port" htmlFor="mail-port" required>
<Input
id="mail-port"
name="port"
inputMode="numeric"
value={port}
onChange={(event) => setPort(event.target.value)}
required
/>
</Field>
</div>
<Field label="Verschlüsselung" htmlFor="mail-security">
<NativeSelect
id="mail-security"
name="security"
value={security}
onChange={(event) => changeSecurity(event.target.value as typeof security)}
>
{Object.entries(SECURITY_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Benutzername" htmlFor="mail-user">
<Input
id="mail-user"
name="user"
defaultValue={view.user}
placeholder="bricktrack@example.de"
autoComplete="off"
/>
</Field>
<Field
label="Passwort"
htmlFor="mail-password"
hint={
view.passwordUnreadable
? 'Das gespeicherte Passwort ist nicht mehr lesbar (BETTER_AUTH_SECRET wurde geändert). Bitte neu eingeben.'
: view.hasPassword
? 'Gespeichert. Leer lassen, um es zu behalten.'
: undefined
}
>
<Input
id="mail-password"
name="password"
type="password"
autoComplete="new-password"
placeholder={view.hasPassword ? '••••••••' : ''}
/>
</Field>
</div>
{view.hasPassword ? (
<label className="flex items-center gap-2 text-sm">
<Checkbox name="clearPassword" value="on" />
Gespeichertes Passwort entfernen
</label>
) : null}
<Field
label="Absender"
htmlFor="mail-from"
required
hint="So steht es im Postfach, z. B. Bricktrack <bricktrack@example.de>."
>
<Input
id="mail-from"
name="from"
defaultValue={view.from}
placeholder="Bricktrack <bricktrack@example.de>"
required
/>
</Field>
<div className="flex flex-wrap gap-2">
<Button type="submit" size="lg" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Save />}
Speichern
</Button>
{view.source === 'app' ? (
<Button type="button" variant="ghost" size="lg" onClick={remove} disabled={pending}>
<Trash2 />
Entfernen
</Button>
) : null}
</div>
</form>
</CardContent>
</Card>
);
}
@@ -98,17 +98,10 @@ export function ReminderSettings({
) : ( ) : (
<div className="space-y-2 rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground"> <div className="space-y-2 rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
<p> <p>
Es ist kein Mailversand eingerichtet – Erinnerungen sind damit aus, alles andere Es ist noch kein Mailserver eingerichtet – Erinnerungen sind damit aus, alles andere
funktioniert unverändert. funktioniert unverändert.
</p> </p>
<p>Zum Aktivieren in die .env eintragen und den Container neu starten:</p> <p>Trag ihn unten unter „Mailserver“ ein. Ein Neustart ist nicht nötig.</p>
<pre className="overflow-x-auto rounded-md bg-muted p-3 font-mono text-xs">
{`SMTP_HOST=mail.example.de
SMTP_PORT=587
SMTP_USER=bricktrack@example.de
SMTP_PASSWORD=…
SMTP_FROM=Bricktrack <bricktrack@example.de>`}
</pre>
</div> </div>
)} )}
</CardContent> </CardContent>
+1
View File
@@ -4,4 +4,5 @@ export * from './collector';
export * from './prices'; export * from './prices';
export * from './collection'; export * from './collection';
export * from './wishlist'; export * from './wishlist';
export * from './settings';
export * from './relations'; export * from './relations';
+14
View File
@@ -0,0 +1,14 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
/**
* Settings made in the app rather than in the .env - for now the SMTP server
* for reminders. One row per topic, the value as JSON.
*
* Never part of the export: it may hold credentials (the SMTP password is
* stored encrypted, see src/lib/secret-box.ts).
*/
export const appSetting = sqliteTable('app_setting', {
key: text('key').primaryKey(),
value: text('value', { mode: 'json' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
+129
View File
@@ -0,0 +1,129 @@
import 'server-only';
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { appSetting } from '@/db/schema';
import { env } from '@/env';
import { openSecret, sealSecret } from '@/lib/secret-box';
import type { MailSettingsInput } from '@/lib/validation';
/**
* Where the SMTP server comes from.
*
* The .env wins when it names a server: an admin who configured it there
* expects exactly that. Otherwise the settings made on the reminders page
* apply. Without either, reminders stay off.
*/
export type MailSecurity = 'starttls' | 'tls' | 'none';
export type MailConfig = {
source: 'env' | 'app';
host: string;
port: number;
security: MailSecurity;
user: string | null;
password: string | null;
from: string;
};
type StoredMail = {
host: string;
port: number;
security: MailSecurity;
user: string | null;
/** Encrypted, see secret-box.ts. */
password: string | null;
from: string;
};
const KEY = 'smtp';
function readStored(): StoredMail | null {
const row = db.select().from(appSetting).where(eq(appSetting.key, KEY)).get();
return (row?.value as StoredMail | undefined) ?? null;
}
export function getMailConfig(): MailConfig | null {
if (env.isMailConfigured) {
return {
source: 'env',
host: env.SMTP_HOST!,
port: env.SMTP_PORT,
security: env.SMTP_SECURE || env.SMTP_PORT === 465 ? 'tls' : 'starttls',
user: env.SMTP_USER ?? null,
password: env.SMTP_PASSWORD ?? null,
from: env.SMTP_FROM!,
};
}
const stored = readStored();
if (!stored) return null;
return {
source: 'app',
host: stored.host,
port: stored.port,
security: stored.security,
user: stored.user,
password: stored.password ? openSecret(stored.password, env.BETTER_AUTH_SECRET) : null,
from: stored.from,
};
}
export function isMailConfigured(): boolean {
return getMailConfig() !== null;
}
/** What the settings form may show - never the password itself. */
export type MailSettingsView = {
source: 'env' | 'app' | null;
host: string;
port: number;
security: MailSecurity;
user: string;
from: string;
hasPassword: boolean;
/** A password is stored but cannot be decrypted (BETTER_AUTH_SECRET changed). */
passwordUnreadable: boolean;
};
export function getMailSettingsView(): MailSettingsView {
const config = getMailConfig();
const stored = config?.source === 'env' ? null : readStored();
return {
source: config?.source ?? null,
host: config?.host ?? '',
port: config?.port ?? 587,
security: config?.security ?? 'starttls',
user: config?.user ?? '',
from: config?.from ?? '',
hasPassword: Boolean(config?.password),
passwordUnreadable: Boolean(stored?.password && !config?.password),
};
}
export function saveMailSettings(input: MailSettingsInput): void {
const previous = readStored();
let password = previous?.password ?? null;
if (input.clearPassword) password = null;
if (input.password) password = sealSecret(input.password, env.BETTER_AUTH_SECRET);
const value: StoredMail = {
host: input.host,
port: input.port,
security: input.security,
user: input.user,
password,
from: input.from,
};
db.insert(appSetting)
.values({ key: KEY, value, updatedAt: new Date() })
.onConflictDoUpdate({ target: appSetting.key, set: { value, updatedAt: new Date() } })
.run();
}
export function deleteMailSettings(): void {
db.delete(appSetting).where(eq(appSetting.key, KEY)).run();
}
+27 -20
View File
@@ -1,32 +1,39 @@
import 'server-only'; import 'server-only';
import nodemailer, { type Transporter } from 'nodemailer'; import nodemailer, { type Transporter } from 'nodemailer';
import { env } from '@/env'; import { getMailConfig, type MailConfig } from '@/lib/mail-settings';
/** /**
* Outgoing mail. * Outgoing mail.
* *
* Entirely optional: without SMTP_HOST and SMTP_FROM nothing is scheduled and * Entirely optional: without a server - in the .env or on the reminders page -
* nothing is sent. The app never depends on mail working. * nothing is sent. The app never depends on mail working.
*/ */
let transporter: Transporter | null = null; let cached: { key: string; transporter: Transporter; from: string } | null = null;
function getTransporter(): Transporter | null { function connectionKey(config: MailConfig): string {
if (!env.isMailConfigured) return null; return JSON.stringify([config.host, config.port, config.security, config.user, config.password]);
if (transporter) return transporter; }
transporter = nodemailer.createTransport({ function getTransport(): { transporter: Transporter; from: string } | null {
host: env.SMTP_HOST, const config = getMailConfig();
port: env.SMTP_PORT, if (!config) return null;
// Settings can change at runtime now; a new connection follows them.
const key = connectionKey(config);
if (cached?.key === key) return { transporter: cached.transporter, from: config.from };
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
// Implicit TLS on 465, STARTTLS on 587 - the usual pair. // Implicit TLS on 465, STARTTLS on 587 - the usual pair.
secure: env.SMTP_SECURE || env.SMTP_PORT === 465, secure: config.security === 'tls',
auth: ignoreTLS: config.security === 'none',
env.SMTP_USER && env.SMTP_PASSWORD auth: config.user && config.password ? { user: config.user, pass: config.password } : undefined,
? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD }
: undefined,
}); });
return transporter; cached = { key, transporter, from: config.from };
return { transporter, from: config.from };
} }
export type MailResult = { ok: true } | { ok: false; error: string }; export type MailResult = { ok: true } | { ok: false; error: string };
@@ -36,14 +43,14 @@ export async function sendMail(input: {
subject: string; subject: string;
text: string; text: string;
}): Promise<MailResult> { }): Promise<MailResult> {
const transport = getTransporter(); const transport = getTransport();
if (!transport) { if (!transport) {
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' }; return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
} }
try { try {
await transport.sendMail({ await transport.transporter.sendMail({
from: env.SMTP_FROM, from: transport.from,
to: input.to, to: input.to,
subject: input.subject, subject: input.subject,
text: input.text, text: input.text,
@@ -60,13 +67,13 @@ export async function sendMail(input: {
/** Checks the connection without sending anything. */ /** Checks the connection without sending anything. */
export async function verifyMailConnection(): Promise<MailResult> { export async function verifyMailConnection(): Promise<MailResult> {
const transport = getTransporter(); const transport = getTransport();
if (!transport) { if (!transport) {
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' }; return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
} }
try { try {
await transport.verify(); await transport.transporter.verify();
return { ok: true }; return { ok: true };
} catch (error) { } catch (error) {
return { return {
+7 -7
View File
@@ -3,6 +3,7 @@ import { eq, sql } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { user, wishlist } from '@/db/schema'; import { user, wishlist } from '@/db/schema';
import { env } from '@/env'; import { env } from '@/env';
import { isMailConfigured } from './mail-settings';
import { sendMail } from './mailer'; import { sendMail } from './mailer';
import { import {
reminderSubject, reminderSubject,
@@ -59,7 +60,7 @@ function loadCandidates(): Candidate[] {
export async function runReminderJob(now = new Date()): Promise<ReminderRun> { export async function runReminderJob(now = new Date()): Promise<ReminderRun> {
const result: ReminderRun = { checked: 0, sent: 0, skipped: 0, failures: [] }; const result: ReminderRun = { checked: 0, sent: 0, skipped: 0, failures: [] };
if (!env.isMailConfigured) return result; if (!isMailConfigured()) return result;
const candidates = loadCandidates(); const candidates = loadCandidates();
result.checked = candidates.length; result.checked = candidates.length;
@@ -129,13 +130,12 @@ export function ensureReminderScheduler(): void {
* cooldown keeps that from turning into repeated mail. * cooldown keeps that from turning into repeated mail.
*/ */
export function startReminderScheduler(): void { export function startReminderScheduler(): void {
if (!env.isMailConfigured) { // Always scheduled: the mail server can be set up in the app at any time,
console.log('[reminders] Kein SMTP konfiguriert - Erinnerungen sind aus.'); // and each run checks whether one is configured.
return;
}
console.log( console.log(
`[reminders] Aktiv: Erinnerung ${env.REMINDER_DAYS_BEFORE} Tage vor dem Termin, stuendliche Pruefung.`, isMailConfigured()
? `[reminders] Aktiv: Erinnerung ${env.REMINDER_DAYS_BEFORE} Tage vor dem Termin, stuendliche Pruefung.`
: '[reminders] Noch kein SMTP-Server eingerichtet - Erinnerungen starten, sobald es einen gibt.',
); );
const tick = () => { const tick = () => {
+41
View File
@@ -0,0 +1,41 @@
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto';
/**
* Encrypts secrets that settings must keep, such as the SMTP password.
*
* AES-256-GCM with a key derived from BETTER_AUTH_SECRET: a copy of the
* database alone does not reveal the password. Changing BETTER_AUTH_SECRET
* makes stored secrets unreadable - `openSecret` then returns null and the
* password has to be entered again. Pure apart from node:crypto, so tested.
*/
const VERSION = 'v1';
function deriveKey(masterSecret: string): Buffer {
return Buffer.from(hkdfSync('sha256', masterSecret, 'bricktrack', 'app-setting-secret', 32));
}
export function sealSecret(plain: string, masterSecret: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', deriveKey(masterSecret), iv);
const encrypted = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [VERSION, iv.toString('base64'), tag.toString('base64'), encrypted.toString('base64')].join(':');
}
export function openSecret(sealed: string, masterSecret: string): string | null {
const [version, iv, tag, encrypted] = sealed.split(':');
if (version !== VERSION || !iv || !tag || encrypted === undefined) return null;
try {
const decipher = createDecipheriv('aes-256-gcm', deriveKey(masterSecret), Buffer.from(iv, 'base64'));
decipher.setAuthTag(Buffer.from(tag, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(encrypted, 'base64')),
decipher.final(),
]).toString('utf8');
} catch {
// Wrong key or tampered value: treat as "no password stored".
return null;
}
}
+33
View File
@@ -219,6 +219,39 @@ export const reservationSchema = z.object({
export type ManufacturerInput = z.infer<typeof manufacturerSchema>; export type ManufacturerInput = z.infer<typeof manufacturerSchema>;
export type ThemeInput = z.infer<typeof themeSchema>; export type ThemeInput = z.infer<typeof themeSchema>;
// ---------------------------------------------------------------------------
// Mail (reminders)
// ---------------------------------------------------------------------------
export const mailSecurityEnum = z.enum(['starttls', 'tls', 'none']);
export const mailSettingsSchema = z.object({
host: z
.string()
.trim()
.min(1, 'Bitte den SMTP-Server angeben.')
.max(255)
.regex(/^[a-z0-9.-]+$/i, 'Nur der Servername, z. B. mail.example.de – ohne https:// und Port.'),
port: z.coerce.number().int().min(1, 'Ungültiger Port.').max(65535, 'Ungültiger Port.').default(587),
security: mailSecurityEnum.default('starttls'),
user: optionalText(255),
/** Empty keeps the stored password; see clearPassword to remove it. Not trimmed. */
password: z
.string()
.max(500)
.nullish()
.transform((value) => (value ? value : null)),
clearPassword: checkbox,
from: z
.string()
.trim()
.min(3, 'Bitte einen Absender angeben.')
.max(255)
.refine((value) => /@[^@\s>]+\.[^@\s>]+/.test(value), 'Der Absender braucht eine E-Mail-Adresse.'),
});
export type MailSettingsInput = z.infer<typeof mailSettingsSchema>;
export type LocationInput = z.infer<typeof locationSchema>; export type LocationInput = z.infer<typeof locationSchema>;
export type SetInput = z.infer<typeof setSchema>; export type SetInput = z.infer<typeof setSchema>;
export type CollectionItemInput = z.infer<typeof collectionItemSchema>; export type CollectionItemInput = z.infer<typeof collectionItemSchema>;
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { openSecret, sealSecret } from '@/lib/secret-box';
import { mailSettingsSchema } from '@/lib/validation';
const SECRET = 'test-secret-0123456789-abcdefghijklmnop';
describe('Passwort verschluesselt speichern', () => {
it('kommt mit demselben Schluessel unveraendert zurueck', () => {
const sealed = sealSecret('geheim mit Leerzeichen ', SECRET);
expect(sealed).not.toContain('geheim');
expect(openSecret(sealed, SECRET)).toBe('geheim mit Leerzeichen ');
});
it('verschluesselt dasselbe Passwort jedes Mal anders', () => {
expect(sealSecret('x', SECRET)).not.toBe(sealSecret('x', SECRET));
});
it('liefert nichts bei anderem Schluessel oder veraendertem Wert', () => {
const sealed = sealSecret('geheim', SECRET);
expect(openSecret(sealed, 'ein-anderes-secret-0123456789-abcdefgh')).toBeNull();
const [version, iv, tag, data] = sealed.split(':');
const flipped = Buffer.from(data!, 'base64');
flipped[0] = flipped[0]! ^ 0xff;
expect(openSecret([version, iv, tag, flipped.toString('base64')].join(':'), SECRET)).toBeNull();
expect(openSecret('kaputt', SECRET)).toBeNull();
});
});
describe('SMTP-Eingaben pruefen', () => {
const valid = {
host: 'mail.example.de',
port: '587',
security: 'starttls',
user: 'bricktrack@example.de',
password: ' mit Rand ',
from: 'Bricktrack <bricktrack@example.de>',
};
it('nimmt gueltige Angaben und laesst das Passwort, wie es ist', () => {
const parsed = mailSettingsSchema.parse(valid);
expect(parsed.port).toBe(587);
expect(parsed.password).toBe(' mit Rand ');
expect(parsed.clearPassword).toBe(false);
});
it('macht aus einem leeren Passwort "unveraendert"', () => {
expect(mailSettingsSchema.parse({ ...valid, password: '' }).password).toBeNull();
});
it('lehnt URLs, Ports und Absender ohne Adresse ab', () => {
expect(mailSettingsSchema.safeParse({ ...valid, host: 'https://mail.example.de' }).success).toBe(false);
expect(mailSettingsSchema.safeParse({ ...valid, host: 'mail.example.de:587' }).success).toBe(false);
expect(mailSettingsSchema.safeParse({ ...valid, port: '70000' }).success).toBe(false);
expect(mailSettingsSchema.safeParse({ ...valid, from: 'Bricktrack' }).success).toBe(false);
expect(mailSettingsSchema.safeParse({ ...valid, security: 'ssl3' }).success).toBe(false);
});
});