feat(erinnerungen): e-mail, wenn ein wunschlisten-termin naeher rueckt

Ruecken Termin und offene Wuensche zusammen, geht eine Nachricht an den
Besitzer der Liste - standardmaessig 14 Tage vorher, hoechstens einmal
pro Woche je Liste. Ist alles reserviert, bleibt es still: eine
Erinnerung ohne Handlungsbedarf ist nur Laerm.

Der Zeitplan haengt an Next' instrumentation-Hook und prueft stuendlich,
nicht taeglich - so kann ein Neustart keinen Tag ueberspringen, und die
Sperrfrist je Liste verhindert trotzdem Mehrfachversand. Der Zeitstempel
wird erst nach erfolgreichem Versand gesetzt, damit ein Fehlversuch
wiederholt wird.

Ohne SMTP_HOST und SMTP_FROM ist die Funktion komplett aus: kein
Zeitplan, kein Versand, und die Verwaltungsseite erklaert stattdessen,
was in die .env gehoert. Mit Konfiguration gibt es dort Knoepfe fuer
Verbindungstest, Testnachricht und sofortige Pruefung.

12 Tests fuer die Entscheidungsregeln. Einer davon hat einen echten
Fehler gefunden: die Sperrfrist rechnete mit umgedrehtem Vorzeichen und
haette jede Erinnerung dauerhaft unterdrueckt.

Refs Phase 3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-07 09:22:29 +02:00
co-authored by Claude Opus 5
parent 91fb66b13b
commit 0d08df0bff
17 changed files with 2884 additions and 1 deletions
+77
View File
@@ -0,0 +1,77 @@
import 'server-only';
import nodemailer, { type Transporter } from 'nodemailer';
import { env } from '@/env';
/**
* Outgoing mail.
*
* Entirely optional: without SMTP_HOST and SMTP_FROM nothing is scheduled and
* nothing is sent. The app never depends on mail working.
*/
let transporter: Transporter | null = null;
function getTransporter(): Transporter | null {
if (!env.isMailConfigured) return null;
if (transporter) return transporter;
transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_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,
});
return transporter;
}
export type MailResult = { ok: true } | { ok: false; error: string };
export async function sendMail(input: {
to: string;
subject: string;
text: string;
}): Promise<MailResult> {
const transport = getTransporter();
if (!transport) {
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
}
try {
await transport.sendMail({
from: env.SMTP_FROM,
to: input.to,
subject: input.subject,
text: input.text,
});
return { ok: true };
} catch (error) {
console.error('[mail] Versand fehlgeschlagen', error);
return {
ok: false,
error: error instanceof Error ? error.message : 'Der Versand ist fehlgeschlagen.',
};
}
}
/** Checks the connection without sending anything. */
export async function verifyMailConnection(): Promise<MailResult> {
const transport = getTransporter();
if (!transport) {
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
}
try {
await transport.verify();
return { ok: true };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : 'Der Server antwortet nicht.',
};
}
}