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:
2026-10-02 15:23:34 +02:00
parent 6829c44464
commit faed0d760f
23 changed files with 1796 additions and 103 deletions
+39
View File
@@ -319,6 +319,45 @@ describe('MailService.sendReminderEmail (quick-260929-if2, E-04/E-07, T-IF2-05)'
});
});
describe('MailService.sendNextcloudAlertEmail (quick-261002-kxc, L-04/L-05)', () => {
const input = {
kind: 'down' as const,
customerName: 'Kunde A',
baseUrl: 'https://cloud.a.de',
rating: { level: 'red' as const, reason: 'unreachable' as const, updateTo: null, eolDate: null, cycle: null },
errorKind: 'network',
errorDetail: 'ECONNREFUSED',
at: new Date('2026-10-02T12:30:00.000Z'),
};
function make() {
return new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any);
}
it('sendet Betreff, Text mit Berliner Zeit und Modul-Link ueber den Transport des Mandanten; true bei Erfolg', async () => {
const ok = await make().sendNextcloudAlertEmail('t1', 'alice@a.example.invalid', input);
expect(ok).toBe(true);
const sent = mockSendMail.mock.calls[0][0] as any;
expect(sent.to).toBe('alice@a.example.invalid');
expect(sent.from).toBe('noreply@a.example.invalid');
expect(sent.subject).toBe('Nextcloud Kunde A: nicht erreichbar');
expect(sent.html).toBeUndefined();
expect(sent.text).toContain('14:30 Uhr');
expect(sent.text).toContain('Verbindung abgelehnt');
expect(sent.text).toContain('http://localhost:3000/modules/nextcloud-status');
expect(mockClose).toHaveBeenCalledTimes(1);
});
it('gibt false zurueck (wirft nicht), wenn der Transport scheitert', async () => {
mockSendMail = vi.fn(async () => {
throw new Error('SMTP down');
});
const ok = await make().sendNextcloudAlertEmail('t1', 'a@a.example.invalid', input);
expect(ok).toBe(false);
expect(mockClose).toHaveBeenCalledTimes(1);
});
});
describe('MailService.sendWelcomeMail / hasConfiguredTransport (Willkommensmail)', () => {
it('haengt das Kopfbild als CID-Anhang an, reicht HTML und Text durch und wirft Transportfehler nach aussen', async () => {
const service = new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any);
+29
View File
@@ -4,6 +4,10 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as nodemailer from 'nodemailer';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import {
buildNextcloudAlertMail,
type NextcloudAlertMailInput,
} from '../nextcloud-status/nextcloud-alert-mail';
import { SettingsService } from '../settings/settings.service';
/**
@@ -406,4 +410,29 @@ export class MailService {
return false;
}
}
/**
* Benachrichtigung des Moduls Nextcloud-Status (quick-261002-kxc, L-04/L-05):
* Stoerung oder "wieder in Ordnung" einer Cloud. Text und Betreff baut der
* reine Baustein `buildNextcloudAlertMail` (nur Deutsch, wie die
* Erinnerungsmails). Liefert true bei Erfolg, false (und ein Protokolleintrag)
* bei Transportfehlern — der Aufrufer entscheidet ueber Wiederholungen.
*/
async sendNextcloudAlertEmail(
tenantId: string,
to: string,
input: NextcloudAlertMailInput,
): Promise<boolean> {
const { subject, text } = buildNextcloudAlertMail(input, this.appUrl);
try {
await this.deliver(tenantId, { to, subject, text }, 'NextcloudAlert');
return true;
} catch (error) {
this.logger.error(
`Failed to send NextcloudAlert email to ${to}`,
error instanceof Error ? error.stack : String(error),
);
return false;
}
}
}