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>
This commit is contained in:
unknown
2026-10-25 22:40:36 +01:00
co-authored by Claude Opus 5.5
parent ebdd6c313a
commit 01bbb3ba9a
15 changed files with 2731 additions and 44 deletions
+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,
"tag": "0003_powerful_sally_floyd",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1792963951232,
"tag": "0004_modern_agent_brand",
"breakpoints": true
}
]
}
@@ -1,9 +1,23 @@
'use server';
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 {
deleteMailSettings,
getMailConfig,
isMailConfigured,
saveMailSettings,
} from '@/lib/mail-settings';
import { sendMail, verifyMailConnection } from '@/lib/mailer';
import { mailSettingsSchema } from '@/lib/validation';
import { runReminderJob, type ReminderRun } from '@/lib/reminder-job';
import { requireAdmin } from '@/lib/session';
@@ -17,7 +31,7 @@ export async function testMailConnection(): Promise<ActionResult> {
export async function sendTestMail(): Promise<ActionResult> {
const admin = await requireAdmin();
if (!env.isMailConfigured) {
if (!isMailConfigured()) {
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>> {
await requireAdmin();
if (!env.isMailConfigured) {
if (!isMailConfigured()) {
return actionError('Es ist kein SMTP-Server konfiguriert.');
}
@@ -49,3 +63,26 @@ export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> {
revalidatePath('/verwaltung/erinnerungen');
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();
}
+12 -5
View File
@@ -3,10 +3,12 @@ import { eq, sql } from 'drizzle-orm';
import { db } from '@/db';
import { wishlist } from '@/db/schema';
import { env } from '@/env';
import { MailSettingsForm } from '@/components/verwaltung/mail-settings-form';
import {
ReminderSettings,
type ReminderListRow,
} from '@/components/verwaltung/reminder-settings';
import { getMailSettingsView } from '@/lib/mail-settings';
import { daysUntil } from '@/lib/reminders';
import { requireAdmin } from '@/lib/session';
@@ -41,11 +43,16 @@ export default async function ErinnerungenPage() {
}))
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
const mail = getMailSettingsView();
return (
<ReminderSettings
configured={env.isMailConfigured}
daysBefore={env.REMINDER_DAYS_BEFORE}
lists={lists}
/>
<div className="space-y-6">
<ReminderSettings
configured={mail.source !== null}
daysBefore={env.REMINDER_DAYS_BEFORE}
lists={lists}
/>
<MailSettingsForm view={mail} />
</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">
<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.
</p>
<p>Zum Aktivieren in die .env eintragen und den Container neu starten:</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>
<p>Trag ihn unten unter „Mailserver“ ein. Ein Neustart ist nicht nötig.</p>
</div>
)}
</CardContent>
+1
View File
@@ -4,4 +4,5 @@ export * from './collector';
export * from './prices';
export * from './collection';
export * from './wishlist';
export * from './settings';
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 nodemailer, { type Transporter } from 'nodemailer';
import { env } from '@/env';
import { getMailConfig, type MailConfig } from '@/lib/mail-settings';
/**
* 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.
*/
let transporter: Transporter | null = null;
let cached: { key: string; transporter: Transporter; from: string } | null = null;
function getTransporter(): Transporter | null {
if (!env.isMailConfigured) return null;
if (transporter) return transporter;
function connectionKey(config: MailConfig): string {
return JSON.stringify([config.host, config.port, config.security, config.user, config.password]);
}
transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
function getTransport(): { transporter: Transporter; from: string } | null {
const config = getMailConfig();
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.
secure: env.SMTP_SECURE || env.SMTP_PORT === 465,
auth:
env.SMTP_USER && env.SMTP_PASSWORD
? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD }
: undefined,
secure: config.security === 'tls',
ignoreTLS: config.security === 'none',
auth: config.user && config.password ? { user: config.user, pass: config.password } : undefined,
});
return transporter;
cached = { key, transporter, from: config.from };
return { transporter, from: config.from };
}
export type MailResult = { ok: true } | { ok: false; error: string };
@@ -36,14 +43,14 @@ export async function sendMail(input: {
subject: string;
text: string;
}): Promise<MailResult> {
const transport = getTransporter();
const transport = getTransport();
if (!transport) {
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
}
try {
await transport.sendMail({
from: env.SMTP_FROM,
await transport.transporter.sendMail({
from: transport.from,
to: input.to,
subject: input.subject,
text: input.text,
@@ -60,13 +67,13 @@ export async function sendMail(input: {
/** Checks the connection without sending anything. */
export async function verifyMailConnection(): Promise<MailResult> {
const transport = getTransporter();
const transport = getTransport();
if (!transport) {
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
}
try {
await transport.verify();
await transport.transporter.verify();
return { ok: true };
} catch (error) {
return {
+7 -7
View File
@@ -3,6 +3,7 @@ import { eq, sql } from 'drizzle-orm';
import { db } from '@/db';
import { user, wishlist } from '@/db/schema';
import { env } from '@/env';
import { isMailConfigured } from './mail-settings';
import { sendMail } from './mailer';
import {
reminderSubject,
@@ -59,7 +60,7 @@ function loadCandidates(): Candidate[] {
export async function runReminderJob(now = new Date()): Promise<ReminderRun> {
const result: ReminderRun = { checked: 0, sent: 0, skipped: 0, failures: [] };
if (!env.isMailConfigured) return result;
if (!isMailConfigured()) return result;
const candidates = loadCandidates();
result.checked = candidates.length;
@@ -129,13 +130,12 @@ export function ensureReminderScheduler(): void {
* cooldown keeps that from turning into repeated mail.
*/
export function startReminderScheduler(): void {
if (!env.isMailConfigured) {
console.log('[reminders] Kein SMTP konfiguriert - Erinnerungen sind aus.');
return;
}
// Always scheduled: the mail server can be set up in the app at any time,
// and each run checks whether one is configured.
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 = () => {
+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 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 SetInput = z.infer<typeof setSchema>;
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);
});
});