From eca093189d3435a711e04c71653c2bb7e862f915 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 15 Sep 2025 10:08:33 -0700 Subject: [PATCH] Enhancement: add processed mails management UI and API --- .../manage/mail/mail.component.html | 7 ++ .../components/manage/mail/mail.component.ts | 9 ++ .../processed-mails-dialog.component.html | 92 +++++++++++++++++++ .../processed-mails-dialog.component.scss | 0 .../processed-mails-dialog.component.spec.ts | 0 .../processed-mails-dialog.component.ts | 91 ++++++++++++++++++ src-ui/src/app/data/processed-mail.ts | 12 +++ .../src/app/services/permissions.service.ts | 1 + .../rest/processed-mail.service.spec.ts | 0 .../services/rest/processed-mail.service.ts | 19 ++++ src-ui/src/main.ts | 4 + src/documents/filters.py | 10 ++ src/documents/views.py | 31 +++++++ src/paperless/urls.py | 2 + src/paperless_mail/serialisers.py | 18 ++++ 15 files changed, 296 insertions(+) create mode 100644 src-ui/src/app/components/manage/mail/processed-mails-dialog/processed-mails-dialog.component.html create mode 100644 src-ui/src/app/components/manage/mail/processed-mails-dialog/processed-mails-dialog.component.scss create mode 100644 src-ui/src/app/components/manage/mail/processed-mails-dialog/processed-mails-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/manage/mail/processed-mails-dialog/processed-mails-dialog.component.ts create mode 100644 src-ui/src/app/data/processed-mail.ts create mode 100644 src-ui/src/app/services/rest/processed-mail.service.spec.ts create mode 100644 src-ui/src/app/services/rest/processed-mail.service.ts diff --git a/src-ui/src/app/components/manage/mail/mail.component.html b/src-ui/src/app/components/manage/mail/mail.component.html index 16e8e88fb..2eb95fabd 100644 --- a/src-ui/src/app/components/manage/mail/mail.component.html +++ b/src-ui/src/app/components/manage/mail/mail.component.html @@ -168,6 +168,13 @@  Copy +
+
+ +
+
diff --git a/src-ui/src/app/components/manage/mail/mail.component.ts b/src-ui/src/app/components/manage/mail/mail.component.ts index 06e2570ee..6a11d85b3 100644 --- a/src-ui/src/app/components/manage/mail/mail.component.ts +++ b/src-ui/src/app/components/manage/mail/mail.component.ts @@ -27,6 +27,7 @@ import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule- import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' +import { ProcessedMailsDialogComponent } from './processed-mails-dialog/processed-mails-dialog.component' @Component({ selector: 'pngx-mail', @@ -347,6 +348,14 @@ export class MailComponent ) } + viewProcessedMails(rule: MailRule) { + const modal = this.modalService.open(ProcessedMailsDialogComponent, { + backdrop: 'static', + size: 'xl', + }) + modal.componentInstance.rule = rule + } + userCanEdit(obj: ObjectWithPermissions): boolean { return this.permissionsService.currentUserHasObjectPermissions( PermissionAction.Change, diff --git a/src-ui/src/app/components/manage/mail/processed-mails-dialog/processed-mails-dialog.component.html b/src-ui/src/app/components/manage/mail/processed-mails-dialog/processed-mails-dialog.component.html new file mode 100644 index 000000000..d5221d935 --- /dev/null +++ b/src-ui/src/app/components/manage/mail/processed-mails-dialog/processed-mails-dialog.component.html @@ -0,0 +1,92 @@ + + diff --git a/src-ui/src/app/components/manage/mail/processed-mails-dialog/processed-mails-dialog.component.scss b/src-ui/src/app/components/manage/mail/processed-mails-dialog/processed-mails-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src-ui/src/app/components/manage/mail/processed-mails-dialog/processed-mails-dialog.component.spec.ts b/src-ui/src/app/components/manage/mail/processed-mails-dialog/processed-mails-dialog.component.spec.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src-ui/src/app/components/manage/mail/processed-mails-dialog/processed-mails-dialog.component.ts b/src-ui/src/app/components/manage/mail/processed-mails-dialog/processed-mails-dialog.component.ts new file mode 100644 index 000000000..2acda5137 --- /dev/null +++ b/src-ui/src/app/components/manage/mail/processed-mails-dialog/processed-mails-dialog.component.ts @@ -0,0 +1,91 @@ +import { SlicePipe } from '@angular/common' +import { Component, inject, Input } from '@angular/core' +import { + NgbActiveModal, + NgbPagination, + NgbTooltipModule, +} from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component' +import { MailRule } from 'src/app/data/mail-rule' +import { ProcessedMail } from 'src/app/data/processed-mail' +import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' +import { ProcessedMailService } from 'src/app/services/rest/processed-mail.service' +import { ToastService } from 'src/app/services/toast.service' + +@Component({ + selector: 'pngx-processed-mails-dialog', + imports: [ + ConfirmButtonComponent, + CustomDatePipe, + NgbPagination, + NgbTooltipModule, + NgxBootstrapIconsModule, + SlicePipe, + ], + templateUrl: './processed-mails-dialog.component.html', + styleUrl: './processed-mails-dialog.component.scss', +}) +export class ProcessedMailsDialogComponent { + private activeModal = inject(NgbActiveModal) + private processedMailService = inject(ProcessedMailService) + private toastService = inject(ToastService) + + public processedMails: ProcessedMail[] = [] + + public loading: boolean = true + public toggleAllEnabled: boolean = false + public readonly selectedMailIds: Set = new Set() + + public page: number = 1 + + @Input() rule: MailRule + + ngOnInit(): void { + this.loadProcessedMails() + } + + public close() { + this.activeModal.close() + } + + private loadProcessedMails(): void { + this.loading = true + this.clearSelection() + this.processedMailService + .list(this.page, 50, 'processed_at', true, { rule: this.rule.id }) + .subscribe((result) => { + this.processedMails = result.results + this.loading = false + }) + } + + public deleteSelected(): void { + this.processedMailService + .bulk_delete(Array.from(this.selectedMailIds)) + .subscribe(() => { + this.toastService.showInfo($localize`Processed mail(s) deleted`) + this.loadProcessedMails() + }) + } + + public toggleAll(event: PointerEvent) { + if ((event.target as HTMLInputElement).checked) { + this.selectedMailIds.clear() + this.processedMails.forEach((mail) => this.selectedMailIds.add(mail.id)) + } else { + this.clearSelection() + } + } + + public clearSelection() { + this.toggleAllEnabled = false + this.selectedMailIds.clear() + } + + public toggleSelected(mail: ProcessedMail) { + this.selectedMailIds.has(mail.id) + ? this.selectedMailIds.delete(mail.id) + : this.selectedMailIds.add(mail.id) + } +} diff --git a/src-ui/src/app/data/processed-mail.ts b/src-ui/src/app/data/processed-mail.ts new file mode 100644 index 000000000..7eacf2415 --- /dev/null +++ b/src-ui/src/app/data/processed-mail.ts @@ -0,0 +1,12 @@ +import { ObjectWithId } from './object-with-id' + +export interface ProcessedMail extends ObjectWithId { + rule: number // MailRule.id + folder: string + uid: number + subject: string + received: Date + processed: Date + status: string + error: string +} diff --git a/src-ui/src/app/services/permissions.service.ts b/src-ui/src/app/services/permissions.service.ts index 3d88b10cc..0c36b646f 100644 --- a/src-ui/src/app/services/permissions.service.ts +++ b/src-ui/src/app/services/permissions.service.ts @@ -28,6 +28,7 @@ export enum PermissionType { ShareLink = '%s_sharelink', CustomField = '%s_customfield', Workflow = '%s_workflow', + ProcessedMail = '%s_processedmail', } @Injectable({ diff --git a/src-ui/src/app/services/rest/processed-mail.service.spec.ts b/src-ui/src/app/services/rest/processed-mail.service.spec.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src-ui/src/app/services/rest/processed-mail.service.ts b/src-ui/src/app/services/rest/processed-mail.service.ts new file mode 100644 index 000000000..e1ea327da --- /dev/null +++ b/src-ui/src/app/services/rest/processed-mail.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core' +import { ProcessedMail } from 'src/app/data/processed-mail' +import { AbstractPaperlessService } from './abstract-paperless-service' + +@Injectable({ + providedIn: 'root', +}) +export class ProcessedMailService extends AbstractPaperlessService { + constructor() { + super() + this.resourceName = 'processed_mail' + } + + public bulk_delete(mailIds: number[]) { + return this.http.post(`${this.getResourceUrl()}bulk_delete/`, { + mail_ids: mailIds, + }) + } +} diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts index 5ed4fe373..a8a85bbf4 100644 --- a/src-ui/src/main.ts +++ b/src-ui/src/main.ts @@ -51,6 +51,7 @@ import { check, check2All, checkAll, + checkCircle, checkCircleFill, checkLg, chevronDoubleLeft, @@ -59,6 +60,7 @@ import { clipboardCheck, clipboardCheckFill, clipboardFill, + clockHistory, dash, dashCircle, diagram3, @@ -261,6 +263,7 @@ const icons = { check, check2All, checkAll, + checkCircle, checkCircleFill, checkLg, chevronDoubleLeft, @@ -269,6 +272,7 @@ const icons = { clipboardCheck, clipboardCheckFill, clipboardFill, + clockHistory, dash, dashCircle, diagram3, diff --git a/src/documents/filters.py b/src/documents/filters.py index 87274f9fa..c76fafa83 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -41,6 +41,7 @@ from documents.models import PaperlessTask from documents.models import ShareLink from documents.models import StoragePath from documents.models import Tag +from paperless_mail.models import ProcessedMail if TYPE_CHECKING: from collections.abc import Callable @@ -802,6 +803,15 @@ class PaperlessTaskFilterSet(FilterSet): } +class ProcessedMailFilterSet(FilterSet): + class Meta: + model = ProcessedMail + fields = { + "rule": ["exact"], + "status": ["exact"], + } + + class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter): """ A filter backend that limits results to those where the requesting user diff --git a/src/documents/views.py b/src/documents/views.py index 002cb0eea..8ba93fbb2 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -107,6 +107,7 @@ from documents.filters import DocumentTypeFilterSet from documents.filters import ObjectOwnedOrGrantedPermissionsFilter from documents.filters import ObjectOwnedPermissionsFilter from documents.filters import PaperlessTaskFilterSet +from documents.filters import ProcessedMailFilterSet from documents.filters import ShareLinkFilterSet from documents.filters import StoragePathFilterSet from documents.filters import TagFilterSet @@ -181,9 +182,11 @@ from paperless.serialisers import UserSerializer from paperless.views import StandardPagination from paperless_mail.models import MailAccount from paperless_mail.models import MailRule +from paperless_mail.models import ProcessedMail from paperless_mail.oauth import PaperlessMailOAuth2Manager from paperless_mail.serialisers import MailAccountSerializer from paperless_mail.serialisers import MailRuleSerializer +from paperless_mail.serialisers import ProcessedMailSerializer if settings.AUDIT_LOG_ENABLED: from auditlog.models import LogEntry @@ -2981,3 +2984,31 @@ def serve_logo(request, filename=None): filename=app_logo.name, as_attachment=True, ) + + +class ProcessedMailViewSet(ReadOnlyModelViewSet, DestroyModelMixin, PassUserMixin): + permission_classes = (IsAuthenticated, PaperlessObjectPermissions) + serializer_class = ProcessedMailSerializer + pagination_class = StandardPagination + filter_backends = ( + DjangoFilterBackend, + OrderingFilter, + ObjectOwnedOrGrantedPermissionsFilter, + ) + filterset_class = ProcessedMailFilterSet + + queryset = ProcessedMail.objects.all().order_by("-processed") + + @action(methods=["post"], detail=False) + def bulk_delete(self, request): + mail_ids = request.data.get("mail_ids", []) + if not isinstance(mail_ids, list) or not all( + isinstance(i, int) for i in mail_ids + ): + return HttpResponseBadRequest("mail_ids must be a list of integers") + mails = ProcessedMail.objects.filter(id__in=mail_ids) + for mail in mails: + if not has_perms_owner_aware(request.user, "delete_processedmail", mail): + return HttpResponseForbidden("Insufficient permissions") + mail.delete() + return Response({"result": "OK", "deleted_mail_ids": mail_ids}) diff --git a/src/paperless/urls.py b/src/paperless/urls.py index c37331ce2..fe8c53fc4 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -25,6 +25,7 @@ from documents.views import GlobalSearchView from documents.views import IndexView from documents.views import LogViewSet from documents.views import PostDocumentView +from documents.views import ProcessedMailViewSet from documents.views import RemoteVersionView from documents.views import SavedViewViewSet from documents.views import SearchAutoCompleteView @@ -77,6 +78,7 @@ api_router.register(r"workflow_actions", WorkflowActionViewSet) api_router.register(r"workflows", WorkflowViewSet) api_router.register(r"custom_fields", CustomFieldViewSet) api_router.register(r"config", ApplicationConfigurationViewSet) +api_router.register(r"processed_mail", ProcessedMailViewSet) urlpatterns = [ diff --git a/src/paperless_mail/serialisers.py b/src/paperless_mail/serialisers.py index fa025fcbe..b38c8e78c 100644 --- a/src/paperless_mail/serialisers.py +++ b/src/paperless_mail/serialisers.py @@ -6,6 +6,7 @@ from documents.serialisers import OwnedObjectSerializer from documents.serialisers import TagsField from paperless_mail.models import MailAccount from paperless_mail.models import MailRule +from paperless_mail.models import ProcessedMail class ObfuscatedPasswordField(serializers.CharField): @@ -130,3 +131,20 @@ class MailRuleSerializer(OwnedObjectSerializer): if value > 36500: # ~100 years raise serializers.ValidationError("Maximum mail age is unreasonably large.") return value + + +class ProcessedMailSerializer(OwnedObjectSerializer): + class Meta: + model = ProcessedMail + fields = [ + "id", + "owner", + "rule", + "folder", + "uid", + "subject", + "received", + "processed", + "status", + "error", + ]