Files
tessera-ctl/apps/api/src/nextcloud-status/nextcloud-alert-mail.ts
T
schalli faed0d760f feat(nextcloud-status): Benachrichtigung abonnieren und Mail bei Störung
- Glocke je Kachel (persönlich, Benutzen-Ebene), Tabelle NextcloudAlertSubscription mit RLS
- Zwei-Fehlschläge-Regel und gemeldeter Zustand auf der Zeile, Anspruch vor dem Mailversand
- Mail (nur Deutsch) über MailService, bis zu drei Versuche, Zugriff beim Senden erneut geprüft
- Fehlercodes in der Mail als lesbarer Hinweis

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 15:23:34 +02:00

146 lines
4.9 KiB
TypeScript

/**
* nextcloud-alert-mail — reiner Baustein der Benachrichtigungsmail
* (quick-261002-kxc, L-05, D-K6). Kein Nest, kein Zugriff auf die Uhr oder
* den Versand: Betreff und Klartext entstehen aus den Eingaben. Die Mail ist
* wie die Erinnerungsmails nur Deutsch, Zeit in Europe/Berlin.
*/
import type { NextcloudRating } from './nextcloud-rating';
export interface NextcloudAlertMailInput {
kind: 'down' | 'up';
customerName: string;
baseUrl: string;
rating: NextcloudRating;
errorKind: string | null;
errorDetail: string | null;
at: Date;
}
const SUBJECT_MAX = 150;
const CERT_NAME_CODES = new Set(['ERR_TLS_CERT_ALTNAME_INVALID', 'HOSTNAME_MISMATCH']);
const CERT_EXPIRED_CODES = new Set(['CERT_HAS_EXPIRED']);
const CERT_UNTRUSTED_CODES = new Set([
'DEPTH_ZERO_SELF_SIGNED_CERT',
'SELF_SIGNED_CERT_IN_CHAIN',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'CERT_UNTRUSTED',
'CERT_NOT_YET_VALID',
'CERT_REVOKED',
]);
const DNS_CODES = new Set(['ENOTFOUND', 'EAI_AGAIN']);
const TIMEOUT_CODES = new Set(['ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT']);
/**
* Uebersetzt Fehlerart und Kurzkennung eines fehlgeschlagenen Abrufs in einen
* lesbaren deutschen Hinweis. Die Rohkennung bleibt in der Datenbank; die
* Kachel (Web) bildet dieselben Regeln ab.
*/
export function describeCheckError(
errorKind: string | null | undefined,
errorDetail: string | null | undefined,
): string {
const detail = errorDetail ?? '';
if (errorKind === 'tls') {
if (CERT_NAME_CODES.has(detail)) return 'Zertifikat passt nicht zur Adresse';
if (CERT_EXPIRED_CODES.has(detail)) return 'Zertifikat abgelaufen';
if (CERT_UNTRUSTED_CODES.has(detail)) return 'Zertifikat nicht vertrauenswürdig';
return 'Verbindungsfehler';
}
if (errorKind === 'timeout') return 'Zeitüberschreitung';
if (errorKind === 'http-status') {
const match = /^HTTP (\d{3})$/.exec(detail);
return match ? `Server antwortet mit Fehler ${match[1]}` : 'Verbindungsfehler';
}
if (errorKind === 'network') {
if (DNS_CODES.has(detail)) return 'Adresse nicht gefunden';
if (detail === 'ECONNREFUSED') return 'Verbindung abgelehnt';
if (TIMEOUT_CODES.has(detail)) return 'Zeitüberschreitung';
}
return 'Verbindungsfehler';
}
function subjectWording(input: NextcloudAlertMailInput): string {
if (input.kind === 'up') return 'wieder in Ordnung';
switch (input.rating.reason) {
case 'invalid-response':
return 'keine gültige Antwort';
case 'maintenance':
return 'im Wartungsmodus';
case 'needs-db-upgrade':
return 'Datenbank-Aktualisierung ausstehend';
case 'eol-passed':
return 'Support abgelaufen';
default:
return 'nicht erreichbar';
}
}
/** 'YYYY-MM-DD' als 'TT.MM.JJJJ'. */
function germanDay(day: string): string {
const [y, m, d] = day.split('-');
return `${d}.${m}.${y}`;
}
function reasonLine(input: NextcloudAlertMailInput): string {
const { rating } = input;
if (input.kind === 'up') {
if (rating.reason === 'update-available' && rating.updateTo) {
return `Aktueller Stand: Update auf ${rating.updateTo} verfügbar`;
}
if (rating.reason === 'eol-soon' && rating.eolDate) {
return `Aktueller Stand: Support endet am ${germanDay(rating.eolDate)}`;
}
return 'Aktueller Stand: Aktuell';
}
switch (rating.reason) {
case 'invalid-response':
return 'Grund: Keine gültige Nextcloud-Antwort';
case 'maintenance':
return 'Grund: Wartungsmodus eingeschaltet';
case 'needs-db-upgrade':
return 'Grund: Datenbank-Aktualisierung ausstehend';
case 'eol-passed':
return rating.eolDate
? `Grund: Support abgelaufen seit ${germanDay(rating.eolDate)}`
: 'Grund: Support abgelaufen';
default:
return `Grund: Nicht erreichbar (${describeCheckError(input.errorKind, input.errorDetail)})`;
}
}
export function buildNextcloudAlertMail(
input: NextcloudAlertMailInput,
appUrl: string,
): { subject: string; text: string } {
const subject = `Nextcloud ${input.customerName}: ${subjectWording(input)}`
.replace(/[\r\n]+/g, ' ')
.slice(0, SUBJECT_MAX);
const when = `${new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'full',
timeStyle: 'short',
}).format(input.at)} Uhr`;
const name = input.customerName.replace(/[\r\n]+/g, ' ');
const sentence =
input.kind === 'down'
? `die Nextcloud "${name}" hat seit ${when} ein Problem.`
: `die Nextcloud "${name}" ist seit ${when} wieder in Ordnung.`;
const lines = [
'Guten Tag,',
'',
sentence,
'',
reasonLine(input),
`Adresse: ${input.baseUrl}`,
`Zeitpunkt: ${when}`,
'',
`Zum Modul: ${appUrl}/modules/nextcloud-status`,
'',
'Sie erhalten diese Nachricht, weil für diese Cloud "Benachrichtigen" eingeschaltet ist. Mit der Glocke auf der Kachel können Sie das jederzeit ausschalten.',
];
return { subject, text: lines.join('\n') };
}