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>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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') };
|
||||
}
|
||||
Reference in New Issue
Block a user