feat: add daily digest email notification module
Build and Push Multi-Platform Images / build-and-push (push) Successful in 50s

- New DailyDigestModule with scheduled summary email for open dashboard items
- Extract StatsService from StatsController for reuse in digest
- Add DailyDigestEnabled, UserEmail, UserPreferredUsername to UserSettings entity
- Sync email/username from OIDC token on each get/update call
- Add dailyDigestEnabled to UserSettingsDto and update API
- Notifications tab in UserSettingsPage with enable toggle and "Jetzt senden" button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 15:57:10 +02:00
parent 029d5b351f
commit 52438ee11f
12 changed files with 366 additions and 86 deletions
+2
View File
@@ -20,6 +20,7 @@ import { UserSettingsModule } from './user-settings/user-settings.module';
import { LabelPrintAgentModule } from './label-print-agent/label-print-agent.module';
import { AgrarmonitorModule } from './agrarmonitor/agrarmonitor.module';
import { FreigabeModule } from './freigabe/freigabe.module';
import { DailyDigestModule } from './daily-digest/daily-digest.module';
import * as path from 'path';
@Module({
@@ -51,6 +52,7 @@ import * as path from 'path';
LabelPrintAgentModule,
AgrarmonitorModule,
FreigabeModule,
DailyDigestModule,
],
})
export class AppModule {}
@@ -0,0 +1,22 @@
import { Controller, Post, Request, HttpCode } from '@nestjs/common';
import { DailyDigestService } from './daily-digest.service';
@Controller('api/daily-digest')
export class DailyDigestController {
constructor(private readonly dailyDigestService: DailyDigestService) {}
@Post('send-now')
@HttpCode(200)
async sendNow(@Request() req: any) {
const { userId, email, preferredUsername } = req.user;
if (!email) {
return { ok: false, error: 'Keine E-Mail-Adresse im Benutzerprofil gefunden.' };
}
try {
await this.dailyDigestService.sendDigestForUser(userId, email, preferredUsername);
return { ok: true };
} catch (err: any) {
return { ok: false, error: err.message };
}
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { DailyDigestService } from './daily-digest.service';
import { DailyDigestController } from './daily-digest.controller';
import { StatsModule } from '../stats/stats.module';
import { UserSettingsModule } from '../user-settings/user-settings.module';
import { PostprocessingModule } from '../postprocessing/postprocessing.module';
@Module({
imports: [StatsModule, UserSettingsModule, PostprocessingModule],
providers: [DailyDigestService],
controllers: [DailyDigestController],
})
export class DailyDigestModule {}
@@ -0,0 +1,141 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { StatsService, DashboardCounts } from '../stats/stats.service';
import { UserSettingsService } from '../user-settings/user-settings.service';
import { MailService } from '../postprocessing/mail.service';
@Injectable()
export class DailyDigestService {
private readonly logger = new Logger(DailyDigestService.name);
constructor(
private readonly statsService: StatsService,
private readonly userSettingsService: UserSettingsService,
private readonly mailService: MailService,
) {}
async sendDigestForUser(userId: string, email: string, preferredUsername?: string) {
const today = new Date().toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
const counts = await this.statsService.getDashboardCounts(preferredUsername);
const html = buildDigestHtml(counts, today);
const plainText = buildDigestPlainText(counts, today);
await this.mailService.sendMail({
to: email,
subject: `Paperless Manager Tagesübersicht ${today}`,
body: plainText,
html,
});
this.logger.log(`Manueller Digest gesendet an ${email} (userId: ${userId})`);
}
@Cron(process.env.DAILY_DIGEST_CRON || '0 7 * * *')
async sendDailyDigests() {
this.logger.log('Starte täglichen E-Mail-Digest...');
const subscribers = await this.userSettingsService.findAllDigestSubscribers();
if (subscribers.length === 0) {
this.logger.log('Keine Abonnenten für den täglichen Digest.');
return;
}
const today = new Date().toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
for (const sub of subscribers) {
try {
const counts = await this.statsService.getDashboardCounts(sub.UserPreferredUsername ?? undefined);
const html = buildDigestHtml(counts, today);
const plainText = buildDigestPlainText(counts, today);
await this.mailService.sendMail({
to: sub.UserEmail!,
subject: `Paperless Manager Tagesübersicht ${today}`,
body: plainText,
html,
});
this.logger.log(`Digest gesendet an ${sub.UserEmail}`);
} catch (err) {
this.logger.error(`Fehler beim Senden des Digests an ${sub.UserEmail}: ${err.message}`);
}
}
}
}
function countColor(n: number): string {
if (n === 0) return '#16a34a';
if (n <= 5) return '#d97706';
return '#dc2626';
}
function buildDigestHtml(counts: DashboardCounts, today: string): string {
const rows: { label: string; count: number }[] = [
{ label: 'Eingangsbox (Scanner)', count: counts.inbox },
{ label: 'Posteingang', count: counts.posteingang },
{ label: 'Manuell bearbeiten', count: counts.manuell },
{ label: 'Mailpostfach', count: counts.mailpostfach },
{ label: 'In Agrarmonitor', count: counts.agrarmonitor },
];
const tableRows = rows
.map(
r => `
<tr>
<td style="padding:10px 16px;border-bottom:1px solid #e5e7eb;font-family:sans-serif;font-size:14px;color:#374151;">${r.label}</td>
<td style="padding:10px 16px;border-bottom:1px solid #e5e7eb;text-align:center;font-family:sans-serif;font-size:16px;font-weight:bold;color:${countColor(r.count)};">${r.count}</td>
</tr>`,
)
.join('');
return `<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f9fafb;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;padding:32px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);">
<tr>
<td style="background:#1d4ed8;padding:24px 32px;">
<h1 style="margin:0;font-family:sans-serif;font-size:20px;color:#ffffff;font-weight:600;">Paperless Manager</h1>
<p style="margin:4px 0 0;font-family:sans-serif;font-size:14px;color:#bfdbfe;">Tagesübersicht ${today}</p>
</td>
</tr>
<tr>
<td style="padding:24px 32px 8px;">
<p style="margin:0 0 16px;font-family:sans-serif;font-size:14px;color:#6b7280;">Hier ist Ihre aktuelle Übersicht der offenen Vorgänge:</p>
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:6px;overflow:hidden;">
<thead>
<tr style="background:#f3f4f6;">
<th style="padding:10px 16px;text-align:left;font-family:sans-serif;font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:0.05em;">Bereich</th>
<th style="padding:10px 16px;text-align:center;font-family:sans-serif;font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:0.05em;">Offen</th>
</tr>
</thead>
<tbody>${tableRows}</tbody>
</table>
</td>
</tr>
<tr>
<td style="padding:16px 32px 32px;">
<p style="margin:0;font-family:sans-serif;font-size:12px;color:#9ca3af;">
Diese E-Mail wird täglich automatisch von Paperless Manager versendet.<br>
Sie können den Digest in den Benutzereinstellungen deaktivieren.
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
function buildDigestPlainText(counts: DashboardCounts, today: string): string {
return `Paperless Manager Tagesübersicht ${today}
Offene Vorgänge:
Eingangsbox (Scanner): ${counts.inbox}
Posteingang: ${counts.posteingang}
Manuell bearbeiten: ${counts.manuell}
Mailpostfach: ${counts.mailpostfach}
In Agrarmonitor: ${counts.agrarmonitor}
Diese E-Mail wird täglich automatisch von Paperless Manager versendet.
`;
}
@@ -34,4 +34,13 @@ export class UserSettings {
@Column({ type: 'json', nullable: true })
EmailRecipientHistory!: string[] | null;
@Column({ type: 'boolean', default: false })
DailyDigestEnabled!: boolean;
@Column({ type: 'varchar', length: 255, nullable: true })
UserEmail!: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
UserPreferredUsername!: string | null;
}
@@ -1,90 +1,12 @@
import { Controller, Get, Request, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Email } from '../database/entities/email.entity';
import { InboxService } from '../inbox/inbox.service';
import { PaperlessService } from '../paperless/paperless.service';
import { ConfigService } from '@nestjs/config';
import { Controller, Get, Request } from '@nestjs/common';
import { StatsService } from './stats.service';
@Controller('api/stats')
export class StatsController {
private readonly logger = new Logger(StatsController.name);
constructor(
@InjectRepository(Email) private readonly emailRepo: Repository<Email>,
private readonly inboxService: InboxService,
private readonly paperlessService: PaperlessService,
private readonly configService: ConfigService,
) {}
constructor(private readonly statsService: StatsService) {}
@Get('counts')
async getCounts(@Request() req: any) {
let inboxCount = 0;
let posteingangCount = 0;
let manuellCount = 0;
let mailpostfachCount = 0;
let agrarmonitorCount = 0;
// 1. Eingangsbox (Dateien aus /mnt/scans)
if (req.user) {
try {
const files = await this.inboxService.listFiles(req.user.preferredUsername ?? null);
inboxCount = files.length;
} catch (err) {
this.logger.error('Fehler beim Abrufen der Inbox-Stats: ' + err.message);
}
}
// 2. Posteingang (Paperless tag 1)
try {
const response1 = await this.paperlessService.getDocuments({
page: 1,
page_size: 1,
tags__id__all: 1,
});
posteingangCount = response1.count || 0;
} catch (err) {
this.logger.error('Fehler beim Abrufen der Posteingang-Stats: ' + err.message);
}
// 3. Manuell bearbeiten (Paperless tag errorTag)
try {
const errorTag = this.configService.get<number>('MANUELL_BEARBEITEN_TAG', 6);
const response2 = await this.paperlessService.getDocuments({
page: 1,
page_size: 1,
tags__id__all: errorTag,
});
manuellCount = response2.count || 0;
} catch (err) {
this.logger.error('Fehler beim Abrufen der Manuell-Stats: ' + err.message);
}
// 4. Mailpostfach (Emails with status 0)
try {
mailpostfachCount = await this.emailRepo.count({ where: { Status: 0 } });
} catch (err) {
this.logger.error('Fehler beim Abrufen der E-Mail-Stats: ' + err.message);
}
// 5. Agrarmonitor (Paperless tag 3)
try {
const agrarmonitorResponse = await this.paperlessService.getDocuments({
page: 1,
page_size: 1,
tags__id__all: 3,
});
agrarmonitorCount = agrarmonitorResponse.count || 0;
} catch (err) {
this.logger.error('Fehler beim Abrufen der Agrarmonitor-Stats: ' + err.message);
}
return {
inbox: inboxCount,
posteingang: posteingangCount,
manuell: manuellCount,
mailpostfach: mailpostfachCount,
agrarmonitor: agrarmonitorCount,
};
return this.statsService.getDashboardCounts(req.user?.preferredUsername);
}
}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StatsController } from './stats.controller';
import { StatsService } from './stats.service';
import { Email } from '../database/entities/email.entity';
import { InboxModule } from '../inbox/inbox.module';
import { PaperlessModule } from '../paperless/paperless.module';
@@ -12,5 +13,7 @@ import { PaperlessModule } from '../paperless/paperless.module';
PaperlessModule,
],
controllers: [StatsController],
providers: [StatsService],
exports: [StatsService],
})
export class StatsModule {}
@@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { Email } from '../database/entities/email.entity';
import { InboxService } from '../inbox/inbox.service';
import { PaperlessService } from '../paperless/paperless.service';
export interface DashboardCounts {
inbox: number;
posteingang: number;
manuell: number;
mailpostfach: number;
agrarmonitor: number;
}
@Injectable()
export class StatsService {
private readonly logger = new Logger(StatsService.name);
constructor(
@InjectRepository(Email) private readonly emailRepo: Repository<Email>,
private readonly inboxService: InboxService,
private readonly paperlessService: PaperlessService,
private readonly configService: ConfigService,
) {}
async getDashboardCounts(preferredUsername?: string): Promise<DashboardCounts> {
let inboxCount = 0;
let posteingangCount = 0;
let manuellCount = 0;
let mailpostfachCount = 0;
let agrarmonitorCount = 0;
try {
const files = await this.inboxService.listFiles(preferredUsername ?? null);
inboxCount = files.length;
} catch (err) {
this.logger.error('Fehler beim Abrufen der Inbox-Stats: ' + err.message);
}
try {
const response = await this.paperlessService.getDocuments({ page: 1, page_size: 1, tags__id__all: 1 });
posteingangCount = response.count || 0;
} catch (err) {
this.logger.error('Fehler beim Abrufen der Posteingang-Stats: ' + err.message);
}
try {
const errorTag = this.configService.get<number>('MANUELL_BEARBEITEN_TAG', 6);
const response = await this.paperlessService.getDocuments({ page: 1, page_size: 1, tags__id__all: errorTag });
manuellCount = response.count || 0;
} catch (err) {
this.logger.error('Fehler beim Abrufen der Manuell-Stats: ' + err.message);
}
try {
mailpostfachCount = await this.emailRepo.count({ where: { Status: 0 } });
} catch (err) {
this.logger.error('Fehler beim Abrufen der E-Mail-Stats: ' + err.message);
}
try {
const response = await this.paperlessService.getDocuments({ page: 1, page_size: 1, tags__id__all: 3 });
agrarmonitorCount = response.count || 0;
} catch (err) {
this.logger.error('Fehler beim Abrufen der Agrarmonitor-Stats: ' + err.message);
}
return { inbox: inboxCount, posteingang: posteingangCount, manuell: manuellCount, mailpostfach: mailpostfachCount, agrarmonitor: agrarmonitorCount };
}
}
@@ -9,12 +9,12 @@ export class UserSettingsController {
@Get()
async getSettings(@Request() req: any) {
return this.userSettingsService.getSettings(req.user.userId);
return this.userSettingsService.getSettings(req.user.userId, req.user.email, req.user.preferredUsername);
}
@Put()
async updateSettings(@Request() req: any, @Body() body: any) {
return this.userSettingsService.updateSettings(req.user.userId, body);
return this.userSettingsService.updateSettings(req.user.userId, body, req.user.email, req.user.preferredUsername);
}
@Get('senders')
@@ -19,6 +19,7 @@ export interface UserSettingsDto {
mailSignatureHtml: string | null;
defaultLabelTemplateId: number | null;
emailRecipientHistory: string[] | null;
dailyDigestEnabled: boolean;
}
@Injectable()
@@ -64,8 +65,16 @@ export class UserSettingsService {
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
}
async getSettings(userId: string): Promise<UserSettingsDto> {
const entity = await this.repo.findOne({ where: { UserId: userId } });
async getSettings(userId: string, email?: string, preferredUsername?: string): Promise<UserSettingsDto> {
let entity = await this.repo.findOne({ where: { UserId: userId } });
if (email || preferredUsername) {
if (!entity) {
entity = this.repo.create({ UserId: userId });
}
if (email) entity.UserEmail = email;
if (preferredUsername) entity.UserPreferredUsername = preferredUsername;
await this.repo.save(entity);
}
return this.toDto(entity);
}
@@ -82,7 +91,10 @@ export class UserSettingsService {
mailSignatureHtml?: string | null;
defaultLabelTemplateId?: number | null;
emailRecipientHistory?: string[] | null;
dailyDigestEnabled?: boolean;
},
email?: string,
preferredUsername?: string,
): Promise<UserSettingsDto> {
let entity = await this.repo.findOne({ where: { UserId: userId } });
if (!entity) {
@@ -101,6 +113,9 @@ export class UserSettingsService {
if (data.mailSignatureHtml !== undefined) entity.MailSignatureHtml = data.mailSignatureHtml;
if (data.defaultLabelTemplateId !== undefined) entity.DefaultLabelTemplateId = data.defaultLabelTemplateId;
if (data.emailRecipientHistory !== undefined) entity.EmailRecipientHistory = data.emailRecipientHistory;
if (data.dailyDigestEnabled !== undefined) entity.DailyDigestEnabled = data.dailyDigestEnabled;
if (email) entity.UserEmail = email;
if (preferredUsername) entity.UserPreferredUsername = preferredUsername;
await this.repo.save(entity);
return this.toDto(entity);
@@ -144,6 +159,12 @@ export class UserSettingsService {
};
}
async findAllDigestSubscribers(): Promise<UserSettings[]> {
return this.repo.find({
where: { DailyDigestEnabled: true },
}).then(rows => rows.filter(r => !!r.UserEmail));
}
async getAvailableSenders(userId: string): Promise<{ id: string; label: string }[]> {
const defaultEmail = this.configService.get<string>('SMTP_FROM', 'paperless@localhost');
const defaultName = this.configService.get<string>('SMTP_FROM_NAME', '');
@@ -172,6 +193,7 @@ export class UserSettingsService {
mailSignatureHtml: entity?.MailSignatureHtml ?? null,
defaultLabelTemplateId: entity?.DefaultLabelTemplateId ?? null,
emailRecipientHistory: entity?.EmailRecipientHistory ?? null,
dailyDigestEnabled: entity?.DailyDigestEnabled ?? false,
};
}
}
@@ -11,6 +11,7 @@ export interface UserSettingsData {
mailSignatureHtml: string | null;
defaultLabelTemplateId: number | null;
emailRecipientHistory: string[] | null;
dailyDigestEnabled: boolean;
}
export interface SenderOption {
@@ -31,4 +32,7 @@ export const userSettingsApi = {
getSenders: () =>
api.get<SenderOption[]>('/api/user-settings/senders').then((r) => r.data),
sendDigestNow: () =>
api.post<{ ok: boolean; error?: string }>('/api/daily-digest/send-now').then((r) => r.data),
};
@@ -197,6 +197,71 @@ function MailSettingsTab() {
);
}
function NotificationsTab() {
const [enabled, setEnabled] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [sending, setSending] = useState(false);
useEffect(() => {
userSettingsApi.get()
.then((data) => setEnabled(data.dailyDigestEnabled ?? false))
.catch(() => message.error('Einstellungen konnten nicht geladen werden'))
.finally(() => setLoading(false));
}, []);
const handleSave = async () => {
setSaving(true);
try {
await userSettingsApi.update({ dailyDigestEnabled: enabled });
message.success('Einstellungen gespeichert');
} catch {
message.error('Speichern fehlgeschlagen');
} finally {
setSaving(false);
}
};
const handleSendNow = async () => {
setSending(true);
try {
const result = await userSettingsApi.sendDigestNow();
if (result.ok) {
message.success('Tagesübersicht wurde gesendet');
} else {
message.error(result.error ?? 'Senden fehlgeschlagen');
}
} catch {
message.error('Senden fehlgeschlagen');
} finally {
setSending(false);
}
};
if (loading) return null;
return (
<Form layout="vertical" style={{ maxWidth: 600 }}>
<Form.Item
label="Tägliche E-Mail-Zusammenfassung"
extra="Sie erhalten jeden Morgen eine E-Mail mit der Übersicht aller offenen Vorgänge aus dem Dashboard."
>
<Switch checked={enabled} onChange={setEnabled} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" loading={saving} onClick={handleSave}>
Speichern
</Button>
<Button loading={sending} onClick={handleSendNow}>
Jetzt senden
</Button>
</Space>
</Form.Item>
</Form>
);
}
export default function UserSettingsPage() {
return (
<div>
@@ -214,6 +279,11 @@ export default function UserSettingsPage() {
label: 'Etikettendruck',
children: <LabelSettingsTab />,
},
{
key: 'notifications',
label: 'Benachrichtigungen',
children: <NotificationsTab />,
},
]}
/>
</Card>