diff --git a/paperless-backend/src/app.module.ts b/paperless-backend/src/app.module.ts index b988881..da8b64f 100644 --- a/paperless-backend/src/app.module.ts +++ b/paperless-backend/src/app.module.ts @@ -16,6 +16,7 @@ import { KontonummernModule } from './kontonummern/kontonummern.module'; import { StatsModule } from './stats/stats.module'; import { BarcodeModule } from './barcode/barcode.module'; import { InboxPostprocessorModule } from './inbox-postprocessor/inbox-postprocessor.module'; +import { UserSettingsModule } from './user-settings/user-settings.module'; import * as path from 'path'; @Module({ @@ -43,6 +44,7 @@ import * as path from 'path'; StatsModule, BarcodeModule, InboxPostprocessorModule, + UserSettingsModule, ], }) export class AppModule {} diff --git a/paperless-backend/src/database/database.module.ts b/paperless-backend/src/database/database.module.ts index 73cd8c6..10862d6 100644 --- a/paperless-backend/src/database/database.module.ts +++ b/paperless-backend/src/database/database.module.ts @@ -23,6 +23,7 @@ import { InboxDocument, InboxPostprocessingAction, CorrespondentEmailMapping, + UserSettings, } from './entities'; const entities = [ @@ -47,6 +48,7 @@ const entities = [ InboxDocument, InboxPostprocessingAction, CorrespondentEmailMapping, + UserSettings, ]; @Module({ diff --git a/paperless-backend/src/database/entities/index.ts b/paperless-backend/src/database/entities/index.ts index 8051dbc..31b738b 100644 --- a/paperless-backend/src/database/entities/index.ts +++ b/paperless-backend/src/database/entities/index.ts @@ -19,3 +19,4 @@ export { BarcodeTemplate } from './barcode-template.entity'; export { InboxDocument } from './inbox-document.entity'; export { InboxPostprocessingAction } from './inbox-postprocessing-action.entity'; export { CorrespondentEmailMapping } from './correspondent-email-mapping.entity'; +export { UserSettings } from './user-settings.entity'; diff --git a/paperless-backend/src/database/entities/user-settings.entity.ts b/paperless-backend/src/database/entities/user-settings.entity.ts new file mode 100644 index 0000000..2359758 --- /dev/null +++ b/paperless-backend/src/database/entities/user-settings.entity.ts @@ -0,0 +1,28 @@ +import { Entity, PrimaryColumn, Column } from 'typeorm'; + +@Entity('user_settings') +export class UserSettings { + @PrimaryColumn({ type: 'varchar', length: 255 }) + UserId!: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + SmtpHost!: string | null; + + @Column({ type: 'int', nullable: true }) + SmtpPort!: number | null; + + @Column({ type: 'boolean', default: false }) + SmtpSecure!: boolean; + + @Column({ type: 'varchar', length: 255, nullable: true }) + SmtpUser!: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + SmtpPass!: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + SmtpFrom!: string | null; + + @Column({ type: 'text', nullable: true }) + MailSignatureHtml!: string | null; +} diff --git a/paperless-backend/src/inbox/inbox.controller.ts b/paperless-backend/src/inbox/inbox.controller.ts index 068989e..ae0be74 100644 --- a/paperless-backend/src/inbox/inbox.controller.ts +++ b/paperless-backend/src/inbox/inbox.controller.ts @@ -18,6 +18,7 @@ import { createReadStream } from 'fs'; import { InboxService } from './inbox.service'; import { InboxPostprocessorService } from '../inbox-postprocessor/inbox-postprocessor.service'; import { BarcodeScannerService } from '../barcode/barcode-scanner.service'; +import { UserSettingsService } from '../user-settings/user-settings.service'; import { RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; @@ -28,6 +29,7 @@ export class InboxController { private readonly inboxService: InboxService, private readonly postprocessor: InboxPostprocessorService, private readonly barcodeScanner: BarcodeScannerService, + private readonly userSettingsService: UserSettingsService, ) {} @Get() @@ -205,6 +207,10 @@ export class InboxController { @Request() req: any, ): Promise { const preferredUsername: string | null = req.user?.preferredUsername ?? null; - await this.inboxService.sendAsEmail(id, preferredUsername, body); + const smtpOverride = await this.userSettingsService.getSmtpConfig(req.user.userId); + await this.inboxService.sendAsEmail(id, preferredUsername, { + ...body, + smtpOverride: smtpOverride ?? undefined, + }); } } diff --git a/paperless-backend/src/inbox/inbox.module.ts b/paperless-backend/src/inbox/inbox.module.ts index edbd62c..24a9516 100644 --- a/paperless-backend/src/inbox/inbox.module.ts +++ b/paperless-backend/src/inbox/inbox.module.ts @@ -10,6 +10,7 @@ import { InboxMigrationService } from './inbox-migration.service'; import { BarcodeModule } from '../barcode/barcode.module'; import { InboxPostprocessorModule } from '../inbox-postprocessor/inbox-postprocessor.module'; import { PostprocessingModule } from '../postprocessing/postprocessing.module'; +import { UserSettingsModule } from '../user-settings/user-settings.module'; @Module({ imports: [ @@ -17,6 +18,7 @@ import { PostprocessingModule } from '../postprocessing/postprocessing.module'; BarcodeModule, InboxPostprocessorModule, PostprocessingModule, + UserSettingsModule, ], controllers: [InboxController, ClientsController], providers: [InboxService, InboxMigrationService], diff --git a/paperless-backend/src/inbox/inbox.service.ts b/paperless-backend/src/inbox/inbox.service.ts index 2e6b63c..6cb23c9 100644 --- a/paperless-backend/src/inbox/inbox.service.ts +++ b/paperless-backend/src/inbox/inbox.service.ts @@ -274,6 +274,7 @@ export class InboxService { body: string; html?: string; segments: { pages: number[]; filename: string }[]; + smtpOverride?: { host: string; port: number; secure: boolean; user: string; pass: string; from: string }; }, ): Promise { const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername); @@ -294,6 +295,7 @@ export class InboxService { body: opts.body, html: opts.html, attachments, + smtpOverride: opts.smtpOverride, }); } } diff --git a/paperless-backend/src/postprocessing/mail.service.ts b/paperless-backend/src/postprocessing/mail.service.ts index d04f701..4b897e8 100644 --- a/paperless-backend/src/postprocessing/mail.service.ts +++ b/paperless-backend/src/postprocessing/mail.service.ts @@ -25,10 +25,27 @@ export class MailService { body: string; html?: string; attachments?: { filename: string; content: Buffer }[]; + smtpOverride?: { host: string; port: number; secure: boolean; user: string; pass: string; from: string }; }): Promise { - const from = this.configService.get('SMTP_FROM', 'paperless@localhost'); + let transporter = this.transporter; + let from = this.configService.get('SMTP_FROM', 'paperless@localhost'); - await this.transporter.sendMail({ + if (options.smtpOverride) { + const o = options.smtpOverride; + transporter = nodemailer.createTransport({ + host: o.host, + port: o.port, + secure: o.secure, + auth: { user: o.user, pass: o.pass }, + }); + from = o.from || from; + } else { + this.logger.debug( + `SMTP config — host: ${this.configService.get('SMTP_HOST')} port: ${this.configService.get('SMTP_PORT')} secure: ${this.configService.get('SMTP_SECURE')} user: ${this.configService.get('SMTP_USER')} from: ${from}`, + ); + } + + const info = await transporter.sendMail({ from, to: options.to, subject: options.subject, @@ -40,6 +57,8 @@ export class MailService { })), }); - this.logger.log(`Mail gesendet an ${options.to}: "${options.subject}"`); + this.logger.log( + `Mail gesendet an ${options.to}: "${options.subject}" — messageId: ${info.messageId} accepted: ${JSON.stringify(info.accepted)} rejected: ${JSON.stringify(info.rejected)} response: ${info.response}`, + ); } } diff --git a/paperless-backend/src/user-settings/user-settings.controller.ts b/paperless-backend/src/user-settings/user-settings.controller.ts new file mode 100644 index 0000000..e65e732 --- /dev/null +++ b/paperless-backend/src/user-settings/user-settings.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Get, HttpCode, Post, Put, Request } from '@nestjs/common'; +import { UserSettingsService } from './user-settings.service'; + +@Controller('api/user-settings') +export class UserSettingsController { + constructor(private readonly userSettingsService: UserSettingsService) {} + + @Get() + async getSettings(@Request() req: any) { + return this.userSettingsService.getSettings(req.user.userId); + } + + @Put() + async updateSettings(@Request() req: any, @Body() body: any) { + return this.userSettingsService.updateSettings(req.user.userId, body); + } + + @Post('test-smtp') + @HttpCode(200) + async testSmtp(@Body() body: { host: string; port: number; secure: boolean; user: string; pass: string }) { + return this.userSettingsService.testSmtp(body); + } +} diff --git a/paperless-backend/src/user-settings/user-settings.module.ts b/paperless-backend/src/user-settings/user-settings.module.ts new file mode 100644 index 0000000..c450bac --- /dev/null +++ b/paperless-backend/src/user-settings/user-settings.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UserSettings } from '../database/entities/user-settings.entity'; +import { UserSettingsService } from './user-settings.service'; +import { UserSettingsController } from './user-settings.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([UserSettings])], + providers: [UserSettingsService], + controllers: [UserSettingsController], + exports: [UserSettingsService], +}) +export class UserSettingsModule {} diff --git a/paperless-backend/src/user-settings/user-settings.service.ts b/paperless-backend/src/user-settings/user-settings.service.ts new file mode 100644 index 0000000..3f5800e --- /dev/null +++ b/paperless-backend/src/user-settings/user-settings.service.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as nodemailer from 'nodemailer'; +import { UserSettings } from '../database/entities/user-settings.entity'; + +export interface UserSettingsDto { + smtpHost: string | null; + smtpPort: number | null; + smtpSecure: boolean; + smtpUser: string | null; + smtpPassSet: boolean; + smtpFrom: string | null; + mailSignatureHtml: string | null; +} + +@Injectable() +export class UserSettingsService { + constructor( + @InjectRepository(UserSettings) + private readonly repo: Repository, + ) {} + + async getSettings(userId: string): Promise { + const entity = await this.repo.findOne({ where: { UserId: userId } }); + return this.toDto(entity); + } + + async updateSettings( + userId: string, + data: { + smtpHost?: string | null; + smtpPort?: number | null; + smtpSecure?: boolean; + smtpUser?: string | null; + smtpPass?: string | null; + smtpFrom?: string | null; + mailSignatureHtml?: string | null; + }, + ): Promise { + let entity = await this.repo.findOne({ where: { UserId: userId } }); + if (!entity) { + entity = this.repo.create({ UserId: userId }); + } + + if (data.smtpHost !== undefined) entity.SmtpHost = data.smtpHost; + if (data.smtpPort !== undefined) entity.SmtpPort = data.smtpPort; + if (data.smtpSecure !== undefined) entity.SmtpSecure = data.smtpSecure; + if (data.smtpUser !== undefined) entity.SmtpUser = data.smtpUser; + if (data.smtpPass !== undefined && data.smtpPass !== null && data.smtpPass !== '') { + entity.SmtpPass = data.smtpPass; + } + if (data.smtpFrom !== undefined) entity.SmtpFrom = data.smtpFrom; + if (data.mailSignatureHtml !== undefined) entity.MailSignatureHtml = data.mailSignatureHtml; + + await this.repo.save(entity); + return this.toDto(entity); + } + + async testSmtp(config: { + host: string; + port: number; + secure: boolean; + user: string; + pass: string; + }): Promise<{ ok: boolean; error?: string }> { + const transporter = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + auth: { user: config.user, pass: config.pass }, + }); + try { + await transporter.verify(); + return { ok: true }; + } catch (err: any) { + return { ok: false, error: err.message }; + } + } + + async getSmtpConfig(userId: string): Promise<{ + host: string; port: number; secure: boolean; user: string; pass: string; from: string; + } | null> { + const entity = await this.repo.findOne({ where: { UserId: userId } }); + if (!entity?.SmtpHost || !entity?.SmtpPass) return null; + return { + host: entity.SmtpHost, + port: entity.SmtpPort ?? 587, + secure: entity.SmtpSecure, + user: entity.SmtpUser ?? '', + pass: entity.SmtpPass, + from: entity.SmtpFrom ?? entity.SmtpUser ?? '', + }; + } + + private toDto(entity: UserSettings | null): UserSettingsDto { + return { + smtpHost: entity?.SmtpHost ?? null, + smtpPort: entity?.SmtpPort ?? null, + smtpSecure: entity?.SmtpSecure ?? false, + smtpUser: entity?.SmtpUser ?? null, + smtpPassSet: !!(entity?.SmtpPass), + smtpFrom: entity?.SmtpFrom ?? null, + mailSignatureHtml: entity?.MailSignatureHtml ?? null, + }; + } +} diff --git a/paperless-frontend/src/App.tsx b/paperless-frontend/src/App.tsx index 4a45ddc..19bf781 100644 --- a/paperless-frontend/src/App.tsx +++ b/paperless-frontend/src/App.tsx @@ -13,6 +13,7 @@ import ManuellBearbeitenPage from './pages/ManuellBearbeitenPage'; import MailpostfachPage from './pages/MailpostfachPage'; import MailDetailPage from './pages/MailDetailPage'; import SettingsPage from './pages/SettingsPage'; +import UserSettingsPage from './pages/UserSettingsPage'; import LoginPage from './pages/LoginPage'; import DashboardPage from './pages/DashboardPage'; import { Spin, Result, Button } from 'antd'; @@ -127,6 +128,7 @@ function ThemedApp() { } /> } /> } /> + } /> diff --git a/paperless-frontend/src/api/userSettings.ts b/paperless-frontend/src/api/userSettings.ts new file mode 100644 index 0000000..5d51bcd --- /dev/null +++ b/paperless-frontend/src/api/userSettings.ts @@ -0,0 +1,23 @@ +import api from './client'; + +export interface UserSettingsData { + smtpHost: string | null; + smtpPort: number | null; + smtpSecure: boolean; + smtpUser: string | null; + smtpPassSet: boolean; + smtpFrom: string | null; + mailSignatureHtml: string | null; +} + +export const userSettingsApi = { + get: () => api.get('/api/user-settings').then((r) => r.data), + + update: (data: Partial & { smtpPass?: string }) => + api.put('/api/user-settings', data).then((r) => r.data), + + testSmtp: (cfg: { host: string; port: number; secure: boolean; user: string; pass: string }) => + api + .post<{ ok: boolean; error?: string }>('/api/user-settings/test-smtp', cfg) + .then((r) => r.data), +}; diff --git a/paperless-frontend/src/layouts/AppLayout.tsx b/paperless-frontend/src/layouts/AppLayout.tsx index e90e9a9..802d64b 100644 --- a/paperless-frontend/src/layouts/AppLayout.tsx +++ b/paperless-frontend/src/layouts/AppLayout.tsx @@ -204,6 +204,12 @@ export default function AppLayout() { , + label: 'Benutzereinstellungen', + onClick: () => navigate('/user-settings'), + }, { key: 'logout', icon: , diff --git a/paperless-frontend/src/pages/InboxDetailPage.tsx b/paperless-frontend/src/pages/InboxDetailPage.tsx index bb7f89c..edeb024 100644 --- a/paperless-frontend/src/pages/InboxDetailPage.tsx +++ b/paperless-frontend/src/pages/InboxDetailPage.tsx @@ -26,6 +26,7 @@ import StarterKit from '@tiptap/starter-kit'; import Underline from '@tiptap/extension-underline'; import { inboxApi, type InboxBarcode, type InboxFile, type PostprocessActionResult } from '../api/inbox'; import { paperlessApi } from '../api/paperless'; +import { userSettingsApi } from '../api/userSettings'; const ZOOM_MIN = 0.5; const ZOOM_MAX = 3; @@ -678,13 +679,17 @@ function SendEmailDialog({ open, fileId, fileName, documents, onClose }: SendEma useEffect(() => { if (!open) return; form.resetFields(); - editor?.commands.clearContent(); const base = fileName.replace(/\.pdf$/i, ''); setFilenames( documents.map((doc) => doc.belegname || (documents.length === 1 ? base : `${base}_${doc.index + 1}`), ), ); + userSettingsApi.get().then((settings) => { + editor?.commands.setContent(settings.mailSignatureHtml ?? ''); + }).catch(() => { + editor?.commands.clearContent(); + }); }, [open, documents, fileName, form, editor]); const handleOk = async () => { diff --git a/paperless-frontend/src/pages/UserSettingsPage.tsx b/paperless-frontend/src/pages/UserSettingsPage.tsx new file mode 100644 index 0000000..35c0fa9 --- /dev/null +++ b/paperless-frontend/src/pages/UserSettingsPage.tsx @@ -0,0 +1,185 @@ +import { useEffect, useState } from 'react'; +import { Button, Card, Form, Input, InputNumber, Space, Switch, Tabs, Typography, message } from 'antd'; +import { CheckCircleOutlined, CloseCircleOutlined, LoadingOutlined } from '@ant-design/icons'; +import { useEditor, EditorContent } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import Underline from '@tiptap/extension-underline'; +import { userSettingsApi, type UserSettingsData } from '../api/userSettings'; + +const { Title } = Typography; + +function TiptapToolbar({ editor }: { editor: ReturnType }) { + if (!editor) return null; + const btn = (active: boolean): React.CSSProperties => ({ + padding: '2px 8px', + border: '1px solid #d9d9d9', + borderRadius: 4, + cursor: 'pointer', + background: active ? '#e6f4ff' : '#fff', + fontWeight: active ? 600 : 400, + }); + return ( +
+ + + + + +
+ ); +} + +function MailSettingsTab() { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ ok: boolean; error?: string } | null>(null); + const [passSet, setPassSet] = useState(false); + + const editor = useEditor({ + extensions: [StarterKit, Underline], + content: '', + }); + + useEffect(() => { + userSettingsApi.get().then((data) => { + form.setFieldsValue({ + smtpHost: data.smtpHost ?? '', + smtpPort: data.smtpPort ?? 587, + smtpSecure: data.smtpSecure, + smtpUser: data.smtpUser ?? '', + smtpFrom: data.smtpFrom ?? '', + }); + setPassSet(data.smtpPassSet); + if (data.mailSignatureHtml) { + editor?.commands.setContent(data.mailSignatureHtml); + } + }).catch(() => { + message.error('Einstellungen konnten nicht geladen werden'); + }).finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSave = async () => { + const values = await form.validateFields(); + setSaving(true); + try { + const updated = await userSettingsApi.update({ + smtpHost: values.smtpHost || null, + smtpPort: values.smtpPort || null, + smtpSecure: values.smtpSecure ?? false, + smtpUser: values.smtpUser || null, + smtpPass: values.smtpPass || undefined, + smtpFrom: values.smtpFrom || null, + mailSignatureHtml: editor?.getHTML() ?? null, + }); + setPassSet(updated.smtpPassSet); + form.setFieldValue('smtpPass', ''); + message.success('Einstellungen gespeichert'); + } catch { + message.error('Speichern fehlgeschlagen'); + } finally { + setSaving(false); + } + }; + + const handleTest = async () => { + const values = form.getFieldsValue(); + if (!values.smtpHost) { message.warning('Bitte SMTP-Server angeben'); return; } + setTesting(true); + setTestResult(null); + try { + const result = await userSettingsApi.testSmtp({ + host: values.smtpHost, + port: values.smtpPort ?? 587, + secure: values.smtpSecure ?? false, + user: values.smtpUser ?? '', + pass: values.smtpPass || '', + }); + setTestResult(result); + } catch { + setTestResult({ ok: false, error: 'Verbindung fehlgeschlagen' }); + } finally { + setTesting(false); + } + }; + + if (loading) return null; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + + + {testResult && ( + + {testResult.ok + ? + : } + + {testResult.ok ? 'Verbindung erfolgreich' : (testResult.error ?? 'Fehler')} + + + )} + + +
+ ); +} + +export default function UserSettingsPage() { + return ( +
+ Benutzereinstellungen + + , + }, + ]} + /> + +
+ ); +}