import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ImapFlow, type FetchMessageObject } from 'imapflow'; import { simpleParser, type AddressObject, type Attachment as MailAttachment, } from 'mailparser'; import * as crypto from 'crypto'; import * as os from 'os'; import * as path from 'path'; import * as fs from 'fs/promises'; import { PdfService } from '../preprocessing/pdf.service'; import { EmailPageCacheService } from '../email/email-page-cache.service'; import { Email } from '../database/entities/email.entity'; import { Attachment } from '../database/entities/attachment.entity'; import { Content } from '../database/entities/content.entity'; import { isERechnung } from './zugferd.util'; @Injectable() export class EmailDownloadService { private readonly logger = new Logger(EmailDownloadService.name); private running = false; constructor( private readonly configService: ConfigService, private readonly pdfService: PdfService, private readonly pageCache: EmailPageCacheService, @InjectRepository(Email) private readonly emailRepo: Repository, @InjectRepository(Attachment) private readonly attachmentRepo: Repository, @InjectRepository(Content) private readonly contentRepo: Repository, ) {} @Cron(CronExpression.EVERY_5_MINUTES) async handleCron() { if (this.running) { this.logger.warn('E-Mail-Download läuft noch – überspringe Tick.'); return; } this.running = true; try { await this.fetchAndStore(); } catch (err: any) { this.logger.error( `Fehler im E-Mail-Download-Job: ${err.message}`, err.stack, ); } finally { this.running = false; } } private async fetchAndStore(): Promise { const host = this.configService.get('IMAP_HOST'); const port = this.configService.get('IMAP_PORT', 993); const secure = this.configService.get('IMAP_USE_SSL', 'true') === 'true'; const user = this.configService.get('IMAP_USERNAME'); const pass = this.configService.get('IMAP_PASSWORD'); if (!host || !user || !pass) { this.logger.warn( 'IMAP-Konfiguration unvollständig – Job wird übersprungen.', ); return; } this.logger.log('E-Mail Fetch Job gestartet.'); const client = new ImapFlow({ host, port, secure, auth: { user, pass }, logger: false, }); await client.connect(); this.logger.log(`Verbunden mit IMAP-Server ${host}:${port}`); const lock = await client.getMailboxLock('INBOX'); try { const status = await client.status('INBOX', { messages: true }); this.logger.log( `Posteingang geöffnet. Anzahl der Nachrichten: ${status.messages ?? 0}`, ); if (!status.messages || status.messages === 0) { return; } const iter = client.fetch('1:*', { envelope: true, uid: true, source: true, }); for await (const msg of iter) { const messageId = msg.envelope?.messageId; if (!messageId) continue; try { const existing = await this.emailRepo.findOne({ where: { MessageId: messageId }, }); if (existing) { this.logger.debug( `E-Mail mit MessageId ${messageId} bereits vorhanden.`, ); continue; } await this.processMessage(msg); } catch (err: any) { this.logger.error( `Fehler beim Abrufen der Nachricht ${messageId}: ${err.message}`, err.stack, ); } } this.logger.log( 'Alle neuen E-Mails und deren Anhänge wurden in der Datenbank gespeichert.', ); } finally { lock.release(); await client.logout().catch(() => undefined); this.logger.log('Verbindung zum IMAP-Server geschlossen.'); } } private async processMessage(msg: FetchMessageObject): Promise { if (!msg.source) return; const parsed = await simpleParser(msg.source); const messageId = msg.envelope?.messageId ?? parsed.messageId ?? ''; const email = new Email(); email.MessageId = messageId; email.SenderAddress = formatAddress(parsed.from); email.RecipientAddress = formatAddress(parsed.to); email.Subject = (msg.envelope?.subject ?? parsed.subject ?? '').slice( 0, 500, ); email.Date = msg.envelope?.date ?? parsed.date ?? new Date(); email.Body = parsed.html || parsed.text || ''; email.Status = 0; const attachmentsToPersist: Array<{ attachment: Attachment; buffer: Buffer; }> = []; for (const att of parsed.attachments) { const entry = await this.buildAttachment(att); if (entry) attachmentsToPersist.push(entry); } // Double-Check: nochmal gegen DB prüfen (Race-Condition-Schutz wie in C#) const existing2 = await this.emailRepo.findOne({ where: { MessageId: messageId }, }); if (existing2) { this.logger.debug( `E-Mail mit MessageId ${messageId} nach dem Download bereits vorhanden.`, ); return; } const savedEmail = await this.emailRepo.save(email); for (const { attachment, buffer } of attachmentsToPersist) { attachment.EmailMessageId = savedEmail.Id; const savedAttachment = await this.attachmentRepo.save(attachment); const content = new Content(); content.AttachmentEntityId = savedAttachment.Id; content.Content1 = buffer; content.ContentLength = buffer.length; await this.contentRepo.save(content); // Generate PDF thumbnails if it's a PDF if (savedAttachment.ContentType === 'application/pdf') { await this.generateThumbnailsForAttachment(savedAttachment, buffer); } } this.logger.debug( `Neue E-Mail mit MessageId ${messageId} hinzugefügt (${attachmentsToPersist.length} Anhänge).`, ); } public async generateThumbnailsForAttachment( attachment: Attachment, buffer: Buffer, ): Promise { try { const tempPdfPath = path.join( os.tmpdir(), `email-att-${attachment.Id}.pdf`, ); await fs.writeFile(tempPdfPath, buffer); const images = await this.pdfService.pdfToImages(tempPdfPath, 400); await this.pageCache.generate(attachment.Id, images); attachment.PageCount = images.length; await this.attachmentRepo.save(attachment); await this.pdfService.cleanup(images); await fs.unlink(tempPdfPath).catch(() => {}); } catch (err: any) { this.logger.warn( `Konnte Vorschaubilder für Anhang ${attachment.Id} nicht generieren: ${err.message}`, ); } } public async backfillThumbnailsForNewEmails(): Promise<{ processed: number; failed: number; }> { const emails = await this.emailRepo.find({ where: { Status: 0 }, relations: ['Attachments', 'Attachments.Content'], }); let processed = 0; let failed = 0; for (const email of emails) { for (const attachment of email.Attachments) { if ( attachment.ContentType === 'application/pdf' && attachment.PageCount === 0 && attachment.Content?.Content1 ) { this.logger.log( `Backfill: Generiere Thumbnails für Attachment ${attachment.Id} (Email ${email.Id})`, ); try { await this.generateThumbnailsForAttachment( attachment, attachment.Content.Content1, ); processed++; } catch (err) { failed++; } } } } this.logger.log( `Backfill abgeschlossen: ${processed} erfolgreich, ${failed} fehlgeschlagen.`, ); return { processed, failed }; } private async buildAttachment( att: MailAttachment, ): Promise<{ attachment: Attachment; buffer: Buffer } | null> { const buffer = att.content; if (!Buffer.isBuffer(buffer) || buffer.length === 0) return null; const filename = att.filename ?? att.cid ?? 'unbenannt'; let contentType = att.contentType ?? 'application/octet-stream'; const isEmbedded = att.contentDisposition === 'inline'; const isPdfByName = filename.toLowerCase().endsWith('.pdf'); const isPdfByType = contentType.toLowerCase() === 'application/pdf'; const isOctet = contentType.toLowerCase() === 'application/octet-stream'; // Nur PDFs (inkl. als octet-stream deklarierter PDFs) und embedded rfc822 werden übernommen. const isRfc822 = contentType.toLowerCase() === 'message/rfc822'; if (!isPdfByType && !isRfc822 && !(isOctet && isPdfByName)) { return null; } if (isPdfByName && !isPdfByType) { contentType = 'application/pdf'; } const attachment = new Attachment(); attachment.FileName = filename.slice(0, 255); attachment.ContentType = contentType.slice(0, 100); attachment.IsEmbedded = isEmbedded; attachment.ContentId = att.cid ? att.cid.slice(0, 255) : null; attachment.Checksum = crypto.createHash('md5').update(buffer).digest('hex'); attachment.Erechnung = contentType.toLowerCase() === 'application/pdf' ? isERechnung(buffer) : false; attachment.ParentId = null; return { attachment, buffer }; } } function formatAddress( addr: AddressObject | AddressObject[] | undefined, ): string { if (!addr) return ''; const first = Array.isArray(addr) ? addr[0] : addr; return (first?.text ?? '').slice(0, 255); }