diff --git a/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.html b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.html index 56d404fd5..079790c4b 100644 --- a/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.html +++ b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.html @@ -1,5 +1,9 @@ - +
+ Some email servers may reject messages with large attachments. +
diff --git a/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.spec.ts b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.spec.ts index 7a3659205..462d93477 100644 --- a/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.spec.ts @@ -36,31 +36,59 @@ describe('EmailDocumentDialogComponent', () => { documentService = TestBed.inject(DocumentService) toastService = TestBed.inject(ToastService) component = fixture.componentInstance + component.documentIds = [1] fixture.detectChanges() }) it('should set hasArchiveVersion and useArchiveVersion', () => { expect(component.hasArchiveVersion).toBeTruthy() + expect(component.useArchiveVersion).toBeTruthy() + component.hasArchiveVersion = false expect(component.hasArchiveVersion).toBeFalsy() expect(component.useArchiveVersion).toBeFalsy() }) - it('should support sending document via email, showing error if needed', () => { + it('should support sending single document via email, showing error if needed', () => { const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') + component.documentIds = [1] component.emailAddress = 'hello@paperless-ngx.com' component.emailSubject = 'Hello' component.emailMessage = 'World' jest - .spyOn(documentService, 'emailDocument') + .spyOn(documentService, 'emailDocuments') .mockReturnValue(throwError(() => new Error('Unable to email document'))) - component.emailDocument() - expect(toastErrorSpy).toHaveBeenCalled() + component.emailDocuments() + expect(toastErrorSpy).toHaveBeenCalledWith( + 'Error emailing document', + expect.any(Error) + ) - jest.spyOn(documentService, 'emailDocument').mockReturnValue(of(true)) - component.emailDocument() - expect(toastSuccessSpy).toHaveBeenCalled() + jest.spyOn(documentService, 'emailDocuments').mockReturnValue(of(true)) + component.emailDocuments() + expect(toastSuccessSpy).toHaveBeenCalledWith('Email sent') + }) + + it('should support sending multiple documents via email, showing appropriate messages', () => { + const toastErrorSpy = jest.spyOn(toastService, 'showError') + const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') + component.documentIds = [1, 2, 3] + component.emailAddress = 'hello@paperless-ngx.com' + component.emailSubject = 'Hello' + component.emailMessage = 'World' + jest + .spyOn(documentService, 'emailDocuments') + .mockReturnValue(throwError(() => new Error('Unable to email documents'))) + component.emailDocuments() + expect(toastErrorSpy).toHaveBeenCalledWith( + 'Error emailing documents', + expect.any(Error) + ) + + jest.spyOn(documentService, 'emailDocuments').mockReturnValue(of(true)) + component.emailDocuments() + expect(toastSuccessSpy).toHaveBeenCalledWith('Email sent') }) it('should close the dialog', () => { diff --git a/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.ts b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.ts index dc9455330..96e236e11 100644 --- a/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.ts +++ b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.ts @@ -18,10 +18,7 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission private toastService = inject(ToastService) @Input() - title = $localize`Email Document` - - @Input() - documentId: number + documentIds: number[] private _hasArchiveVersion: boolean = true @@ -46,11 +43,11 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission this.loading = false } - public emailDocument() { + public emailDocuments() { this.loading = true this.documentService - .emailDocument( - this.documentId, + .emailDocuments( + this.documentIds, this.emailAddress, this.emailSubject, this.emailMessage, @@ -67,7 +64,11 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission }, error: (e) => { this.loading = false - this.toastService.showError($localize`Error emailing document`, e) + const errorMessage = + this.documentIds.length > 1 + ? $localize`Error emailing documents` + : $localize`Error emailing document` + this.toastService.showError(errorMessage, e) }, }) } diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index c2780652a..48ddf61e5 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -1481,7 +1481,7 @@ export class DocumentDetailComponent const modal = this.modalService.open(EmailDocumentDialogComponent, { backdrop: 'static', }) - modal.componentInstance.documentId = this.document.id + modal.componentInstance.documentIds = [this.document.id] modal.componentInstance.hasArchiveVersion = !!this.document?.archived_file_name } diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html index 7e499dfd0..5f0260529 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -96,6 +96,9 @@ + diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts index 96c180263..49877b470 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -46,6 +46,7 @@ import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/docume import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component' import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' +import { EmailDocumentDialogComponent } from '../../common/email-document-dialog/email-document-dialog.component' import { ChangedItems, FilterableDropdownComponent, @@ -902,4 +903,16 @@ export class BulkEditorComponent ) }) } + + emailSelected() { + const allHaveArchiveVersion = this.list.documents + .filter((d) => this.list.selected.has(d.id)) + .every((doc) => !!doc.archived_file_name) + + const modal = this.modalService.open(EmailDocumentDialogComponent, { + backdrop: 'static', + }) + modal.componentInstance.documentIds = Array.from(this.list.selected) + modal.componentInstance.hasArchiveVersion = allHaveArchiveVersion + } } diff --git a/src-ui/src/app/services/rest/document.service.spec.ts b/src-ui/src/app/services/rest/document.service.spec.ts index 9480e2816..55cbdcff9 100644 --- a/src-ui/src/app/services/rest/document.service.spec.ts +++ b/src-ui/src/app/services/rest/document.service.spec.ts @@ -357,17 +357,15 @@ it('should include custom fields in sort fields if user has permission', () => { it('should call appropriate api endpoint for email document', () => { subscription = service - .emailDocument( - documents[0].id, + .emailDocuments( + [documents[0].id], 'hello@paperless-ngx.com', 'hello', 'world', true ) .subscribe() - httpTestingController.expectOne( - `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/email/` - ) + httpTestingController.expectOne(`${environment.apiBaseUrl}${endpoint}/email/`) }) afterEach(() => { diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts index 4f52633ea..1cead8ec5 100644 --- a/src-ui/src/app/services/rest/document.service.ts +++ b/src-ui/src/app/services/rest/document.service.ts @@ -256,14 +256,15 @@ export class DocumentService extends AbstractPaperlessService { return this._searchQuery } - emailDocument( - documentId: number, + emailDocuments( + documentIds: number[], addresses: string, subject: string, message: string, useArchiveVersion: boolean ): Observable { - return this.http.post(this.getResourceUrl(documentId, 'email'), { + return this.http.post(this.getResourceUrl(null, 'email'), { + documents: documentIds, addresses: addresses, subject: subject, message: message, diff --git a/src/documents/mail.py b/src/documents/mail.py index 12a1c0aa0..fa7d61feb 100644 --- a/src/documents/mail.py +++ b/src/documents/mail.py @@ -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() diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index ce0192074..0633684bb 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -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 diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 97903fd66..12eedb49b 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -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}", diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index 927744c37..906cfa351 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -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/", diff --git a/src/documents/tests/test_api_email.py b/src/documents/tests/test_api_email.py new file mode 100644 index 000000000..ed2fed750 --- /dev/null +++ b/src/documents/tests/test_api_email.py @@ -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()) diff --git a/src/documents/views.py b/src/documents/views.py index efc6896e7..eec24d13d 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -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.", )