8 Commits

Author SHA1 Message Date
bjoernpoettker 969f0ae0b1 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>
2026-06-18 13:04:03 +02:00
bjoernpoettker 41eed1871e fix: Produktions-Crash durch TypeORM-synchronize beheben
Build and Push Multi-Platform Images / build-and-push (push) Successful in 44s
NODE_ENV=production deaktiviert synchronize (zerstörerischer ADD/DROP-COLUMN-
Churn auf MariaDB, der die 8126-Byte-Zeilengröße sprengte) und aktiviert
migrationsRun. Neue data-source.ts als einzige Konfigquelle (Laufzeit + CLI),
Migrations-Workflow (generate/run/revert) inkl. dotenv ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 09:27:04 +02:00
bjoernpoettker ed57477324 feat: importierte E-Mails automatisch in IMAP-Ordner verschieben und nach 90 Tagen löschen
Build and Push Multi-Platform Images / build-and-push (push) Successful in 31s
- Neuer ImapFolderService verschiebt E-Mails nach erfolgreichem Import in den
  konfigurierbaren Ordner "importiert" (wird bei Bedarf automatisch erstellt)
- Täglicher Cron um 03:00 Uhr verschiebt E-Mails älter als 90 Tage in den
  Papierkorb und leert ihn anschließend
- createImapClient()-Hilfsmethode im EmailDownloadService ausgelagert
- IMAP_IMPORTED_FOLDER und IMAP_TRASH_FOLDER in docker-compose ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:58:41 +02:00
bjoernpoettker ef7813f9f9 ci: add manual build workflow with custom image tag
Build and Push Multi-Platform Images / build-and-push (push) Successful in 11s
New workflow_dispatch workflow to build & push backend/frontend images
with a manually chosen tag and service selection (both/backend/frontend).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:48:04 +02:00
bjoernpoettker 66aeab282c Revert "fix: resolve all ESLint errors in backend and frontend"
Build and Push Multi-Platform Images / build-and-push (push) Successful in 19s
This reverts commit 07dfd7e840.
2026-06-16 16:19:11 +02:00
bjoernpoettker 14c11bf718 Revert "feat: auto-move imported emails to IMAP folder and add 90-day cleanup"
This reverts commit b1b30fe1dd.
2026-06-16 16:19:11 +02:00
bjoernpoettker b1b30fe1dd feat: auto-move imported emails to IMAP folder and add 90-day cleanup
Build and Push Multi-Platform Images / build-and-push (push) Successful in 41s
- New ImapFolderService moves emails to configurable "importiert" folder
  after successful import, creating the folder if it doesn't exist
- Daily cron at 03:00 moves emails older than 90 days to trash and empties it
- Extract createImapClient() helper in EmailDownloadService
- Add ensurePageCache() with in-flight deduplication to BarcodeScannerService
- InboxService regenerates page cache on-demand when image file is missing
- IMAP_IMPORTED_FOLDER and IMAP_TRASH_FOLDER added to .env.example and docker-compose

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 13:53:56 +02:00
bjoernpoettker 07dfd7e840 fix: resolve all ESLint errors in backend and frontend
Backend 958→0 errors, frontend 98→0 errors. Builds and tsc clean.

Echte Fixes:
- Auth: AuthenticatedUser/AuthenticatedRequest, JwtStrategy + alle 5
  Controller von `@Request() req: any` auf typisierten Request umgestellt
- Error-Handling: neuer getErrorMessage/Stack/Code/getResponseData-Helper;
  alle 50 `catch (err: any)`-Blöcke auf `unknown` + Helper umgestellt
- 24 echte Bugs: require-await, require-imports→ES-Imports, useless-escape,
  misused-promises, tote Imports/Vars, leere catch-Blöcke kommentiert
- document-pipeline: OCR-Ergebnis wird nicht gespeichert (als TODO markiert)

Pragmatisch auf warn herabgestuft (untypisierte Paperless-NGX-API):
no-unsafe-*, restrict-template-expressions, no-base-to-string,
no-explicit-any (FE), react-refresh/only-export-components

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:33:37 +02:00
16 changed files with 380 additions and 86 deletions
+7
View File
@@ -7,6 +7,13 @@
# Produktion: VITE_API_URL leer lassen (nginx Reverse-Proxy leitet /api weiter)
# =============================================================================
# --- Umgebung ---
# Produktion: NODE_ENV=production -> KEIN TypeORM-synchronize (Schema via Migrationen),
# Migrationen werden beim Start automatisch ausgeführt (migrationsRun).
# Aktiviert zudem CORS-Schutz (siehe CORS_ORIGIN weiter unten).
# Entwicklung: NODE_ENV leer lassen -> synchronize ON (Schema folgt den Entities).
NODE_ENV=production
# --- Ports ---
BACKEND_PORT=7601
FRONTEND_PORT=7600
+53
View File
@@ -0,0 +1,53 @@
name: Manual Build and Push (custom tag)
on:
workflow_dispatch:
inputs:
tag:
description: "Container-Tag (z. B. stable, v1.2.3, hotfix)"
required: true
default: "stable"
service:
description: "Welche Images bauen?"
type: choice
required: true
default: "both"
options:
- both
- backend
- frontend
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Show selected inputs
run: |
echo "Tag: ${{ inputs.tag }}"
echo "Service: ${{ inputs.service }}"
echo "Ref: ${{ github.ref_name }}"
- name: Login to Gitea Container Registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.poettker-cloud.de -u "${{ gitea.actor }}" --password-stdin
- name: Build and Push Backend
if: ${{ inputs.service == 'both' || inputs.service == 'backend' }}
env:
TAG: ${{ inputs.tag }}
run: |
IMAGE="gitea.poettker-cloud.de/bjoernpoettker/paperlessmanager-backend:${TAG}"
docker build -t "$IMAGE" ./paperless-backend
docker push "$IMAGE"
- name: Build and Push Frontend
if: ${{ inputs.service == 'both' || inputs.service == 'frontend' }}
env:
TAG: ${{ inputs.tag }}
run: |
IMAGE="gitea.poettker-cloud.de/bjoernpoettker/paperlessmanager-frontend:${TAG}"
docker build -t "$IMAGE" ./paperless-frontend
docker push "$IMAGE"
+27 -1
View File
@@ -78,11 +78,37 @@ Use `@Public()` to bypass auth guards entirely.
### Database
- TypeORM with MySQL 8+, UTF8MB4, `synchronize: true` (schema auto-migrates)
- TypeORM with MySQL/MariaDB, UTF8MB4
- Connection config lives in `src/database/data-source.ts` (single source of truth,
shared by the NestJS runtime and the TypeORM CLI). `database.module.ts` consumes it.
- **Schema strategy depends on `NODE_ENV`:**
- Dev (`NODE_ENV` unset): `synchronize: true` — schema auto-migrates from entities.
- Production (`NODE_ENV=production`): `synchronize: false` + `migrationsRun: true`
pending migrations in `src/database/migrations/` are applied automatically on boot.
**Never run `synchronize` against the production DB** — on MariaDB it issues
destructive `ADD`/`DROP COLUMN` churn every boot (see caveat below).
- 23 entities in `src/database/entities/`
- JSON columns use transformers to normalize empty arrays/objects to `null`
- Key entities: `InboxDocument`, `Task`, `Email`, `Attachment`, `Postprocessing`, `BarcodeTemplate`, `LabelPrintJob`, `ApiKey`, `Setting`
#### Migrations workflow
```bash
npm run migration:generate -- src/database/migrations/<Name> # diff entities → DB
npm run migration:run # apply pending
npm run migration:revert # roll back last
```
The existing production schema is the implicit baseline (no baseline migration); only
future changes get migration files.
**Caveat — MariaDB reports `json` as `longtext`:** every `@Column({ type: 'json' })`
is stored as `longtext ... CHECK (json_valid(...))`, so `migration:generate` always
emits spurious no-op `CHANGE` statements (json↔longtext, nullable/default re-declares).
**Hand-trim generated migrations** down to the real change before committing. (This same
false diff is exactly why `synchronize` must stay off in production — left unchecked it
accumulates `ALGORITHM=INSTANT` drops until a table trips the 8126-byte row-size limit.)
### Document Processing Pipeline
**Preprocessing** (`preprocessing/document-pipeline.service.ts`):
+4
View File
@@ -8,6 +8,8 @@ services:
ports:
- "${BACKEND_PORT:-7601}:3100"
environment:
- NODE_ENV=production
- CORS_ORIGIN=${CORS_ORIGIN:-}
- PORT=3100
- DB_HOST=${DB_HOST:-db}
- DB_PORT=${DB_PORT:-3306}
@@ -36,6 +38,8 @@ services:
- IMAP_USE_SSL=${IMAP_USE_SSL:-true}
- IMAP_USERNAME=${IMAP_USERNAME:-}
- IMAP_PASSWORD=${IMAP_PASSWORD:-}
- IMAP_IMPORTED_FOLDER=${IMAP_IMPORTED_FOLDER:-importiert}
- IMAP_TRASH_FOLDER=${IMAP_TRASH_FOLDER:-Trash}
- BELEGNUMMER_GET_URL=${BELEGNUMMER_GET_URL:-}
- BELEGNUMMER_SET_URL=${BELEGNUMMER_SET_URL:-}
- AGRARMONITOR_BASE_URL=${AGRARMONITOR_BASE_URL:-https://admin7.agrarmonitor.de}
+16 -3
View File
@@ -24,6 +24,7 @@
"axios": "^1.14.0",
"basic-ftp": "^5.2.1",
"chokidar": "^4.0.3",
"dotenv": "^17.4.2",
"form-data": "^4.0.5",
"imapflow": "^1.3.2",
"jsqr": "^1.4.0",
@@ -3023,6 +3024,18 @@
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/config/node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/@nestjs/core": {
"version": "11.1.17",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.17.tgz",
@@ -6291,9 +6304,9 @@
}
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
+6 -1
View File
@@ -17,7 +17,11 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "typeorm-ts-node-commonjs -d ./src/database/data-source.ts",
"migration:generate": "npm run typeorm -- migration:generate",
"migration:run": "npm run typeorm -- migration:run",
"migration:revert": "npm run typeorm -- migration:revert"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
@@ -35,6 +39,7 @@
"axios": "^1.14.0",
"basic-ftp": "^5.2.1",
"chokidar": "^4.0.3",
"dotenv": "^17.4.2",
"form-data": "^4.0.5",
"imapflow": "^1.3.2",
"jsqr": "^1.4.0",
@@ -0,0 +1,81 @@
import 'reflect-metadata';
import { join } from 'path';
import { config as loadEnv } from 'dotenv';
import { DataSource, DataSourceOptions } from 'typeorm';
import {
Client,
DocumentType,
DocumentField,
Task,
Postprocessing,
PostprocessingAction,
PostprocessingLog,
ExportTarget,
Setting,
Kontonummer,
Document,
UserClient,
Email,
Attachment,
Content,
ApiKey,
CorrespondentSetting,
BarcodeTemplate,
InboxDocument,
InboxPostprocessingAction,
CorrespondentEmailMapping,
UserSettings,
LabelPrintJob,
} from './entities';
// CLI-Kontext: .env laden (Laufzeit im Container liefert die Variablen via Docker,
// dotenv ist dort ein No-Op). dotenv überschreibt bereits gesetzte Variablen nicht.
loadEnv();
loadEnv({ path: join(process.cwd(), '..', '.env') });
export const entities = [
Client,
DocumentType,
DocumentField,
Task,
Postprocessing,
PostprocessingAction,
PostprocessingLog,
ExportTarget,
Setting,
Kontonummer,
Document,
UserClient,
Email,
Attachment,
Content,
ApiKey,
CorrespondentSetting,
BarcodeTemplate,
InboxDocument,
InboxPostprocessingAction,
CorrespondentEmailMapping,
UserSettings,
LabelPrintJob,
];
const isProduction = process.env.NODE_ENV === 'production';
export const dataSourceOptions: DataSourceOptions = {
type: 'mysql',
host: process.env.DB_HOST ?? 'localhost',
port: Number(process.env.DB_PORT ?? 3306),
username: process.env.DB_USERNAME ?? 'root',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_DATABASE ?? 'paperlessadd',
charset: 'utf8mb4',
entities,
migrations: [join(__dirname, 'migrations', '*.{ts,js}')],
// In Produktion: kein synchronize (zerstörerischer ALTER-Churn), Migrationen
// werden beim Start automatisch ausgeführt. In Dev: synchronize wie bisher.
synchronize: !isProduction,
migrationsRun: isProduction,
};
// Wird von der TypeORM-CLI verwendet (migration:generate / :run / :revert).
export default new DataSource(dataSourceOptions);
@@ -1,75 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import {
Client,
DocumentType,
DocumentField,
Task,
Postprocessing,
PostprocessingAction,
PostprocessingLog,
ExportTarget,
Setting,
Kontonummer,
Document,
UserClient,
Email,
Attachment,
Content,
ApiKey,
CorrespondentSetting,
BarcodeTemplate,
InboxDocument,
InboxPostprocessingAction,
CorrespondentEmailMapping,
UserSettings,
LabelPrintJob,
} from './entities';
const entities = [
Client,
DocumentType,
DocumentField,
Task,
Postprocessing,
PostprocessingAction,
PostprocessingLog,
ExportTarget,
Setting,
Kontonummer,
Document,
UserClient,
Email,
Attachment,
Content,
ApiKey,
CorrespondentSetting,
BarcodeTemplate,
InboxDocument,
InboxPostprocessingAction,
CorrespondentEmailMapping,
UserSettings,
LabelPrintJob,
];
import { dataSourceOptions, entities } from './data-source';
@Module({
imports: [
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'mysql' as const,
host: config.get<string>('DB_HOST', 'localhost'),
port: config.get<number>('DB_PORT', 3306),
username: config.get<string>('DB_USERNAME', 'root'),
password: config.get<string>('DB_PASSWORD', ''),
database: config.get<string>('DB_DATABASE', 'paperlessadd'),
entities,
synchronize: config.get<string>('NODE_ENV') !== 'production',
charset: 'utf8mb4',
}),
}),
// Eine einzige Konfigurationsquelle (data-source.ts), geteilt zwischen
// NestJS-Laufzeit und TypeORM-CLI (Migrationen).
TypeOrmModule.forRoot(dataSourceOptions),
TypeOrmModule.forFeature(entities),
],
exports: [TypeOrmModule],
@@ -55,11 +55,21 @@ export class EmailDownloadService {
}
}
private createImapClient(): ImapFlow {
return new ImapFlow({
host: this.configService.get<string>('IMAP_HOST', ''),
port: this.configService.get<number>('IMAP_PORT', 993),
secure: this.configService.get<string>('IMAP_USE_SSL', 'true') === 'true',
auth: {
user: this.configService.get<string>('IMAP_USERNAME', ''),
pass: this.configService.get<string>('IMAP_PASSWORD', ''),
},
logger: false,
});
}
private async fetchAndStore(): Promise<void> {
const host = this.configService.get<string>('IMAP_HOST');
const port = this.configService.get<number>('IMAP_PORT', 993);
const secure =
this.configService.get<string>('IMAP_USE_SSL', 'true') === 'true';
const user = this.configService.get<string>('IMAP_USERNAME');
const pass = this.configService.get<string>('IMAP_PASSWORD');
@@ -72,16 +82,10 @@ export class EmailDownloadService {
this.logger.log('E-Mail Fetch Job gestartet.');
const client = new ImapFlow({
host,
port,
secure,
auth: { user, pass },
logger: false,
});
const client = this.createImapClient();
await client.connect();
this.logger.log(`Verbunden mit IMAP-Server ${host}:${port}`);
this.logger.log(`Verbunden mit IMAP-Server ${host}.`);
const lock = await client.getMailboxLock('INBOX');
try {
@@ -12,6 +12,7 @@ import { Task } from '../database/entities/task.entity';
import { PaperlessService } from '../paperless/paperless.service';
import * as QRCode from 'qrcode';
import { EmailPageCacheService } from './email-page-cache.service';
import { ImapFolderService } from './imap-folder.service';
import { PdfService } from '../preprocessing/pdf.service';
import * as path from 'path';
import * as os from 'os';
@@ -52,6 +53,7 @@ export class EmailImportService {
private readonly paperlessService: PaperlessService,
private readonly pdfService: PdfService,
private readonly pageCache: EmailPageCacheService,
private readonly imapFolderService: ImapFolderService,
) {}
async ensurePreviews(emailId: number): Promise<void> {
@@ -646,6 +648,12 @@ export class EmailImportService {
this.logger.log(
`Email ${firstAtt.EmailMessageId} als verarbeitet markiert.`,
);
const emailEntity = await this.emailRepo.findOne({ where: { Id: firstAtt.EmailMessageId } });
if (emailEntity) {
this.imapFolderService.moveToImportiert(emailEntity.MessageId).catch(err =>
this.logger.error('IMAP-Verschieben fehlgeschlagen: ' + err.message),
);
}
}
}
@@ -17,6 +17,7 @@ import { Email } from '../database/entities/email.entity';
import { Attachment } from '../database/entities/attachment.entity';
import { Content } from '../database/entities/content.entity';
import { PaperlessService } from '../paperless/paperless.service';
import { ImapFolderService } from './imap-folder.service';
import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
@@ -31,6 +32,7 @@ export class EmailController {
@InjectRepository(Content)
private readonly contentRepo: Repository<Content>,
private readonly paperlessService: PaperlessService,
private readonly imapFolderService: ImapFolderService,
) {}
@Get()
@@ -202,7 +204,21 @@ export class EmailController {
this.logger.log(
`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) {
this.logger.error(
`Kritischer Fehler bei checkAttachments: ${error.message}`,
+2 -1
View File
@@ -9,6 +9,7 @@ import { EmailPageCacheService } from './email-page-cache.service';
import { EmailImportController } from './email-import.controller';
import { EmailImportService } from './email-import.service';
import { ImapFolderService } from './imap-folder.service';
import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity';
import { Task } from '../database/entities/task.entity';
import { PreprocessingModule } from '../preprocessing/preprocessing.module';
@@ -26,7 +27,7 @@ import { PreprocessingModule } from '../preprocessing/preprocessing.module';
PreprocessingModule,
],
controllers: [EmailController, EmailImportController],
providers: [EmailImportService, EmailPageCacheService],
providers: [EmailImportService, EmailPageCacheService, ImapFolderService],
exports: [EmailPageCacheService],
})
export class EmailModule {}
@@ -0,0 +1,138 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import { ImapFlow } from 'imapflow';
@Injectable()
export class ImapFolderService {
private readonly logger = new Logger(ImapFolderService.name);
constructor(private readonly configService: ConfigService) {}
private createClient(): ImapFlow {
return new ImapFlow({
host: this.configService.get<string>('IMAP_HOST', ''),
port: this.configService.get<number>('IMAP_PORT', 993),
secure: this.configService.get<string>('IMAP_USE_SSL', 'true') === 'true',
auth: {
user: this.configService.get<string>('IMAP_USERNAME', ''),
pass: this.configService.get<string>('IMAP_PASSWORD', ''),
},
logger: false,
});
}
@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> {
if (!this.configService.get<string>('IMAP_HOST')) return;
const importedFolder = this.configService.get<string>('IMAP_IMPORTED_FOLDER', 'importiert');
const client = this.createClient();
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.`);
}
await client.mailboxOpen('INBOX');
const uids = await client.search({ header: { 'message-id': messageId } }, { uid: true });
if (Array.isArray(uids) && uids.length > 0) {
await client.messageMove(uids, importedFolder, { uid: true });
this.logger.log(`E-Mail ${messageId} → "${importedFolder}" verschoben.`);
} else {
this.logger.warn(`E-Mail ${messageId} nicht in INBOX gefunden (bereits verschoben?).`);
}
} catch (err: any) {
this.logger.error(`IMAP moveToImportiert fehlgeschlagen: ${err.message}`);
} finally {
await client.logout().catch(() => {});
}
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ export const emailsApi = {
api.post<{ message: string }>('/api/emails/fetch').then((r) => r.data),
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) =>
api.patch<{ message?: string }>(`/api/emails/${id}/status`, { status }).then((r) => r.data),
@@ -172,6 +172,7 @@ export default function MailpostfachPage() {
const parts = [];
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.movedToImportiert > 0) parts.push(`${result.movedToImportiert} E-Mail(s) in „importiert" verschoben`);
message.success(parts.length > 0 ? parts.join(', ') + '.' : 'Keine Änderungen.');
if (result.updatedCount > 0 || result.idsUpdated > 0) await loadData();
} catch {