Feature: processed mail UI (#10866)

This commit is contained in:
shamoon
2025-09-22 11:17:42 -07:00
committed by GitHub
parent 1cdd8d9ba8
commit 19a54b3b23
18 changed files with 824 additions and 8 deletions

View File

@@ -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"],
}

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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