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 30e34d9cf..9fe0d7294 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
@@ -99,7 +99,7 @@ import { TagsComponent } from '../common/input/tags/tags.component'
import { TextComponent } from '../common/input/text/text.component'
import { UrlComponent } from '../common/input/url/url.component'
import { PageHeaderComponent } from '../common/page-header/page-header.component'
-import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component'
+import { ShareDocumentDropdownComponent } from '../common/share-document-dropdown/share-document-dropdown.component'
import { DocumentHistoryComponent } from '../document-history/document-history.component'
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
@@ -145,7 +145,7 @@ export enum ZoomSetting {
CustomFieldsDropdownComponent,
DocumentNotesComponent,
DocumentHistoryComponent,
- ShareLinksDropdownComponent,
+ ShareDocumentDropdownComponent,
CheckComponent,
DateComponent,
DocumentLinkComponent,
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 4d7d7cef7..84f7f6f8a 100644
--- a/src-ui/src/app/services/rest/document.service.spec.ts
+++ b/src-ui/src/app/services/rest/document.service.spec.ts
@@ -355,6 +355,21 @@ 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,
+ 'hello@paperless-ngx.com',
+ 'hello',
+ 'world',
+ true
+ )
+ .subscribe()
+ httpTestingController.expectOne(
+ `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/email/`
+ )
+})
+
afterEach(() => {
subscription?.unsubscribe()
httpTestingController.verify()
diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts
index bbb611adf..0c6c8cfa6 100644
--- a/src-ui/src/app/services/rest/document.service.ts
+++ b/src-ui/src/app/services/rest/document.service.ts
@@ -258,4 +258,19 @@ export class DocumentService extends AbstractPaperlessService
{
public get searchQuery(): string {
return this._searchQuery
}
+
+ emailDocument(
+ documentId: number,
+ addresses: string,
+ subject: string,
+ message: string,
+ useArchiveVersion: boolean
+ ): Observable {
+ return this.http.post(this.getResourceUrl(documentId, 'email'), {
+ addresses: addresses,
+ subject: subject,
+ message: message,
+ use_archive_version: useArchiveVersion,
+ })
+ }
}
diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py
index 6247b0a6e..630aeaee2 100644
--- a/src/documents/tests/test_api_documents.py
+++ b/src/documents/tests/test_api_documents.py
@@ -15,6 +15,7 @@ from dateutil import parser
from django.conf import settings
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
+from django.core import mail
from django.core.cache import cache
from django.db import DataError
from django.test import override_settings
@@ -2651,6 +2652,153 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(doc1.tags.count(), 2)
+ @override_settings(
+ EMAIL_ENABLED=True,
+ EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
+ )
+ def test_email_document(self):
+ """
+ GIVEN:
+ - Existing document
+ WHEN:
+ - API request is made to email document action
+ THEN:
+ - Email is sent, with document (original or archive) attached
+ """
+ doc = Document.objects.create(
+ title="test",
+ mime_type="application/pdf",
+ content="this is a document 1",
+ checksum="1",
+ filename="test.pdf",
+ archive_checksum="A",
+ archive_filename="archive.pdf",
+ )
+ doc2 = Document.objects.create(
+ title="test2",
+ mime_type="application/pdf",
+ content="this is a document 2",
+ checksum="2",
+ filename="test2.pdf",
+ )
+
+ archive_file = os.path.join(os.path.dirname(__file__), "samples", "simple.pdf")
+ source_file = os.path.join(os.path.dirname(__file__), "samples", "simple.pdf")
+
+ shutil.copy(archive_file, doc.archive_path)
+ shutil.copy(source_file, doc2.source_path)
+
+ self.client.post(
+ f"/api/documents/{doc.pk}/email/",
+ {
+ "addresses": "hello@paperless-ngx.com",
+ "subject": "test",
+ "message": "hello",
+ },
+ )
+
+ self.assertEqual(len(mail.outbox), 1)
+ self.assertEqual(mail.outbox[0].attachments[0][0], "archive.pdf")
+
+ self.client.post(
+ f"/api/documents/{doc2.pk}/email/",
+ {
+ "addresses": "hello@paperless-ngx.com",
+ "subject": "test",
+ "message": "hello",
+ "use_archive_version": False,
+ },
+ )
+
+ self.assertEqual(len(mail.outbox), 2)
+ self.assertEqual(mail.outbox[1].attachments[0][0], "test2.pdf")
+
+ @mock.patch("django.core.mail.message.EmailMessage.send", side_effect=Exception)
+ def test_email_document_errors(self, mocked_send):
+ """
+ GIVEN:
+ - Existing document
+ WHEN:
+ - API request is made to email document action with insufficient permissions
+ - API request is made to email document action with invalid document id
+ - API request is made to email document action with missing data
+ - API request is made to email document action with invalid email address
+ - API request is made to email document action and error occurs during email send
+ THEN:
+ - Error response is returned
+ """
+ user1 = User.objects.create_user(username="test1")
+ user1.user_permissions.add(*Permission.objects.all())
+ user1.save()
+
+ doc = Document.objects.create(
+ title="test",
+ mime_type="application/pdf",
+ content="this is a document 1",
+ checksum="1",
+ filename="test.pdf",
+ archive_checksum="A",
+ archive_filename="archive.pdf",
+ )
+
+ doc2 = Document.objects.create(
+ title="test2",
+ mime_type="application/pdf",
+ content="this is a document 2",
+ checksum="2",
+ owner=self.user,
+ )
+
+ self.client.force_authenticate(user1)
+
+ resp = self.client.post(
+ f"/api/documents/{doc2.pk}/email/",
+ {
+ "addresses": "hello@paperless-ngx.com",
+ "subject": "test",
+ "message": "hello",
+ },
+ )
+ self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
+
+ resp = self.client.post(
+ "/api/documents/999/email/",
+ {
+ "addresses": "hello@paperless-ngx.com",
+ "subject": "test",
+ "message": "hello",
+ },
+ )
+ self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
+
+ resp = self.client.post(
+ f"/api/documents/{doc.pk}/email/",
+ {
+ "addresses": "hello@paperless-ngx.com",
+ },
+ )
+ self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
+
+ resp = self.client.post(
+ f"/api/documents/{doc.pk}/email/",
+ {
+ "addresses": "hello@paperless-ngx.com,hello",
+ "subject": "test",
+ "message": "hello",
+ },
+ )
+ self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
+
+ resp = self.client.post(
+ f"/api/documents/{doc.pk}/email/",
+ {
+ "addresses": "hello@paperless-ngx.com",
+ "subject": "test",
+ "message": "hello",
+ },
+ )
+ self.assertEqual(resp.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
+
@mock.patch("django_softdelete.models.SoftDeleteModel.delete")
def test_warn_on_delete_with_old_uuid_field(self, mocked_delete):
"""
diff --git a/src/documents/views.py b/src/documents/views.py
index aceea6699..8e61f74f0 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -18,6 +18,7 @@ import pathvalidate
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
+from django.core.mail import EmailMessage
from django.db import connections
from django.db.migrations.loader import MigrationLoader
from django.db.migrations.recorder import MigrationRecorder
@@ -37,6 +38,7 @@ from django.http import HttpResponse
from django.http import HttpResponseBadRequest
from django.http import HttpResponseForbidden
from django.http import HttpResponseRedirect
+from django.http import HttpResponseServerError
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.decorators import method_decorator
@@ -1023,6 +1025,58 @@ 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)
+ if request.user is not None and not has_perms_owner_aware(
+ request.user,
+ "view_document",
+ doc,
+ ):
+ return HttpResponseForbidden("Insufficient permissions")
+ except Document.DoesNotExist:
+ raise Http404
+
+ 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")
+
+ email = EmailMessage(
+ subject=request.data.get("subject"),
+ body=request.data.get("message"),
+ to=addresses,
+ )
+ attachment = (
+ doc.archive_path
+ if use_archive_version and doc.has_archive_version
+ else doc.source_path
+ )
+ email.attach_file(attachment)
+ email.send()
+ logger.debug(
+ f"Sent document {doc.id} via email to {addresses}",
+ )
+ return Response({"message": "Email sent"})
+ except Exception as e:
+ logger.warning(f"An error occurred emailing document: {e!s}")
+ return HttpResponseServerError(
+ "Error emailing document, check logs for more detail.",
+ )
+
@extend_schema_view(
list=extend_schema(