Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c665451abf |
@@ -7,13 +7,6 @@
|
|||||||
# Produktion: VITE_API_URL leer lassen (nginx Reverse-Proxy leitet /api weiter)
|
# 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 ---
|
# --- Ports ---
|
||||||
BACKEND_PORT=7601
|
BACKEND_PORT=7601
|
||||||
FRONTEND_PORT=7600
|
FRONTEND_PORT=7600
|
||||||
|
|||||||
@@ -78,37 +78,11 @@ Use `@Public()` to bypass auth guards entirely.
|
|||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
||||||
- TypeORM with MySQL/MariaDB, UTF8MB4
|
- TypeORM with MySQL 8+, UTF8MB4, `synchronize: true` (schema auto-migrates)
|
||||||
- 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/`
|
- 23 entities in `src/database/entities/`
|
||||||
- JSON columns use transformers to normalize empty arrays/objects to `null`
|
- JSON columns use transformers to normalize empty arrays/objects to `null`
|
||||||
- Key entities: `InboxDocument`, `Task`, `Email`, `Attachment`, `Postprocessing`, `BarcodeTemplate`, `LabelPrintJob`, `ApiKey`, `Setting`
|
- 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
|
### Document Processing Pipeline
|
||||||
|
|
||||||
**Preprocessing** (`preprocessing/document-pipeline.service.ts`):
|
**Preprocessing** (`preprocessing/document-pipeline.service.ts`):
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-7601}:3100"
|
- "${BACKEND_PORT:-7601}:3100"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
|
||||||
- CORS_ORIGIN=${CORS_ORIGIN:-}
|
|
||||||
- PORT=3100
|
- PORT=3100
|
||||||
- DB_HOST=${DB_HOST:-db}
|
- DB_HOST=${DB_HOST:-db}
|
||||||
- DB_PORT=${DB_PORT:-3306}
|
- DB_PORT=${DB_PORT:-3306}
|
||||||
@@ -38,8 +36,6 @@ services:
|
|||||||
- IMAP_USE_SSL=${IMAP_USE_SSL:-true}
|
- IMAP_USE_SSL=${IMAP_USE_SSL:-true}
|
||||||
- IMAP_USERNAME=${IMAP_USERNAME:-}
|
- IMAP_USERNAME=${IMAP_USERNAME:-}
|
||||||
- IMAP_PASSWORD=${IMAP_PASSWORD:-}
|
- 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_GET_URL=${BELEGNUMMER_GET_URL:-}
|
||||||
- BELEGNUMMER_SET_URL=${BELEGNUMMER_SET_URL:-}
|
- BELEGNUMMER_SET_URL=${BELEGNUMMER_SET_URL:-}
|
||||||
- AGRARMONITOR_BASE_URL=${AGRARMONITOR_BASE_URL:-https://admin7.agrarmonitor.de}
|
- AGRARMONITOR_BASE_URL=${AGRARMONITOR_BASE_URL:-https://admin7.agrarmonitor.de}
|
||||||
|
|||||||
Generated
+3
-16
@@ -24,7 +24,6 @@
|
|||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
"basic-ftp": "^5.2.1",
|
"basic-ftp": "^5.2.1",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
"dotenv": "^17.4.2",
|
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"imapflow": "^1.3.2",
|
"imapflow": "^1.3.2",
|
||||||
"jsqr": "^1.4.0",
|
"jsqr": "^1.4.0",
|
||||||
@@ -3024,18 +3023,6 @@
|
|||||||
"rxjs": "^7.1.0"
|
"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": {
|
"node_modules/@nestjs/core": {
|
||||||
"version": "11.1.17",
|
"version": "11.1.17",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.17.tgz",
|
||||||
@@ -6304,9 +6291,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.4.2",
|
"version": "17.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
|
|||||||
@@ -17,11 +17,7 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"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": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
@@ -39,7 +35,6 @@
|
|||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
"basic-ftp": "^5.2.1",
|
"basic-ftp": "^5.2.1",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
"dotenv": "^17.4.2",
|
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"imapflow": "^1.3.2",
|
"imapflow": "^1.3.2",
|
||||||
"jsqr": "^1.4.0",
|
"jsqr": "^1.4.0",
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
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,12 +1,75 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { dataSourceOptions, entities } from './data-source';
|
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,
|
||||||
|
];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
// Eine einzige Konfigurationsquelle (data-source.ts), geteilt zwischen
|
TypeOrmModule.forRootAsync({
|
||||||
// NestJS-Laufzeit und TypeORM-CLI (Migrationen).
|
imports: [ConfigModule],
|
||||||
TypeOrmModule.forRoot(dataSourceOptions),
|
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',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
TypeOrmModule.forFeature(entities),
|
TypeOrmModule.forFeature(entities),
|
||||||
],
|
],
|
||||||
exports: [TypeOrmModule],
|
exports: [TypeOrmModule],
|
||||||
|
|||||||
@@ -55,21 +55,11 @@ 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> {
|
private async fetchAndStore(): Promise<void> {
|
||||||
const host = this.configService.get<string>('IMAP_HOST');
|
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 user = this.configService.get<string>('IMAP_USERNAME');
|
||||||
const pass = this.configService.get<string>('IMAP_PASSWORD');
|
const pass = this.configService.get<string>('IMAP_PASSWORD');
|
||||||
|
|
||||||
@@ -82,10 +72,16 @@ export class EmailDownloadService {
|
|||||||
|
|
||||||
this.logger.log('E-Mail Fetch Job gestartet.');
|
this.logger.log('E-Mail Fetch Job gestartet.');
|
||||||
|
|
||||||
const client = this.createImapClient();
|
const client = new ImapFlow({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure,
|
||||||
|
auth: { user, pass },
|
||||||
|
logger: false,
|
||||||
|
});
|
||||||
|
|
||||||
await client.connect();
|
await client.connect();
|
||||||
this.logger.log(`Verbunden mit IMAP-Server ${host}.`);
|
this.logger.log(`Verbunden mit IMAP-Server ${host}:${port}`);
|
||||||
|
|
||||||
const lock = await client.getMailboxLock('INBOX');
|
const lock = await client.getMailboxLock('INBOX');
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { Task } from '../database/entities/task.entity';
|
|||||||
import { PaperlessService } from '../paperless/paperless.service';
|
import { PaperlessService } from '../paperless/paperless.service';
|
||||||
import * as QRCode from 'qrcode';
|
import * as QRCode from 'qrcode';
|
||||||
import { EmailPageCacheService } from './email-page-cache.service';
|
import { EmailPageCacheService } from './email-page-cache.service';
|
||||||
import { ImapFolderService } from './imap-folder.service';
|
|
||||||
import { PdfService } from '../preprocessing/pdf.service';
|
import { PdfService } from '../preprocessing/pdf.service';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
@@ -53,7 +52,6 @@ export class EmailImportService {
|
|||||||
private readonly paperlessService: PaperlessService,
|
private readonly paperlessService: PaperlessService,
|
||||||
private readonly pdfService: PdfService,
|
private readonly pdfService: PdfService,
|
||||||
private readonly pageCache: EmailPageCacheService,
|
private readonly pageCache: EmailPageCacheService,
|
||||||
private readonly imapFolderService: ImapFolderService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ensurePreviews(emailId: number): Promise<void> {
|
async ensurePreviews(emailId: number): Promise<void> {
|
||||||
@@ -648,12 +646,6 @@ export class EmailImportService {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Email ${firstAtt.EmailMessageId} als verarbeitet markiert.`,
|
`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,7 +17,6 @@ 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';
|
||||||
|
|
||||||
@@ -32,7 +31,6 @@ 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()
|
||||||
@@ -204,21 +202,7 @@ 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.`,
|
||||||
);
|
);
|
||||||
this.imapFolderService.cleanupImportedEmails().catch(err =>
|
return { updatedCount, idsUpdated };
|
||||||
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}`,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { EmailPageCacheService } from './email-page-cache.service';
|
|||||||
|
|
||||||
import { EmailImportController } from './email-import.controller';
|
import { EmailImportController } from './email-import.controller';
|
||||||
import { EmailImportService } from './email-import.service';
|
import { EmailImportService } from './email-import.service';
|
||||||
import { ImapFolderService } from './imap-folder.service';
|
|
||||||
import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity';
|
import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity';
|
||||||
import { Task } from '../database/entities/task.entity';
|
import { Task } from '../database/entities/task.entity';
|
||||||
import { PreprocessingModule } from '../preprocessing/preprocessing.module';
|
import { PreprocessingModule } from '../preprocessing/preprocessing.module';
|
||||||
@@ -27,7 +26,7 @@ import { PreprocessingModule } from '../preprocessing/preprocessing.module';
|
|||||||
PreprocessingModule,
|
PreprocessingModule,
|
||||||
],
|
],
|
||||||
controllers: [EmailController, EmailImportController],
|
controllers: [EmailController, EmailImportController],
|
||||||
providers: [EmailImportService, EmailPageCacheService, ImapFolderService],
|
providers: [EmailImportService, EmailPageCacheService],
|
||||||
exports: [EmailPageCacheService],
|
exports: [EmailPageCacheService],
|
||||||
})
|
})
|
||||||
export class EmailModule {}
|
export class EmailModule {}
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
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(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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; movedToImportiert: number }>('/api/emails/check-attachments', { includeProcessed }).then((r) => r.data),
|
api.post<{ updatedCount: number; idsUpdated: 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,7 +172,6 @@ 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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user