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:
co-authored by
Claude Opus 5.5
parent
ebdd6c313a
commit
01bbb3ba9a
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<ReminderSettings
|
||||
configured={env.isMailConfigured}
|
||||
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>
|
||||
|
||||
@@ -4,4 +4,5 @@ export * from './collector';
|
||||
export * from './prices';
|
||||
export * from './collection';
|
||||
export * from './wishlist';
|
||||
export * from './settings';
|
||||
export * from './relations';
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user