From a2e63c09fbc77731e79c53c8db6d874e63947fbd Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 15 Sep 2025 13:14:22 -0700 Subject: [PATCH] Move backend to correct module, basic tests --- src/documents/filters.py | 10 - src/documents/views.py | 31 --- src/paperless/urls.py | 2 +- src/paperless_mail/filters.py | 12 ++ src/paperless_mail/tests/test_api.py | 284 +++++++++++++++++++++++++++ src/paperless_mail/views.py | 36 ++++ 6 files changed, 333 insertions(+), 42 deletions(-) create mode 100644 src/paperless_mail/filters.py diff --git a/src/documents/filters.py b/src/documents/filters.py index c76fafa83..87274f9fa 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -41,7 +41,6 @@ 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 @@ -803,15 +802,6 @@ 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 8ba93fbb2..002cb0eea 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -107,7 +107,6 @@ 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 @@ -182,11 +181,9 @@ 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 @@ -2984,31 +2981,3 @@ 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 fe8c53fc4..e24d1a459 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -25,7 +25,6 @@ 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 @@ -58,6 +57,7 @@ from paperless.views import UserViewSet from paperless_mail.views import MailAccountViewSet from paperless_mail.views import MailRuleViewSet from paperless_mail.views import OauthCallbackView +from paperless_mail.views import ProcessedMailViewSet api_router = DefaultRouter() api_router.register(r"correspondents", CorrespondentViewSet) diff --git a/src/paperless_mail/filters.py b/src/paperless_mail/filters.py new file mode 100644 index 000000000..57b8dec77 --- /dev/null +++ b/src/paperless_mail/filters.py @@ -0,0 +1,12 @@ +from django_filters import FilterSet + +from paperless_mail.models import ProcessedMail + + +class ProcessedMailFilterSet(FilterSet): + class Meta: + model = ProcessedMail + fields = { + "rule": ["exact"], + "status": ["exact"], + } diff --git a/src/paperless_mail/tests/test_api.py b/src/paperless_mail/tests/test_api.py index 3ba06a746..dd63c67ab 100644 --- a/src/paperless_mail/tests/test_api.py +++ b/src/paperless_mail/tests/test_api.py @@ -3,6 +3,7 @@ from unittest import mock from django.contrib.auth.models import Permission from django.contrib.auth.models import User +from django.utils import timezone from guardian.shortcuts import assign_perm from rest_framework import status from rest_framework.test import APITestCase @@ -13,6 +14,7 @@ from documents.models import Tag from documents.tests.utils import DirectoriesMixin from paperless_mail.models import MailAccount from paperless_mail.models import MailRule +from paperless_mail.models import ProcessedMail from paperless_mail.tests.test_mail import BogusMailBox @@ -721,3 +723,285 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("maximum_age", response.data) + + +class TestAPIProcessedMails(DirectoriesMixin, APITestCase): + ENDPOINT = "/api/processed_mail/" + + def setUp(self): + super().setUp() + + self.user = User.objects.create_user(username="temp_admin") + self.user.user_permissions.add(*Permission.objects.all()) + self.user.save() + self.client.force_authenticate(user=self.user) + + def test_get_processed_mails_owner_aware(self): + """ + GIVEN: + - Configured processed mails with different users + WHEN: + - API call is made to get processed mails + THEN: + - Only unowned, owned by user or granted processed mails are provided + """ + user2 = User.objects.create_user(username="temp_admin2") + + account = MailAccount.objects.create( + name="Email1", + username="username1", + password="password1", + imap_server="server.example.com", + imap_port=443, + imap_security=MailAccount.ImapSecurity.SSL, + character_set="UTF-8", + ) + + rule = MailRule.objects.create( + name="Rule1", + account=account, + folder="INBOX", + filter_from="from@example.com", + order=0, + ) + + pm1 = ProcessedMail.objects.create( + rule=rule, + folder="INBOX", + uid="1", + subject="Subj1", + received=timezone.now(), + processed=timezone.now(), + status="SUCCESS", + error=None, + ) + + pm2 = ProcessedMail.objects.create( + rule=rule, + folder="INBOX", + uid="2", + subject="Subj2", + received=timezone.now(), + processed=timezone.now(), + status="FAILED", + error="err", + owner=self.user, + ) + + ProcessedMail.objects.create( + rule=rule, + folder="INBOX", + uid="3", + subject="Subj3", + received=timezone.now(), + processed=timezone.now(), + status="SUCCESS", + error=None, + owner=user2, + ) + + pm4 = ProcessedMail.objects.create( + rule=rule, + folder="INBOX", + uid="4", + subject="Subj4", + received=timezone.now(), + processed=timezone.now(), + status="SUCCESS", + error=None, + ) + pm4.owner = user2 + pm4.save() + assign_perm("view_processedmail", self.user, pm4) + + response = self.client.get(self.ENDPOINT) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 3) + returned_ids = {r["id"] for r in response.data["results"]} + self.assertSetEqual(returned_ids, {pm1.id, pm2.id, pm4.id}) + + def test_get_processed_mails_filter_by_rule(self): + """ + GIVEN: + - Processed mails belonging to two different rules + WHEN: + - API call is made with rule filter + THEN: + - Only processed mails for that rule are returned + """ + account = MailAccount.objects.create( + name="Email1", + username="username1", + password="password1", + imap_server="server.example.com", + imap_port=443, + imap_security=MailAccount.ImapSecurity.SSL, + character_set="UTF-8", + ) + + rule1 = MailRule.objects.create( + name="Rule1", + account=account, + folder="INBOX", + filter_from="from1@example.com", + order=0, + ) + rule2 = MailRule.objects.create( + name="Rule2", + account=account, + folder="INBOX", + filter_from="from2@example.com", + order=1, + ) + + pm1 = ProcessedMail.objects.create( + rule=rule1, + folder="INBOX", + uid="r1-1", + subject="R1-A", + received=timezone.now(), + processed=timezone.now(), + status="SUCCESS", + error=None, + owner=self.user, + ) + pm2 = ProcessedMail.objects.create( + rule=rule1, + folder="INBOX", + uid="r1-2", + subject="R1-B", + received=timezone.now(), + processed=timezone.now(), + status="FAILED", + error="e", + ) + ProcessedMail.objects.create( + rule=rule2, + folder="INBOX", + uid="r2-1", + subject="R2-A", + received=timezone.now(), + processed=timezone.now(), + status="SUCCESS", + error=None, + ) + + response = self.client.get(f"{self.ENDPOINT}?rule={rule1.pk}") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + returned_ids = {r["id"] for r in response.data["results"]} + self.assertSetEqual(returned_ids, {pm1.id, pm2.id}) + + def test_bulk_delete_processed_mails(self): + """ + GIVEN: + - Processed mails belonging to two different rules and different users + WHEN: + - API call is made to bulk delete some of the processed mails + THEN: + - Only the specified processed mails are deleted, respecting ownership and permissions + """ + user2 = User.objects.create_user(username="temp_admin2") + + account = MailAccount.objects.create( + name="Email1", + username="username1", + password="password1", + imap_server="server.example.com", + imap_port=443, + imap_security=MailAccount.ImapSecurity.SSL, + character_set="UTF-8", + ) + + rule = MailRule.objects.create( + name="Rule1", + account=account, + folder="INBOX", + filter_from="from@example.com", + order=0, + ) + + # unowned and owned by self, and one with explicit object perm + pm_unowned = ProcessedMail.objects.create( + rule=rule, + folder="INBOX", + uid="u1", + subject="Unowned", + received=timezone.now(), + processed=timezone.now(), + status="SUCCESS", + error=None, + ) + pm_owned = ProcessedMail.objects.create( + rule=rule, + folder="INBOX", + uid="u2", + subject="Owned", + received=timezone.now(), + processed=timezone.now(), + status="FAILED", + error="e", + owner=self.user, + ) + pm_granted = ProcessedMail.objects.create( + rule=rule, + folder="INBOX", + uid="u3", + subject="Granted", + received=timezone.now(), + processed=timezone.now(), + status="SUCCESS", + error=None, + owner=user2, + ) + assign_perm("delete_processedmail", self.user, pm_granted) + pm_forbidden = ProcessedMail.objects.create( + rule=rule, + folder="INBOX", + uid="u4", + subject="Forbidden", + received=timezone.now(), + processed=timezone.now(), + status="SUCCESS", + error=None, + owner=user2, + ) + + # Success for allowed items + response = self.client.post( + f"{self.ENDPOINT}bulk_delete/", + data={ + "mail_ids": [pm_unowned.id, pm_owned.id, pm_granted.id], + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["result"], "OK") + self.assertSetEqual( + set(response.data["deleted_mail_ids"]), + {pm_unowned.id, pm_owned.id, pm_granted.id}, + ) + self.assertFalse(ProcessedMail.objects.filter(id=pm_unowned.id).exists()) + self.assertFalse(ProcessedMail.objects.filter(id=pm_owned.id).exists()) + self.assertFalse(ProcessedMail.objects.filter(id=pm_granted.id).exists()) + self.assertTrue(ProcessedMail.objects.filter(id=pm_forbidden.id).exists()) + + # 403 and not deleted + response = self.client.post( + f"{self.ENDPOINT}bulk_delete/", + data={ + "mail_ids": [pm_forbidden.id], + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertTrue(ProcessedMail.objects.filter(id=pm_forbidden.id).exists()) + + # missing mail_ids + response = self.client.post( + f"{self.ENDPOINT}bulk_delete/", + data={"mail_ids": "not-a-list"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/src/paperless_mail/views.py b/src/paperless_mail/views.py index e48049a36..b54bcb5f7 100644 --- a/src/paperless_mail/views.py +++ b/src/paperless_mail/views.py @@ -3,8 +3,10 @@ import logging from datetime import timedelta from django.http import HttpResponseBadRequest +from django.http import HttpResponseForbidden from django.http import HttpResponseRedirect from django.utils import timezone +from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema_view @@ -12,23 +14,29 @@ from drf_spectacular.utils import inline_serializer from httpx_oauth.oauth2 import GetAccessTokenError from rest_framework import serializers from rest_framework.decorators import action +from rest_framework.filters import OrderingFilter from rest_framework.generics import GenericAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet +from rest_framework.viewsets import ReadOnlyModelViewSet from documents.filters import ObjectOwnedOrGrantedPermissionsFilter from documents.permissions import PaperlessObjectPermissions +from documents.permissions import has_perms_owner_aware from documents.views import PassUserMixin from paperless.views import StandardPagination +from paperless_mail.filters import ProcessedMailFilterSet from paperless_mail.mail import MailError from paperless_mail.mail import get_mailbox from paperless_mail.mail import mailbox_login 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 from paperless_mail.tasks import process_mail_accounts @@ -126,6 +134,34 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin): return Response({"result": "OK"}) +class ProcessedMailViewSet(ReadOnlyModelViewSet, 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}) + + class MailRuleViewSet(ModelViewSet, PassUserMixin): model = MailRule