Initial commit with Email Import Wizard and Task Processor updates

This commit is contained in:
2026-05-04 08:02:11 +02:00
commit effdc5d59f
170 changed files with 67739 additions and 0 deletions
@@ -0,0 +1,114 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ExportTarget } from '../database/entities/export-target.entity';
import * as ftp from 'basic-ftp';
import { createClient, type WebDAVClient } from 'webdav';
@Injectable()
export class ExportService {
private readonly logger = new Logger(ExportService.name);
constructor(
@InjectRepository(ExportTarget) private readonly targetRepo: Repository<ExportTarget>,
) {}
async exportFile(targetId: number, filename: string, content: Buffer): Promise<void> {
const target = await this.targetRepo.findOneByOrFail({ Id: targetId });
if (!target.IsActive) {
throw new Error(`Export-Ziel "${target.Name}" ist deaktiviert.`);
}
switch (target.Protocol) {
case 'ftp':
await this.uploadFtp(target, filename, content);
break;
case 'webdav':
await this.uploadWebDav(target, filename, content);
break;
default:
throw new Error(`Unbekanntes Protokoll: ${target.Protocol}`);
}
}
async testConnection(targetId: number): Promise<{ success: boolean; message: string }> {
const target = await this.targetRepo.findOneByOrFail({ Id: targetId });
try {
switch (target.Protocol) {
case 'ftp':
await this.testFtp(target);
break;
case 'webdav':
await this.testWebDav(target);
break;
default:
return { success: false, message: `Unbekanntes Protokoll: ${target.Protocol}` };
}
return { success: true, message: 'Verbindung erfolgreich.' };
} catch (err: any) {
return { success: false, message: err.message };
}
}
private async uploadFtp(target: ExportTarget, filename: string, content: Buffer): Promise<void> {
const client = new ftp.Client();
try {
await client.access({
host: target.Host,
port: target.Port || 21,
user: target.Username || 'anonymous',
password: target.Password || '',
secure: false,
});
const remotePath = `${target.RemotePath || '/'}/${filename}`;
const { Readable } = await import('stream');
const stream = Readable.from(content);
await client.uploadFrom(stream, remotePath);
this.logger.log(`FTP Upload: ${remotePath}${target.Name}`);
} finally {
client.close();
}
}
private async testFtp(target: ExportTarget): Promise<void> {
const client = new ftp.Client();
try {
await client.access({
host: target.Host,
port: target.Port || 21,
user: target.Username || 'anonymous',
password: target.Password || '',
secure: false,
});
await client.list(target.RemotePath || '/');
} finally {
client.close();
}
}
private async uploadWebDav(target: ExportTarget, filename: string, content: Buffer): Promise<void> {
const client = this.createWebDavClient(target);
const remotePath = `${target.RemotePath || '/'}/${filename}`;
await client.putFileContents(remotePath, content);
this.logger.log(`WebDAV Upload: ${remotePath}${target.Name}`);
}
private async testWebDav(target: ExportTarget): Promise<void> {
const client = this.createWebDavClient(target);
await client.getDirectoryContents(target.RemotePath || '/');
}
private createWebDavClient(target: ExportTarget): WebDAVClient {
const protocol = target.Port === 443 ? 'https' : 'http';
const port = target.Port ? `:${target.Port}` : '';
const url = `${protocol}://${target.Host}${port}`;
return createClient(url, {
username: target.Username || undefined,
password: target.Password || undefined,
});
}
}
@@ -0,0 +1,43 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
private transporter: nodemailer.Transporter;
constructor(private readonly configService: ConfigService) {
this.transporter = nodemailer.createTransport({
host: this.configService.get<string>('SMTP_HOST', ''),
port: this.configService.get<number>('SMTP_PORT', 587),
secure: this.configService.get<string>('SMTP_SECURE', 'false') === 'true',
auth: {
user: this.configService.get<string>('SMTP_USER', ''),
pass: this.configService.get<string>('SMTP_PASS', ''),
},
});
}
async sendMail(options: {
to: string;
subject: string;
body: string;
attachments?: { filename: string; content: Buffer }[];
}): Promise<void> {
const from = this.configService.get<string>('SMTP_FROM', 'paperless@localhost');
await this.transporter.sendMail({
from,
to: options.to,
subject: options.subject,
text: options.body,
attachments: options.attachments?.map(a => ({
filename: a.filename,
content: a.content,
})),
});
this.logger.log(`Mail gesendet an ${options.to}: "${options.subject}"`);
}
}
@@ -0,0 +1,20 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Postprocessing } from '../database/entities/postprocessing.entity';
import { PostprocessingAction } from '../database/entities/postprocessing-action.entity';
import { PostprocessingLog } from '../database/entities/postprocessing-log.entity';
import { ExportTarget } from '../database/entities/export-target.entity';
import { PostprocessingService } from './postprocessing.service';
import { MailService } from './mail.service';
import { ExportService } from './export.service';
import { PaperlessModule } from '../paperless/paperless.module';
@Module({
imports: [
TypeOrmModule.forFeature([Postprocessing, PostprocessingAction, PostprocessingLog, ExportTarget]),
forwardRef(() => PaperlessModule),
],
providers: [PostprocessingService, MailService, ExportService],
exports: [PostprocessingService, ExportService, MailService],
})
export class PostprocessingModule {}
@@ -0,0 +1,84 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { PostprocessingService } from './postprocessing.service';
import { Postprocessing } from '../database/entities/postprocessing.entity';
import { PostprocessingAction } from '../database/entities/postprocessing-action.entity';
import { PaperlessService } from '../paperless/paperless.service';
const mockRules: Partial<Postprocessing>[] = [
{ Id: 1, Name: 'Rule1', DocumentTypeId: 5, CorrespondentId: null, OwnerId: null, TagId: null, Order: 1, IsActive: true, NoFurther: false },
{ Id: 2, Name: 'StopRule', DocumentTypeId: null, CorrespondentId: null, OwnerId: null, TagId: null, Order: 2, IsActive: true, NoFurther: true },
];
const mockActions: Partial<PostprocessingAction>[] = [
{ Id: 1, PostprocessingId: 1, ActionType: 2, Content: '99', Order: 1, IsActive: true },
];
describe('PostprocessingService', () => {
let service: PostprocessingService;
let ppRepo: any;
let ppActionRepo: any;
let paperlessService: any;
beforeEach(async () => {
ppRepo = { find: jest.fn().mockResolvedValue(mockRules) };
ppActionRepo = { find: jest.fn().mockResolvedValue(mockActions) };
paperlessService = { updateDocument: jest.fn().mockResolvedValue({}) };
const module: TestingModule = await Test.createTestingModule({
providers: [
PostprocessingService,
{ provide: getRepositoryToken(Postprocessing), useValue: ppRepo },
{ provide: getRepositoryToken(PostprocessingAction), useValue: ppActionRepo },
{ provide: PaperlessService, useValue: paperlessService },
],
}).compile();
service = module.get<PostprocessingService>(PostprocessingService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('evaluate loads active rules in order', async () => {
await service.evaluate({ documentId: 100, documentTypeId: 5, tagIds: [] });
expect(ppRepo.find).toHaveBeenCalledWith({
where: { IsActive: true },
order: { Order: 'ASC' },
});
});
it('evaluate executes matching actions', async () => {
// Rule1 matches documentTypeId=5 → action adds tag
await service.evaluate({ documentId: 100, documentTypeId: 5, tagIds: [] });
expect(ppActionRepo.find).toHaveBeenCalledWith({
where: { PostprocessingId: 1, IsActive: true },
order: { Order: 'ASC' },
});
expect(paperlessService.updateDocument).toHaveBeenCalledWith(100, { tags: [99] });
});
it('evaluate stops at NoFurther rule', async () => {
// Rule1 matches, Rule2 also matches (no filters) + NoFurther → stops
await service.evaluate({ documentId: 100, documentTypeId: 5, tagIds: [] });
// Actions loaded for rule 1 and rule 2, but no rule after rule 2
expect(ppActionRepo.find).toHaveBeenCalledTimes(2);
});
it('evaluate skips non-matching rules', async () => {
// documentTypeId=999 doesn't match Rule1 (requires 5) but matches Rule2 (no filter)
ppActionRepo.find.mockResolvedValue([]);
await service.evaluate({ documentId: 100, documentTypeId: 999, tagIds: [] });
// Rule1 skipped, Rule2 matched → only 1 action lookup
expect(ppActionRepo.find).toHaveBeenCalledTimes(1);
expect(ppActionRepo.find).toHaveBeenCalledWith({
where: { PostprocessingId: 2, IsActive: true },
order: { Order: 'ASC' },
});
});
});
@@ -0,0 +1,381 @@
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Postprocessing, type FilterGroup, type FilterCondition } from '../database/entities/postprocessing.entity';
import { PostprocessingAction } from '../database/entities/postprocessing-action.entity';
import { PostprocessingLog } from '../database/entities/postprocessing-log.entity';
import { PaperlessService } from '../paperless/paperless.service';
import { MailService } from './mail.service';
import { ExportService } from './export.service';
import axios from 'axios';
@Injectable()
export class PostprocessingService {
private readonly logger = new Logger(PostprocessingService.name);
private readonly errorTagId: number;
constructor(
@InjectRepository(Postprocessing) private readonly ppRepo: Repository<Postprocessing>,
@InjectRepository(PostprocessingAction) private readonly actionRepo: Repository<PostprocessingAction>,
@InjectRepository(PostprocessingLog) private readonly logRepo: Repository<PostprocessingLog>,
private readonly configService: ConfigService,
@Inject(forwardRef(() => PaperlessService)) private readonly paperlessService: PaperlessService,
private readonly mailService: MailService,
private readonly exportService: ExportService,
) {
this.errorTagId = this.configService.get<number>('POSTPROCESSING_ERROR_TAG', 0);
}
async evaluate(doc: any): Promise<void> {
const rules = await this.ppRepo.find({
where: { IsActive: true },
order: { Order: 'ASC' },
});
// Enrich doc with resolved names (once per evaluation)
await this.enrichDocWithNames(doc);
this.logger.log(`[Postprocessing] Dokument ${doc.id} wird gegen ${rules.length} Regel(n) geprüft`);
for (const rule of rules) {
if (!this.hasConditions(rule.FilterJson)) {
this.logger.warn(`[Postprocessing] Regel "${rule.Name}" (ID: ${rule.Id}) hat keine Bedingungen wird übersprungen.`);
continue;
}
this.logger.debug(`[Postprocessing] Prüfe Regel "${rule.Name}" (ID: ${rule.Id}) für Dokument ${doc.id}`);
const matches = this.matchesFilter(rule.FilterJson, doc);
this.logger.log(`[Postprocessing] Regel "${rule.Name}" (ID: ${rule.Id}) → ${matches ? 'TRIFFT ZU' : 'trifft nicht zu'}`);
if (!matches) continue;
this.logger.log(`[Postprocessing] Regel "${rule.Name}" trifft zu für Dokument ${doc.id}`);
const actions = await this.actionRepo.find({
where: { PostprocessingId: rule.Id, IsActive: true },
order: { Order: 'ASC' },
});
let hasError = false;
for (const action of actions) {
try {
await this.executeAction(action, doc);
await this.log(rule.Id, action.Id, doc.id, 'success', `Aktion ${action.ActionType} erfolgreich`);
} catch (err: any) {
hasError = true;
this.logger.error(`Fehler bei Aktion ${action.Id} (Typ ${action.ActionType}) für Dokument ${doc.id}: ${err.message}`);
await this.log(rule.Id, action.Id, doc.id, 'error', err.message);
}
}
if (hasError && this.errorTagId) {
try {
const currentTags = new Set<number>(doc.tags || []);
currentTags.add(this.errorTagId);
await this.paperlessService.updateDocument(doc.id, { tags: Array.from(currentTags) });
} catch (tagErr: any) {
this.logger.error(`Konnte Error-Tag ${this.errorTagId} nicht setzen für Dokument ${doc.id}: ${tagErr.message}`);
}
}
if (rule.NoFurther) {
this.logger.debug('NoFurther keine weiteren Regeln');
break;
}
}
}
// ── Recursive Filter Evaluation ──────────────────────────────────
private hasConditions(filter: FilterGroup): boolean {
if (!filter || !filter.rules || filter.rules.length === 0) return false;
return filter.rules.some(rule => {
if ('combinator' in rule) return this.hasConditions(rule as FilterGroup);
return true;
});
}
private matchesFilter(filter: FilterGroup, doc: any): boolean {
if (!filter || !filter.rules || filter.rules.length === 0) return false;
const results = filter.rules.map(rule => {
if ('combinator' in rule) {
return this.matchesFilter(rule as FilterGroup, doc);
}
return this.evaluateCondition(rule as FilterCondition, doc);
});
return filter.combinator === 'AND'
? results.every(Boolean)
: results.some(Boolean);
}
private evaluateCondition(cond: FilterCondition, doc: any): boolean {
const actual = this.resolveFieldValue(cond.field, doc);
const expected = cond.value;
let result: boolean;
switch (cond.operator) {
case 'equals':
if (cond.field === 'tag') {
result = Array.isArray(actual) && actual.includes(Number(expected));
} else {
result = String(actual ?? '') === String(expected ?? '');
}
break;
case 'not_equals':
if (cond.field === 'tag') {
result = !Array.isArray(actual) || !actual.includes(Number(expected));
} else {
result = String(actual ?? '') !== String(expected ?? '');
}
break;
case 'contains':
if (cond.field === 'tag') {
result = Array.isArray(actual) && actual.includes(Number(expected));
} else {
result = String(actual ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase());
}
break;
case 'not_contains':
if (cond.field === 'tag') {
result = !Array.isArray(actual) || !actual.includes(Number(expected));
} else {
result = !String(actual ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase());
}
break;
case 'is_set':
result = actual !== null && actual !== undefined && actual !== '';
break;
case 'is_not_set':
result = actual === null || actual === undefined || actual === '';
break;
case 'gt':
result = parseFloat(actual) > parseFloat(expected);
break;
case 'lt':
result = parseFloat(actual) < parseFloat(expected);
break;
default:
this.logger.warn(`Unbekannter Operator: ${cond.operator}`);
result = false;
}
this.logger.debug(
`[Postprocessing] Bedingung: ${cond.field} ${cond.operator} "${expected}" | Istwert: "${Array.isArray(actual) ? actual.join(',') : actual}" → ${result ? 'WAHR' : 'FALSCH'}`
);
return result;
}
private resolveFieldValue(field: string, doc: any): any {
switch (field) {
case 'document_type':
return doc.document_type;
case 'correspondent':
return doc.correspondent;
case 'owner':
return doc.owner;
case 'tag':
return doc.tags || [];
case 'title':
return doc.title;
case 'archive_serial_number':
return doc.archive_serial_number;
default:
// Custom field: "custom_field_<id>"
if (field.startsWith('custom_field_')) {
const fieldId = parseInt(field.replace('custom_field_', ''), 10);
const cf = (doc.custom_fields || []).find((f: any) => f.field === fieldId);
return cf?.value ?? null;
}
return null;
}
}
// ── Action Execution ─────────────────────────────────────────────
private async executeAction(action: PostprocessingAction, doc: any): Promise<void> {
const content = action.Content;
switch (action.ActionType) {
case 1: // Export
await this.handleExport(content, doc);
break;
case 2: // Mail
await this.handleMail(content, doc);
break;
case 3: // Tag setzen/entfernen
await this.handleTags(content, doc);
break;
case 4: // Custom Field setzen
await this.handleCustomField(content, doc);
break;
case 5: // Webhook
await this.handleWebhook(content, doc);
break;
case 6: // Notiz hinzufügen
await this.handleNote(content, doc);
break;
default:
this.logger.warn(`Unbekannter ActionType: ${action.ActionType}`);
}
}
private async enrichDocWithNames(doc: any): Promise<void> {
try {
if (doc.correspondent && !doc._correspondentName) {
const response = await this.paperlessService.getCorrespondents({ page_size: 9999 });
const correspondents = response.results;
const c = correspondents.find((x: any) => x.id === doc.correspondent);
doc._correspondentName = c?.name ?? '';
}
if (doc.document_type && !doc._documentTypeName) {
const docTypes = await this.paperlessService.getDocumentTypes();
const dt = docTypes.find((x: any) => x.id === doc.document_type);
doc._documentTypeName = dt?.name ?? '';
}
} catch (err: any) {
this.logger.warn(`Konnte Namen nicht auflösen: ${err.message}`);
}
}
private resolveTemplate(template: string, doc: any): string {
const created = doc.created ? new Date(doc.created) : null;
const replacements: Record<string, string> = {
'{id}': String(doc.id ?? ''),
'{titel}': doc.title ?? '',
'{korrespondent}': String(doc.correspondent ?? ''),
'{absender}': String(doc.correspondent ?? ''),
'{korrespondent_name}': doc._correspondentName ?? String(doc.correspondent ?? ''),
'{absender_name}': doc._correspondentName ?? String(doc.correspondent ?? ''),
'{dokumenttyp}': String(doc.document_type ?? ''),
'{dokumenttyp_name}': doc._documentTypeName ?? String(doc.document_type ?? ''),
'{besitzer}': String(doc.owner ?? ''),
'{ablagenummer}': String(doc.archive_serial_number ?? ''),
'{datum}': created ? `${created.getFullYear()}-${String(created.getMonth() + 1).padStart(2, '0')}-${String(created.getDate()).padStart(2, '0')}` : '',
'{jahr}': created ? String(created.getFullYear()) : '',
'{monat}': created ? String(created.getMonth() + 1).padStart(2, '0') : '',
'{tag}': created ? String(created.getDate()).padStart(2, '0') : '',
'{zeitstempel}': (() => {
const now = new Date();
return `${String(now.getDate()).padStart(2, '0')}.${String(now.getMonth() + 1).padStart(2, '0')}.${now.getFullYear()} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
})(),
};
// Custom Fields: {custom_field_<id>}
for (const cf of (doc.custom_fields || [])) {
replacements[`{custom_field_${cf.field}}`] = String(cf.value ?? '');
}
let result = template;
for (const [placeholder, value] of Object.entries(replacements)) {
result = result.replaceAll(placeholder, value);
}
// Sanitize filename chars
return result.replace(/[<>:"/\\|?*]/g, '_');
}
private buildFilename(template: string | undefined, doc: any): string {
if (template) {
const resolved = this.resolveTemplate(template, doc);
return resolved.endsWith('.pdf') ? resolved : `${resolved}.pdf`;
}
return `${doc.title || `document_${doc.id}`}.pdf`;
}
private async handleExport(content: Record<string, any>, doc: any): Promise<void> {
const fileType = content.fileType || 'archive';
const buffer = await this.paperlessService.downloadDocument(doc.id, fileType);
const filename = this.buildFilename(content.filenameTemplate, doc);
await this.exportService.exportFile(content.exportTargetId, filename, buffer);
}
private async handleMail(content: Record<string, any>, doc: any): Promise<void> {
const fileType = content.fileType || 'archive';
const buffer = await this.paperlessService.downloadDocument(doc.id, fileType);
const filename = this.buildFilename(content.filenameTemplate, doc);
const subject = content.subject
? this.resolveTemplate(content.subject, doc)
: `Dokument: ${doc.title}`;
const body = content.body
? this.resolveTemplate(content.body, doc)
: `Anbei das Dokument "${doc.title}" (ID: ${doc.id}).`;
await this.mailService.sendMail({
to: content.to,
subject,
body,
attachments: [{ filename, content: buffer }],
});
}
private async handleTags(content: Record<string, any>, doc: any): Promise<void> {
const currentTags = new Set<number>(doc.tags || []);
const addTags: number[] = content.addTags || [];
const removeTags: number[] = content.removeTags || [];
addTags.forEach(t => currentTags.add(t));
removeTags.forEach(t => currentTags.delete(t));
await this.paperlessService.updateDocument(doc.id, { tags: Array.from(currentTags) });
}
private async handleCustomField(content: Record<string, any>, doc: any): Promise<void> {
const customFields = [...(doc.custom_fields || [])];
const existing = customFields.find((f: any) => f.field === content.fieldId);
if (existing) {
existing.value = content.value;
} else {
customFields.push({ field: content.fieldId, value: content.value });
}
await this.paperlessService.updateDocument(doc.id, { custom_fields: customFields });
}
private async handleWebhook(content: Record<string, any>, doc: any): Promise<void> {
const method = (content.method || 'POST').toUpperCase();
const headers = content.headers || {};
const body = { documentId: doc.id, title: doc.title, tags: doc.tags, ...(content.body || {}) };
await axios({
method,
url: content.url,
headers,
data: method !== 'GET' ? body : undefined,
params: method === 'GET' ? body : undefined,
timeout: 30000,
});
this.logger.log(`Webhook ${method}${content.url}`);
}
private async handleNote(content: Record<string, any>, doc: any): Promise<void> {
if (!content.note) return;
const resolvedNote = this.resolveTemplate(content.note, doc);
await this.paperlessService.addNote(doc.id, resolvedNote);
}
// ── Logging ──────────────────────────────────────────────────────
private async log(ppId: number, actionId: number | null, docId: number, status: string, message: string): Promise<void> {
const entry = this.logRepo.create({
PostprocessingId: ppId,
ActionId: actionId,
DocumentId: docId,
Status: status,
Message: message,
CreatedAt: new Date(),
});
await this.logRepo.save(entry);
}
}