feat(mail): abgeschlossene E-Mails erneut zur Bearbeitung freigeben
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s
Der Import-Wizard ist für verarbeitete E-Mails gesperrt. Damit ließ sich
ein fehlerhaft abgeschlossener Vorgang bisher nicht korrigieren.
Administratoren können eine E-Mail in der Detailansicht nun wieder
freigeben (POST /api/emails/:id/reimport, MANAGE_ALL). Die Anhänge aus
der Datenbank werden weiterverwendet; ein erneuter Abruf vom IMAP-Server
findet nicht statt.
Die Freigabe setzt Status 4 ("Zur Nachbearbeitung") statt Status 0:
check-attachments prüft Mails mit Status 0, findet die Anhänge per
Checksumme in Paperless – dort liegen sie ja bereits – und würde die
Freigabe beim nächsten Lauf sofort wieder auf "Verarbeitet" zurückdrehen.
Status 4 bleibt davon unberührt. Da Status ein freies int ist, ist dafür
keine Migration nötig.
Erlaubt sind nur die Status 1, 2 und 3; liegt die E-Mail ohnehin im
Arbeitsvorrat, antwortet der Endpunkt mit 400, statt den Zustand still zu
überschreiben. Wer die Freigabe ausgelöst hat, steht im Log.
Der neue Status erscheint als eigener Tag sowie in beiden Filtern der
Mailpostfach-Übersicht, damit die freigegebenen Mails auffindbar bleiben.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
import { EmailController } from './email.controller';
|
import { EmailController } from './email.controller';
|
||||||
import { Email } from '../database/entities/email.entity';
|
import { Email } from '../database/entities/email.entity';
|
||||||
@@ -134,6 +135,44 @@ describe('EmailController', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('reimport', () => {
|
||||||
|
it('gibt eine verarbeitete E-Mail zur Nachbearbeitung frei', async () => {
|
||||||
|
emailRepo.findOneOrFail.mockResolvedValue({ Id: 1, Status: 1 });
|
||||||
|
|
||||||
|
const result = await controller.reimport('1', { user: {} });
|
||||||
|
|
||||||
|
expect(emailRepo.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ Id: 1, Status: 4 }),
|
||||||
|
);
|
||||||
|
expect(result).toEqual(expect.objectContaining({ Status: 4 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['Fehler', 2],
|
||||||
|
['Ignoriert', 3],
|
||||||
|
])('gibt auch Status %s frei', async (_label, status) => {
|
||||||
|
emailRepo.findOneOrFail.mockResolvedValue({ Id: 1, Status: status });
|
||||||
|
|
||||||
|
await controller.reimport('1', { user: {} });
|
||||||
|
|
||||||
|
expect(emailRepo.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ Status: 4 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['Neu', 0],
|
||||||
|
['bereits zur Nachbearbeitung', 4],
|
||||||
|
])('lehnt eine E-Mail mit Status %s ab', async (_label, status) => {
|
||||||
|
emailRepo.findOneOrFail.mockResolvedValue({ Id: 1, Status: status });
|
||||||
|
|
||||||
|
await expect(controller.reimport('1', { user: {} })).rejects.toThrow(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(emailRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('updateStatus speichert den neuen Status', async () => {
|
it('updateStatus speichert den neuen Status', async () => {
|
||||||
await controller.updateStatus('1', 2);
|
await controller.updateStatus('1', 2);
|
||||||
expect(emailRepo.save).toHaveBeenCalledWith(
|
expect(emailRepo.save).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
Res,
|
Res,
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
Patch,
|
Patch,
|
||||||
Body,
|
Body,
|
||||||
|
Request,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
@@ -21,6 +23,17 @@ 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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status einer E-Mail: 0 = Neu, 1 = Verarbeitet, 2 = Fehler, 3 = Ignoriert,
|
||||||
|
* 4 = Zur Nachbearbeitung (von einem Administrator erneut freigegeben).
|
||||||
|
*
|
||||||
|
* Status 4 wird von `check-attachments` bewusst nicht angefasst: Die Anhänge
|
||||||
|
* einer erneut freigegebenen E-Mail liegen bereits in Paperless, die Prüfung
|
||||||
|
* würde sie sonst sofort wieder auf "Verarbeitet" setzen.
|
||||||
|
*/
|
||||||
|
const STATUS_NEU = 0;
|
||||||
|
const STATUS_NACHBEARBEITUNG = 4;
|
||||||
|
|
||||||
@Controller('api/emails')
|
@Controller('api/emails')
|
||||||
export class EmailController {
|
export class EmailController {
|
||||||
private readonly logger = new Logger(EmailController.name);
|
private readonly logger = new Logger(EmailController.name);
|
||||||
@@ -110,6 +123,37 @@ export class EmailController {
|
|||||||
return { message: 'Status aktualisiert' };
|
return { message: 'Status aktualisiert' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/reimport')
|
||||||
|
@RequirePermissions(Permission.MANAGE_ALL)
|
||||||
|
async reimport(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Request() req: { user?: { email?: string; userId?: string } },
|
||||||
|
) {
|
||||||
|
const email = await this.emailRepo.findOneOrFail({
|
||||||
|
where: { Id: parseInt(id, 10) },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
email.Status === STATUS_NEU ||
|
||||||
|
email.Status === STATUS_NACHBEARBEITUNG
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Diese E-Mail liegt bereits im Arbeitsvorrat.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorherigerStatus = email.Status;
|
||||||
|
email.Status = STATUS_NACHBEARBEITUNG;
|
||||||
|
await this.emailRepo.save(email);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`E-Mail ${id} von Status ${vorherigerStatus} zur Nachbearbeitung freigegeben ` +
|
||||||
|
`(durch ${req.user?.email ?? req.user?.userId ?? 'unbekannt'})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
@Post('check-attachments')
|
@Post('check-attachments')
|
||||||
@RequirePermissions(Permission.MANAGE_ALL)
|
@RequirePermissions(Permission.MANAGE_ALL)
|
||||||
async checkAttachments(@Body() body: { includeProcessed?: boolean } = {}) {
|
async checkAttachments(@Body() body: { includeProcessed?: boolean } = {}) {
|
||||||
|
|||||||
@@ -48,4 +48,8 @@ export const emailsApi = {
|
|||||||
|
|
||||||
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),
|
||||||
|
|
||||||
|
// Gibt eine bereits abgeschlossene E-Mail wieder zur Bearbeitung frei (nur Admins).
|
||||||
|
reimport: (id: number) =>
|
||||||
|
api.post<EmailItem>(`/api/emails/${id}/reimport`).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Card, Button, Space, Spin, Tag, Typography, Table, message, Empty, Popconfirm, theme
|
Card, Button, Space, Spin, Tag, Typography, Table, message, Empty, Popconfirm, theme
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { ArrowLeftOutlined, FileTextOutlined, CloseCircleOutlined, LinkOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined, FileTextOutlined, CloseCircleOutlined, LinkOutlined, RedoOutlined } from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { emailsApi, type EmailItem, type EmailAttachment } from '../api/emails';
|
import { emailsApi, type EmailItem, type EmailAttachment } from '../api/emails';
|
||||||
@@ -11,6 +11,8 @@ import { emailImportApi } from '../api/email-import';
|
|||||||
import { getEnv } from '../utils/env';
|
import { getEnv } from '../utils/env';
|
||||||
import MailImportWizard from '../components/MailImportWizard';
|
import MailImportWizard from '../components/MailImportWizard';
|
||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { Permission } from '../auth/permissions';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
@@ -25,7 +27,9 @@ export default function MailDetailPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
|
const [reimporting, setReimporting] = useState(false);
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -51,6 +55,22 @@ export default function MailDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Gibt eine abgeschlossene E-Mail wieder zur Bearbeitung frei (Status 4).
|
||||||
|
// Danach ist der Import-Wizard erneut nutzbar.
|
||||||
|
const handleReimport = async () => {
|
||||||
|
if (!email) return;
|
||||||
|
setReimporting(true);
|
||||||
|
try {
|
||||||
|
const updated = await emailsApi.reimport(email.Id);
|
||||||
|
setEmail(updated);
|
||||||
|
message.success('E-Mail wurde zur Nachbearbeitung freigegeben');
|
||||||
|
} catch {
|
||||||
|
message.error('Fehler beim Freigeben der E-Mail');
|
||||||
|
} finally {
|
||||||
|
setReimporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
@@ -148,6 +168,20 @@ export default function MailDetailPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
|
{hasPermission(Permission.MANAGE_ALL) && email.Status !== 0 && email.Status !== 4 && (
|
||||||
|
<Popconfirm
|
||||||
|
title="Erneut importieren"
|
||||||
|
description="Diese E-Mail wurde bereits abgeschlossen. Möchten Sie sie wieder zur Bearbeitung freigeben?"
|
||||||
|
onConfirm={handleReimport}
|
||||||
|
okText="Ja"
|
||||||
|
cancelText="Nein"
|
||||||
|
placement="bottomRight"
|
||||||
|
>
|
||||||
|
<Button icon={<RedoOutlined />} loading={reimporting}>
|
||||||
|
Erneut importieren
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="E-Mail ignorieren"
|
title="E-Mail ignorieren"
|
||||||
description="Möchten Sie diese E-Mail wirklich als ignoriert markieren?"
|
description="Möchten Sie diese E-Mail wirklich als ignoriert markieren?"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function renderStatusTag(s: number) {
|
|||||||
if (s === 1) return <Tag color="green">Verarbeitet</Tag>;
|
if (s === 1) return <Tag color="green">Verarbeitet</Tag>;
|
||||||
if (s === 2) return <Tag color="red">Fehler</Tag>;
|
if (s === 2) return <Tag color="red">Fehler</Tag>;
|
||||||
if (s === 3) return <Tag color="default">Ignoriert</Tag>;
|
if (s === 3) return <Tag color="default">Ignoriert</Tag>;
|
||||||
|
if (s === 4) return <Tag color="orange">Zur Nachbearbeitung</Tag>;
|
||||||
return <Tag>{s}</Tag>;
|
return <Tag>{s}</Tag>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +102,7 @@ export default function MailpostfachPage() {
|
|||||||
{ text: 'Verarbeitet', value: 1 },
|
{ text: 'Verarbeitet', value: 1 },
|
||||||
{ text: 'Fehler', value: 2 },
|
{ text: 'Fehler', value: 2 },
|
||||||
{ text: 'Ignoriert', value: 3 },
|
{ text: 'Ignoriert', value: 3 },
|
||||||
|
{ text: 'Zur Nachbearbeitung', value: 4 },
|
||||||
],
|
],
|
||||||
onFilter: (value, record) => record.Status === value,
|
onFilter: (value, record) => record.Status === value,
|
||||||
},
|
},
|
||||||
@@ -216,6 +218,7 @@ export default function MailpostfachPage() {
|
|||||||
{ value: 1, label: 'Verarbeitet' },
|
{ value: 1, label: 'Verarbeitet' },
|
||||||
{ value: 2, label: 'Fehler' },
|
{ value: 2, label: 'Fehler' },
|
||||||
{ value: 3, label: 'Ignoriert' },
|
{ value: 3, label: 'Ignoriert' },
|
||||||
|
{ value: 4, label: 'Zur Nachbearbeitung' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user