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,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>;
|
||||
|
||||
Reference in New Issue
Block a user