feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste
- E-Mail-Planer: Anspruch vor dem Senden (genau eine Mail je Faelligkeit, hoechstens 3 Versuche), Systemlesen nur fuer die Kandidatenabfrage - MailService.sendReminderEmail (Berliner Zeit, nur Text), GET /reminders/email-status, Haken im Formular mit Erklaerung - Zugriffsklassifikation und Erlaubnisliste fuer forSystem nachgezogen, Aenderungsliste und Anwenderanleitung Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -248,3 +248,69 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MailService.sendReminderEmail (quick-260929-if2, E-04/E-07, T-IF2-05)', () => {
|
||||
const dueAt = new Date('2026-10-05T12:30:00.000Z'); // 14:30 in Europe/Berlin (Sommerzeit)
|
||||
|
||||
function make() {
|
||||
return new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any);
|
||||
}
|
||||
|
||||
it('sendet Betreff "Erinnerung: <Titel>" mit Berliner Zeit, Titel, Beschreibung und App-Adresse; true bei Erfolg', async () => {
|
||||
const ok = await make().sendReminderEmail('t1', 'alice@a.example.invalid', {
|
||||
title: 'Zahnarzt',
|
||||
description: 'Kartenlesegeraet mitnehmen',
|
||||
dueAt,
|
||||
});
|
||||
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('Erinnerung: Zahnarzt');
|
||||
expect(sent.html).toBeUndefined();
|
||||
expect(sent.text).toContain('14:30 Uhr');
|
||||
expect(sent.text).toContain('Zahnarzt');
|
||||
expect(sent.text).toContain('Kartenlesegeraet mitnehmen');
|
||||
expect(sent.text).toContain('http://localhost:3000');
|
||||
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('entfernt CR/LF aus dem Betreff und kuerzt auf 150 Zeichen', async () => {
|
||||
await make().sendReminderEmail('t1', 'a@a.example.invalid', {
|
||||
title: `Zeile1\r\nBcc: boese@example.invalid ${'x'.repeat(300)}`,
|
||||
description: '',
|
||||
dueAt,
|
||||
});
|
||||
const sent = mockSendMail.mock.calls[0][0] as any;
|
||||
expect(sent.subject).not.toMatch(/[\r\n]/);
|
||||
expect(sent.subject.length).toBe(150);
|
||||
expect(sent.subject.startsWith('Erinnerung: Zeile1 Bcc:')).toBe(true);
|
||||
});
|
||||
|
||||
it('laesst die Beschreibung weg, wenn sie leer ist', async () => {
|
||||
await make().sendReminderEmail('t1', 'a@a.example.invalid', { title: 'T', description: ' ', dueAt });
|
||||
const sent = mockSendMail.mock.calls[0][0] as any;
|
||||
expect(sent.text.split('\n')).toEqual([
|
||||
'Guten Tag,',
|
||||
'',
|
||||
expect.stringContaining('eine Erinnerung für'),
|
||||
'',
|
||||
'T',
|
||||
'',
|
||||
'http://localhost:3000',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gibt false zurueck (wirft nicht), wenn der Transport scheitert', async () => {
|
||||
mockSendMail = vi.fn(async () => {
|
||||
throw new Error('SMTP down');
|
||||
});
|
||||
const ok = await make().sendReminderEmail('t1', 'a@a.example.invalid', {
|
||||
title: 'T',
|
||||
description: '',
|
||||
dueAt,
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,4 +316,51 @@ export class MailService {
|
||||
|
||||
await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Welcome');
|
||||
}
|
||||
|
||||
/**
|
||||
* Erinnerungs-E-Mail (quick-260929-if2): eine Mail je faelliger Erinnerung an
|
||||
* die eigene Adresse des Besitzers, ueber den SMTP-Transport seines Mandanten.
|
||||
* Gibt `true` zurueck, wenn der Versand gelang, `false` bei einem
|
||||
* Transportfehler — der Planer (`ReminderMailScheduler`) entscheidet daran,
|
||||
* ob er den Anspruch wieder freigibt (E-04). Wirft nie.
|
||||
*
|
||||
* Nur Text, kein HTML (T-IF2-05, kein HTML-Einschleusen). Der Betreff hat
|
||||
* Zeilenumbrueche durch Leerzeichen ersetzt (Header-Einschleusung) und ist
|
||||
* auf 150 Zeichen gekuerzt. Die Zeit steht in `Europe/Berlin` (E-07): im
|
||||
* Benutzer ist keine Zeitzone gespeichert, das Haus arbeitet in deutscher Zeit.
|
||||
*/
|
||||
async sendReminderEmail(
|
||||
tenantId: string,
|
||||
to: string,
|
||||
reminder: { title: string; description: string; dueAt: Date },
|
||||
): Promise<boolean> {
|
||||
const when = `${new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
dateStyle: 'full',
|
||||
timeStyle: 'short',
|
||||
}).format(reminder.dueAt)} Uhr`;
|
||||
const subject = `Erinnerung: ${reminder.title}`.replace(/[\r\n]+/g, ' ').slice(0, 150);
|
||||
const lines = [
|
||||
'Guten Tag,',
|
||||
'',
|
||||
`Sie haben in Tessera eine Erinnerung für ${when} gesetzt:`,
|
||||
'',
|
||||
reminder.title,
|
||||
];
|
||||
if (reminder.description.trim() !== '') {
|
||||
lines.push('', reminder.description);
|
||||
}
|
||||
lines.push('', this.appUrl);
|
||||
|
||||
try {
|
||||
await this.deliver(tenantId, { to, subject, text: lines.join('\n') }, 'Reminder');
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send Reminder email to ${to}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user