mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-10-14 02:36:10 -05:00
Feature: add support for emailing multiple documents (#10666)
--------- Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
@@ -10,11 +10,20 @@ def send_email(
|
||||
subject: str,
|
||||
body: str,
|
||||
to: list[str],
|
||||
attachment: Path | None = None,
|
||||
attachment_mime_type: str | None = None,
|
||||
attachments: list[tuple[Path, str]],
|
||||
) -> int:
|
||||
"""
|
||||
Send an email with an optional attachment.
|
||||
Send an email with attachments.
|
||||
|
||||
Args:
|
||||
subject: Email subject
|
||||
body: Email body text
|
||||
to: List of recipient email addresses
|
||||
attachments: List of (path, mime_type) tuples for attachments (the list may be empty)
|
||||
|
||||
Returns:
|
||||
Number of emails sent
|
||||
|
||||
TODO: re-evaluate this pending https://code.djangoproject.com/ticket/35581 / https://github.com/django/django/pull/18966
|
||||
"""
|
||||
email = EmailMessage(
|
||||
@@ -22,17 +31,20 @@ def send_email(
|
||||
body=body,
|
||||
to=to,
|
||||
)
|
||||
if attachment:
|
||||
# Something could be renaming the file concurrently so it can't be attached
|
||||
with FileLock(settings.MEDIA_LOCK), attachment.open("rb") as f:
|
||||
content = f.read()
|
||||
if attachment_mime_type == "message/rfc822":
|
||||
# See https://forum.djangoproject.com/t/using-emailmessage-with-an-attached-email-file-crashes-due-to-non-ascii/37981
|
||||
content = message_from_bytes(f.read())
|
||||
|
||||
email.attach(
|
||||
filename=attachment.name,
|
||||
content=content,
|
||||
mimetype=attachment_mime_type,
|
||||
)
|
||||
# Something could be renaming the file concurrently so it can't be attached
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
for attachment_path, mime_type in attachments:
|
||||
with attachment_path.open("rb") as f:
|
||||
content = f.read()
|
||||
if mime_type == "message/rfc822":
|
||||
# See https://forum.djangoproject.com/t/using-emailmessage-with-an-attached-email-file-crashes-due-to-non-ascii/37981
|
||||
content = message_from_bytes(content)
|
||||
|
||||
email.attach(
|
||||
filename=attachment_path.name,
|
||||
content=content,
|
||||
mimetype=mime_type,
|
||||
)
|
||||
|
||||
return email.send()
|
||||
|
@@ -16,6 +16,7 @@ from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import DecimalValidator
|
||||
from django.core.validators import EmailValidator
|
||||
from django.core.validators import MaxLengthValidator
|
||||
from django.core.validators import RegexValidator
|
||||
from django.core.validators import integer_validator
|
||||
@@ -1906,6 +1907,51 @@ class BulkDownloadSerializer(DocumentListSerializer):
|
||||
}[compression]
|
||||
|
||||
|
||||
class EmailSerializer(DocumentListSerializer):
|
||||
addresses = serializers.CharField(
|
||||
required=True,
|
||||
label="Email addresses",
|
||||
help_text="Comma-separated email addresses",
|
||||
)
|
||||
|
||||
subject = serializers.CharField(
|
||||
required=True,
|
||||
label="Email subject",
|
||||
)
|
||||
|
||||
message = serializers.CharField(
|
||||
required=True,
|
||||
label="Email message",
|
||||
)
|
||||
|
||||
use_archive_version = serializers.BooleanField(
|
||||
default=True,
|
||||
label="Use archive version",
|
||||
help_text="Use archive version of documents if available",
|
||||
)
|
||||
|
||||
def validate_addresses(self, addresses):
|
||||
address_list = [addr.strip() for addr in addresses.split(",")]
|
||||
if not address_list:
|
||||
raise serializers.ValidationError("At least one email address is required")
|
||||
|
||||
email_validator = EmailValidator()
|
||||
try:
|
||||
for address in address_list:
|
||||
email_validator(address)
|
||||
except ValidationError:
|
||||
raise serializers.ValidationError(f"Invalid email address: {address}")
|
||||
|
||||
return ",".join(address_list)
|
||||
|
||||
def validate_documents(self, documents):
|
||||
super().validate_documents(documents)
|
||||
if not documents:
|
||||
raise serializers.ValidationError("At least one document is required")
|
||||
|
||||
return documents
|
||||
|
||||
|
||||
class StoragePathSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
class Meta:
|
||||
model = StoragePath
|
||||
|
@@ -1162,12 +1162,14 @@ def run_workflows(
|
||||
else ""
|
||||
)
|
||||
try:
|
||||
attachments = []
|
||||
if action.email.include_document and original_file:
|
||||
attachments = [(original_file, document.mime_type)]
|
||||
n_messages = send_email(
|
||||
subject=subject,
|
||||
body=body,
|
||||
to=action.email.to.split(","),
|
||||
attachment=original_file if action.email.include_document else None,
|
||||
attachment_mime_type=document.mime_type,
|
||||
attachments=attachments,
|
||||
)
|
||||
logger.debug(
|
||||
f"Sent {n_messages} notification email(s) to {action.email.to}",
|
||||
|
@@ -3093,7 +3093,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
"message": "hello",
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
resp = self.client.post(
|
||||
f"/api/documents/{doc.pk}/email/",
|
||||
|
330
src/documents/tests/test_api_email.py
Normal file
330
src/documents/tests/test_api_email.py
Normal file
@@ -0,0 +1,330 @@
|
||||
import json
|
||||
import shutil
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import mail
|
||||
from django.test import override_settings
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.models import Document
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import SampleDirMixin
|
||||
|
||||
|
||||
class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||
ENDPOINT = "/api/documents/email/"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.user = User.objects.create_superuser(username="temp_admin")
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.doc1 = Document.objects.create(
|
||||
title="test1",
|
||||
mime_type="application/pdf",
|
||||
content="this is document 1",
|
||||
checksum="1",
|
||||
filename="test1.pdf",
|
||||
archive_checksum="A1",
|
||||
archive_filename="archive1.pdf",
|
||||
)
|
||||
self.doc2 = Document.objects.create(
|
||||
title="test2",
|
||||
mime_type="application/pdf",
|
||||
content="this is document 2",
|
||||
checksum="2",
|
||||
filename="test2.pdf",
|
||||
)
|
||||
|
||||
# Copy sample files to document paths
|
||||
shutil.copy(self.SAMPLE_DIR / "simple.pdf", self.doc1.archive_path)
|
||||
shutil.copy(self.SAMPLE_DIR / "simple.pdf", self.doc1.source_path)
|
||||
shutil.copy(self.SAMPLE_DIR / "simple.pdf", self.doc2.source_path)
|
||||
|
||||
@override_settings(
|
||||
EMAIL_ENABLED=True,
|
||||
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||
)
|
||||
def test_email_success(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Multiple existing documents
|
||||
WHEN:
|
||||
- API request is made to bulk email documents
|
||||
THEN:
|
||||
- Email is sent with all documents attached
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk, self.doc2.pk],
|
||||
"addresses": "hello@paperless-ngx.com,test@example.com",
|
||||
"subject": "Bulk email test",
|
||||
"message": "Here are your documents",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["message"], "Email sent")
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
email = mail.outbox[0]
|
||||
self.assertEqual(email.to, ["hello@paperless-ngx.com", "test@example.com"])
|
||||
self.assertEqual(email.subject, "Bulk email test")
|
||||
self.assertEqual(email.body, "Here are your documents")
|
||||
self.assertEqual(len(email.attachments), 2)
|
||||
|
||||
# Check attachment names (should default to archive version for doc1, original for doc2)
|
||||
attachment_names = [att[0] for att in email.attachments]
|
||||
self.assertIn("archive1.pdf", attachment_names)
|
||||
self.assertIn("test2.pdf", attachment_names)
|
||||
|
||||
@override_settings(
|
||||
EMAIL_ENABLED=True,
|
||||
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||
)
|
||||
def test_email_use_original_version(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Documents with archive versions
|
||||
WHEN:
|
||||
- API request is made to bulk email with use_archive_version=False
|
||||
THEN:
|
||||
- Original files are attached instead of archive versions
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk],
|
||||
"addresses": "test@example.com",
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
"use_archive_version": False,
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].attachments[0][0], "test1.pdf")
|
||||
|
||||
def test_email_missing_required_fields(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Request with missing required fields
|
||||
WHEN:
|
||||
- API request is made to bulk email endpoint
|
||||
THEN:
|
||||
- Bad request response is returned
|
||||
"""
|
||||
# Missing addresses
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk],
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Missing subject
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk],
|
||||
"addresses": "test@example.com",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Missing message
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk],
|
||||
"addresses": "test@example.com",
|
||||
"subject": "Test",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Missing documents
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"addresses": "test@example.com",
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_email_empty_document_list(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Request with empty document list
|
||||
WHEN:
|
||||
- API request is made to bulk email endpoint
|
||||
THEN:
|
||||
- Bad request response is returned
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [],
|
||||
"addresses": "test@example.com",
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_email_invalid_document_id(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Request with non-existent document ID
|
||||
WHEN:
|
||||
- API request is made to bulk email endpoint
|
||||
THEN:
|
||||
- Bad request response is returned
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [999],
|
||||
"addresses": "test@example.com",
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_email_invalid_email_address(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Request with invalid email address
|
||||
WHEN:
|
||||
- API request is made to bulk email endpoint
|
||||
THEN:
|
||||
- Bad request response is returned
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk],
|
||||
"addresses": "invalid-email",
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Test multiple addresses with one invalid
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk],
|
||||
"addresses": "valid@example.com,invalid-email",
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_email_insufficient_permissions(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- User without permissions to view document
|
||||
WHEN:
|
||||
- API request is made to bulk email documents
|
||||
THEN:
|
||||
- Forbidden response is returned
|
||||
"""
|
||||
user1 = User.objects.create_user(username="test1")
|
||||
user1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
|
||||
doc_owned = Document.objects.create(
|
||||
title="owned_doc",
|
||||
mime_type="application/pdf",
|
||||
checksum="owned",
|
||||
owner=self.user,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk, doc_owned.pk],
|
||||
"addresses": "test@example.com",
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
@mock.patch(
|
||||
"django.core.mail.message.EmailMessage.send",
|
||||
side_effect=Exception("Email error"),
|
||||
)
|
||||
def test_email_send_error(self, mocked_send):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing documents
|
||||
WHEN:
|
||||
- API request is made to bulk email and error occurs during email send
|
||||
THEN:
|
||||
- Server error response is returned
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk],
|
||||
"addresses": "test@example.com",
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
self.assertIn("Error emailing documents", response.content.decode())
|
@@ -57,6 +57,7 @@ from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from drf_spectacular.utils import extend_schema_serializer
|
||||
from drf_spectacular.utils import extend_schema_view
|
||||
from drf_spectacular.utils import inline_serializer
|
||||
from guardian.utils import get_group_obj_perms_model
|
||||
@@ -153,6 +154,7 @@ from documents.serialisers import CustomFieldSerializer
|
||||
from documents.serialisers import DocumentListSerializer
|
||||
from documents.serialisers import DocumentSerializer
|
||||
from documents.serialisers import DocumentTypeSerializer
|
||||
from documents.serialisers import EmailSerializer
|
||||
from documents.serialisers import NotesSerializer
|
||||
from documents.serialisers import PostDocumentSerializer
|
||||
from documents.serialisers import RunTaskViewSerializer
|
||||
@@ -471,6 +473,14 @@ class DocumentTypeViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
||||
ordering_fields = ("name", "matching_algorithm", "match", "document_count")
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
component_name="EmailDocumentRequest",
|
||||
exclude_fields=("documents",),
|
||||
)
|
||||
class EmailDocumentDetailSchema(EmailSerializer):
|
||||
pass
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
retrieve=extend_schema(
|
||||
description="Retrieve a single document",
|
||||
@@ -638,20 +648,28 @@ class DocumentTypeViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
||||
404: None,
|
||||
},
|
||||
),
|
||||
email=extend_schema(
|
||||
email_document=extend_schema(
|
||||
description="Email the document to one or more recipients as an attachment.",
|
||||
request=inline_serializer(
|
||||
name="EmailRequest",
|
||||
fields={
|
||||
"addresses": serializers.CharField(),
|
||||
"subject": serializers.CharField(),
|
||||
"message": serializers.CharField(),
|
||||
"use_archive_version": serializers.BooleanField(default=True),
|
||||
},
|
||||
),
|
||||
request=EmailDocumentDetailSchema,
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="EmailResponse",
|
||||
name="EmailDocumentResponse",
|
||||
fields={"message": serializers.CharField()},
|
||||
),
|
||||
400: None,
|
||||
403: None,
|
||||
404: None,
|
||||
500: None,
|
||||
},
|
||||
deprecated=True,
|
||||
),
|
||||
email_documents=extend_schema(
|
||||
operation_id="email_documents",
|
||||
description="Email one or more documents as attachments to one or more recipients.",
|
||||
request=EmailSerializer,
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="EmailDocumentsResponse",
|
||||
fields={"message": serializers.CharField()},
|
||||
),
|
||||
400: None,
|
||||
@@ -1155,55 +1173,65 @@ class DocumentViewSet(
|
||||
|
||||
return Response(sorted(entries, key=lambda x: x["timestamp"], reverse=True))
|
||||
|
||||
@action(methods=["post"], detail=True)
|
||||
def email(self, request, pk=None):
|
||||
try:
|
||||
doc = Document.objects.select_related("owner").get(pk=pk)
|
||||
@action(methods=["post"], detail=True, url_path="email")
|
||||
# TODO: deprecated as of 2.19, remove in future release
|
||||
def email_document(self, request, pk=None):
|
||||
request_data = request.data.copy()
|
||||
request_data.setlist("documents", [pk])
|
||||
return self.email_documents(request, data=request_data)
|
||||
|
||||
@action(
|
||||
methods=["post"],
|
||||
detail=False,
|
||||
url_path="email",
|
||||
serializer_class=EmailSerializer,
|
||||
)
|
||||
def email_documents(self, request, data=None):
|
||||
serializer = EmailSerializer(data=data or request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
validated_data = serializer.validated_data
|
||||
document_ids = validated_data.get("documents")
|
||||
addresses = validated_data.get("addresses").split(",")
|
||||
addresses = [addr.strip() for addr in addresses]
|
||||
subject = validated_data.get("subject")
|
||||
message = validated_data.get("message")
|
||||
use_archive_version = validated_data.get("use_archive_version", True)
|
||||
|
||||
documents = Document.objects.select_related("owner").filter(pk__in=document_ids)
|
||||
for document in documents:
|
||||
if request.user is not None and not has_perms_owner_aware(
|
||||
request.user,
|
||||
"view_document",
|
||||
doc,
|
||||
document,
|
||||
):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
except Document.DoesNotExist:
|
||||
raise Http404
|
||||
|
||||
attachments = []
|
||||
for doc in documents:
|
||||
attachment_path = (
|
||||
doc.archive_path
|
||||
if use_archive_version and doc.has_archive_version
|
||||
else doc.source_path
|
||||
)
|
||||
attachments.append((attachment_path, doc.mime_type))
|
||||
|
||||
try:
|
||||
if (
|
||||
"addresses" not in request.data
|
||||
or "subject" not in request.data
|
||||
or "message" not in request.data
|
||||
):
|
||||
return HttpResponseBadRequest("Missing required fields")
|
||||
|
||||
use_archive_version = request.data.get("use_archive_version", True)
|
||||
|
||||
addresses = request.data.get("addresses").split(",")
|
||||
if not all(
|
||||
re.match(r"[^@]+@[^@]+\.[^@]+", address.strip())
|
||||
for address in addresses
|
||||
):
|
||||
return HttpResponseBadRequest("Invalid email address found")
|
||||
|
||||
send_email(
|
||||
subject=request.data.get("subject"),
|
||||
body=request.data.get("message"),
|
||||
subject=subject,
|
||||
body=message,
|
||||
to=addresses,
|
||||
attachment=(
|
||||
doc.archive_path
|
||||
if use_archive_version and doc.has_archive_version
|
||||
else doc.source_path
|
||||
),
|
||||
attachment_mime_type=doc.mime_type,
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Sent document {doc.id} via email to {addresses}",
|
||||
f"Sent documents {[doc.id for doc in documents]} via email to {addresses}",
|
||||
)
|
||||
return Response({"message": "Email sent"})
|
||||
except Exception as e:
|
||||
logger.warning(f"An error occurred emailing document: {e!s}")
|
||||
logger.warning(f"An error occurred emailing documents: {e!s}")
|
||||
return HttpResponseServerError(
|
||||
"Error emailing document, check logs for more detail.",
|
||||
"Error emailing documents, check logs for more detail.",
|
||||
)
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user