feat: verarbeitete/ignorierte E-Mails beim Prüfen in IMAP-Ordner verschieben
Build and Push Multi-Platform Images / build-and-push (push) Successful in 38s

- Cleanup-Cron von EmailDownloadService in ImapFolderService verschoben,
  damit er auch aus EmailController aufrufbar ist (zirkuläre Abhängigkeit vermieden)
- Beim Klick auf „Anhänge prüfen" wird der IMAP-Cleanup fire-and-forget gestartet
- Beim Klick auf „Bereits verarbeitete Anhänge prüfen" werden zusätzlich alle
  E-Mails im IMAP-Posteingang, die in der DB als verarbeitet (Status 1) oder
  ignoriert (Status 3) markiert sind, in den Ordner „importiert" verschoben
- Erfolgsmeldung zeigt Anzahl verschobener E-Mails an

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 13:04:03 +02:00
parent 41eed1871e
commit 969f0ae0b1
5 changed files with 105 additions and 43 deletions
@@ -68,47 +68,6 @@ export class EmailDownloadService {
}); });
} }
@Cron('0 3 * * *', { timeZone: 'Europe/Berlin' })
async cleanupImportedEmails(): Promise<void> {
if (!this.configService.get<string>('IMAP_HOST')) return;
const importedFolder = this.configService.get<string>('IMAP_IMPORTED_FOLDER', 'importiert');
const trashFolder = this.configService.get<string>('IMAP_TRASH_FOLDER', 'Trash');
const client = this.createImapClient();
try {
await client.connect();
// E-Mails älter als 90 Tage in Papierkorb verschieben
try {
await client.mailboxOpen(importedFolder);
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 90);
const oldUids = await client.search({ before: cutoff }, { uid: true });
if (Array.isArray(oldUids) && oldUids.length > 0) {
await client.messageMove(oldUids, trashFolder, { uid: true });
this.logger.log(`${oldUids.length} alte E-Mail(s) aus "${importedFolder}" in "${trashFolder}" verschoben.`);
}
} catch (err: any) {
this.logger.warn(`Bereinigung "${importedFolder}" nicht möglich: ${err.message}`);
}
// Papierkorb leeren
try {
await client.mailboxOpen(trashFolder);
const trashUids = await client.search({ all: true }, { uid: true });
if (Array.isArray(trashUids) && trashUids.length > 0) {
await client.messageDelete(trashUids, { uid: true });
this.logger.log(`${trashUids.length} E-Mail(s) aus "${trashFolder}" gelöscht.`);
}
} catch (err: any) {
this.logger.warn(`Papierkorb "${trashFolder}" konnte nicht geleert werden: ${err.message}`);
}
} catch (err: any) {
this.logger.error(`IMAP-Cleanup fehlgeschlagen: ${err.message}`);
} finally {
await client.logout().catch(() => {});
}
}
private async fetchAndStore(): Promise<void> { private async fetchAndStore(): Promise<void> {
const host = this.configService.get<string>('IMAP_HOST'); const host = this.configService.get<string>('IMAP_HOST');
const user = this.configService.get<string>('IMAP_USERNAME'); const user = this.configService.get<string>('IMAP_USERNAME');
@@ -17,6 +17,7 @@ import { Email } from '../database/entities/email.entity';
import { Attachment } from '../database/entities/attachment.entity'; import { Attachment } from '../database/entities/attachment.entity';
import { Content } from '../database/entities/content.entity'; import { Content } from '../database/entities/content.entity';
import { PaperlessService } from '../paperless/paperless.service'; import { PaperlessService } from '../paperless/paperless.service';
import { ImapFolderService } from './imap-folder.service';
import { RequirePermissions } from '../auth/permissions.decorator'; import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum'; import { Permission } from '../auth/permissions.enum';
@@ -31,6 +32,7 @@ export class EmailController {
@InjectRepository(Content) @InjectRepository(Content)
private readonly contentRepo: Repository<Content>, private readonly contentRepo: Repository<Content>,
private readonly paperlessService: PaperlessService, private readonly paperlessService: PaperlessService,
private readonly imapFolderService: ImapFolderService,
) {} ) {}
@Get() @Get()
@@ -202,7 +204,21 @@ export class EmailController {
this.logger.log( this.logger.log(
`Prüfung abgeschlossen. ${updatedCount} E-Mails aktualisiert, ${idsUpdated} Paperless-IDs ergänzt, ${skippedCount} übersprungen.`, `Prüfung abgeschlossen. ${updatedCount} E-Mails aktualisiert, ${idsUpdated} Paperless-IDs ergänzt, ${skippedCount} übersprungen.`,
); );
return { updatedCount, idsUpdated }; this.imapFolderService.cleanupImportedEmails().catch(err =>
this.logger.error('IMAP-Cleanup fehlgeschlagen: ' + err.message),
);
let movedToImportiert = 0;
if (body.includeProcessed) {
const processedEmails = await this.emailRepo.find({
where: [{ Status: 1 }, { Status: 3 }],
select: ['MessageId'],
});
const messageIds = processedEmails.map((e) => e.MessageId).filter(Boolean);
movedToImportiert = await this.imapFolderService.moveProcessedInboxToImportiert(messageIds);
}
return { updatedCount, idsUpdated, movedToImportiert };
} catch (error: any) { } catch (error: any) {
this.logger.error( this.logger.error(
`Kritischer Fehler bei checkAttachments: ${error.message}`, `Kritischer Fehler bei checkAttachments: ${error.message}`,
@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import { ImapFlow } from 'imapflow'; import { ImapFlow } from 'imapflow';
@Injectable() @Injectable()
@@ -21,6 +22,91 @@ export class ImapFolderService {
}); });
} }
@Cron('0 3 * * *', { timeZone: 'Europe/Berlin' })
async cleanupImportedEmails(): Promise<void> {
if (!this.configService.get<string>('IMAP_HOST')) return;
const importedFolder = this.configService.get<string>('IMAP_IMPORTED_FOLDER', 'importiert');
const trashFolder = this.configService.get<string>('IMAP_TRASH_FOLDER', 'Trash');
const client = this.createClient();
try {
await client.connect();
// E-Mails älter als 90 Tage in Papierkorb verschieben
try {
await client.mailboxOpen(importedFolder);
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 90);
const oldUids = await client.search({ before: cutoff }, { uid: true });
if (Array.isArray(oldUids) && oldUids.length > 0) {
await client.messageMove(oldUids, trashFolder, { uid: true });
this.logger.log(`${oldUids.length} alte E-Mail(s) aus "${importedFolder}" in "${trashFolder}" verschoben.`);
}
} catch (err: any) {
this.logger.warn(`Bereinigung "${importedFolder}" nicht möglich: ${err.message}`);
}
// Papierkorb leeren
try {
await client.mailboxOpen(trashFolder);
const trashUids = await client.search({ all: true }, { uid: true });
if (Array.isArray(trashUids) && trashUids.length > 0) {
await client.messageDelete(trashUids, { uid: true });
this.logger.log(`${trashUids.length} E-Mail(s) aus "${trashFolder}" gelöscht.`);
}
} catch (err: any) {
this.logger.warn(`Papierkorb "${trashFolder}" konnte nicht geleert werden: ${err.message}`);
}
} catch (err: any) {
this.logger.error(`IMAP-Cleanup fehlgeschlagen: ${err.message}`);
} finally {
await client.logout().catch(() => {});
}
}
async moveProcessedInboxToImportiert(messageIds: string[]): Promise<number> {
if (!this.configService.get<string>('IMAP_HOST') || messageIds.length === 0) return 0;
const importedFolder = this.configService.get<string>('IMAP_IMPORTED_FOLDER', 'importiert');
const client = this.createClient();
let movedCount = 0;
try {
await client.connect();
const mailboxes = await client.list();
if (!mailboxes.some(m => m.path === importedFolder)) {
await client.mailboxCreate(importedFolder);
this.logger.log(`IMAP-Ordner "${importedFolder}" erstellt.`);
}
const status = await client.mailboxOpen('INBOX');
if (status.exists === 0) return 0;
const normalize = (id: string) => id.replace(/^<|>$/g, '').toLowerCase();
const idSet = new Set(messageIds.map(normalize));
const uidsToMove: number[] = [];
for await (const msg of client.fetch('1:*', { uid: true, envelope: true })) {
const msgId = msg.envelope?.messageId;
if (msgId && idSet.has(normalize(msgId))) {
uidsToMove.push(msg.uid);
}
}
if (uidsToMove.length > 0) {
await client.messageMove(uidsToMove, importedFolder, { uid: true });
movedCount = uidsToMove.length;
this.logger.log(`${movedCount} E-Mail(s) aus INBOX → "${importedFolder}" verschoben.`);
}
} catch (err: any) {
this.logger.error(`moveProcessedInboxToImportiert fehlgeschlagen: ${err.message}`);
} finally {
await client.logout().catch(() => {});
}
return movedCount;
}
async moveToImportiert(messageId: string): Promise<void> { async moveToImportiert(messageId: string): Promise<void> {
if (!this.configService.get<string>('IMAP_HOST')) return; if (!this.configService.get<string>('IMAP_HOST')) return;
+1 -1
View File
@@ -44,7 +44,7 @@ export const emailsApi = {
api.post<{ message: string }>('/api/emails/fetch').then((r) => r.data), api.post<{ message: string }>('/api/emails/fetch').then((r) => r.data),
checkAttachments: (includeProcessed = false) => checkAttachments: (includeProcessed = false) =>
api.post<{ updatedCount: number; idsUpdated: number }>('/api/emails/check-attachments', { includeProcessed }).then((r) => r.data), api.post<{ updatedCount: number; idsUpdated: number; movedToImportiert: number }>('/api/emails/check-attachments', { includeProcessed }).then((r) => r.data),
updateStatus: (id: number, status: number) => updateStatus: (id: number, status: number) =>
api.patch<{ message?: string }>(`/api/emails/${id}/status`, { status }).then((r) => r.data), api.patch<{ message?: string }>(`/api/emails/${id}/status`, { status }).then((r) => r.data),
@@ -172,6 +172,7 @@ export default function MailpostfachPage() {
const parts = []; const parts = [];
if (result.updatedCount > 0) parts.push(`${result.updatedCount} E-Mail(s) aktualisiert`); if (result.updatedCount > 0) parts.push(`${result.updatedCount} E-Mail(s) aktualisiert`);
if (result.idsUpdated > 0) parts.push(`${result.idsUpdated} Paperless-ID(s) ergänzt`); if (result.idsUpdated > 0) parts.push(`${result.idsUpdated} Paperless-ID(s) ergänzt`);
if (result.movedToImportiert > 0) parts.push(`${result.movedToImportiert} E-Mail(s) in „importiert" verschoben`);
message.success(parts.length > 0 ? parts.join(', ') + '.' : 'Keine Änderungen.'); message.success(parts.length > 0 ? parts.join(', ') + '.' : 'Keine Änderungen.');
if (result.updatedCount > 0 || result.idsUpdated > 0) await loadData(); if (result.updatedCount > 0 || result.idsUpdated > 0) await loadData();
} catch { } catch {