diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
index 349e213aa..b85a7eaf4 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
+++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
@@ -1330,4 +1330,18 @@ describe('DocumentDetailComponent', () => {
expect(createSpy).toHaveBeenCalledWith('a')
expect(urlRevokeSpy).toHaveBeenCalled()
})
+
+ it('should get email enabled status from settings', () => {
+ jest.spyOn(settingsService, 'get').mockReturnValue(true)
+ expect(component.emailEnabled).toBeTruthy()
+ })
+
+ it('should support open share links and email modals', () => {
+ const modalSpy = jest.spyOn(modalService, 'open')
+ initNormally()
+ component.openShareLinks()
+ expect(modalSpy).toHaveBeenCalled()
+ component.openEmailDocument()
+ expect(modalSpy).toHaveBeenCalled()
+ })
})
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..27a74cfcd 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
@@ -88,6 +88,7 @@ import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspo
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
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 { EmailDocumentDialogComponent } from '../common/email-document-dialog/email-document-dialog.component'
import { CheckComponent } from '../common/input/check/check.component'
import { DateComponent } from '../common/input/date/date.component'
import { DocumentLinkComponent } from '../common/input/document-link/document-link.component'
@@ -99,7 +100,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 { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.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 +146,6 @@ export enum ZoomSetting {
CustomFieldsDropdownComponent,
DocumentNotesComponent,
DocumentHistoryComponent,
- ShareLinksDropdownComponent,
CheckComponent,
DateComponent,
DocumentLinkComponent,
@@ -1426,6 +1426,26 @@ export class DocumentDetailComponent
})
}
+ public openShareLinks() {
+ const modal = this.modalService.open(ShareLinksDialogComponent)
+ modal.componentInstance.documentId = this.document.id
+ modal.componentInstance.hasArchiveVersion =
+ !!this.document?.archived_file_name
+ }
+
+ get emailEnabled(): boolean {
+ return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED)
+ }
+
+ public openEmailDocument() {
+ const modal = this.modalService.open(EmailDocumentDialogComponent, {
+ backdrop: 'static',
+ })
+ modal.componentInstance.documentId = this.document.id
+ modal.componentInstance.hasArchiveVersion =
+ !!this.document?.archived_file_name
+ }
+
private tryRenderTiff() {
this.http.get(this.previewUrl, { responseType: 'arraybuffer' }).subscribe({
next: (res) => {
diff --git a/src-ui/src/app/data/workflow-trigger.ts b/src-ui/src/app/data/workflow-trigger.ts
index 12f76b7a3..4299356b0 100644
--- a/src-ui/src/app/data/workflow-trigger.ts
+++ b/src-ui/src/app/data/workflow-trigger.ts
@@ -4,6 +4,7 @@ export enum DocumentSource {
ConsumeFolder = 1,
ApiUpload = 2,
MailFetch = 3,
+ WebUI = 4,
}
export enum WorkflowTriggerType {
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-ui/src/app/services/upload-documents.service.ts b/src-ui/src/app/services/upload-documents.service.ts
index 602e6d8ae..e2d1b52f4 100644
--- a/src-ui/src/app/services/upload-documents.service.ts
+++ b/src-ui/src/app/services/upload-documents.service.ts
@@ -37,6 +37,7 @@ export class UploadDocumentsService {
private uploadFile(file: File) {
let formData = new FormData()
formData.append('document', file, file.name)
+ formData.append('from_webui', 'true')
let status = this.websocketStatusService.newFileUpload(file.name)
status.message = $localize`Connecting...`
diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts
index a9d446891..dd31a6b1e 100644
--- a/src-ui/src/main.ts
+++ b/src-ui/src/main.ts
@@ -112,6 +112,7 @@ import {
questionCircle,
scissors,
search,
+ send,
slashCircle,
sliders2Vertical,
sortAlphaDown,
@@ -316,6 +317,7 @@ const icons = {
questionCircle,
scissors,
search,
+ send,
slashCircle,
sliders2Vertical,
sortAlphaDown,
diff --git a/src/documents/data_models.py b/src/documents/data_models.py
index 231e59005..406fe6b5a 100644
--- a/src/documents/data_models.py
+++ b/src/documents/data_models.py
@@ -144,6 +144,7 @@ class DocumentSource(IntEnum):
ConsumeFolder = 1
ApiUpload = 2
MailFetch = 3
+ WebUI = 4
@dataclasses.dataclass
diff --git a/src/documents/migrations/1063_alter_workflowactionwebhook_url.py b/src/documents/migrations/1063_alter_workflowactionwebhook_url.py
deleted file mode 100644
index e24928717..000000000
--- a/src/documents/migrations/1063_alter_workflowactionwebhook_url.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# Generated by Django 5.1.6 on 2025-02-16 16:31
-
-from django.db import migrations
-from django.db import models
-
-
-class Migration(migrations.Migration):
- dependencies = [
- ("documents", "1062_alter_savedviewfilterrule_rule_type"),
- ]
-
- operations = [
- migrations.AlterField(
- model_name="workflowactionwebhook",
- name="url",
- field=models.CharField(
- help_text="The destination URL for the notification.",
- max_length=256,
- verbose_name="webhook url",
- ),
- ),
- ]
diff --git a/src/documents/migrations/1063_alter_workflowactionwebhook_url_and_more.py b/src/documents/migrations/1063_alter_workflowactionwebhook_url_and_more.py
new file mode 100644
index 000000000..16c1eeb63
--- /dev/null
+++ b/src/documents/migrations/1063_alter_workflowactionwebhook_url_and_more.py
@@ -0,0 +1,52 @@
+# Generated by Django 5.1.6 on 2025-02-20 04:55
+
+import multiselectfield.db.fields
+from django.db import migrations
+from django.db import models
+
+
+# WebUI source was added, so all existing APIUpload sources should be updated to include WebUI
+def update_workflow_sources(apps, schema_editor):
+ WorkflowTrigger = apps.get_model("documents", "WorkflowTrigger")
+ for trigger in WorkflowTrigger.objects.all():
+ sources = list(trigger.sources)
+ if 2 in sources:
+ sources.append(4)
+ trigger.sources = sources
+ trigger.save()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("documents", "1062_alter_savedviewfilterrule_rule_type"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="workflowactionwebhook",
+ name="url",
+ field=models.CharField(
+ help_text="The destination URL for the notification.",
+ max_length=256,
+ verbose_name="webhook url",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="workflowtrigger",
+ name="sources",
+ field=multiselectfield.db.fields.MultiSelectField(
+ choices=[
+ (1, "Consume Folder"),
+ (2, "Api Upload"),
+ (3, "Mail Fetch"),
+ (4, "Web UI"),
+ ],
+ default="1,2,3,4",
+ max_length=7,
+ ),
+ ),
+ migrations.RunPython(
+ code=update_workflow_sources,
+ reverse_code=migrations.RunPython.noop,
+ ),
+ ]
diff --git a/src/documents/models.py b/src/documents/models.py
index 4f9d3cb0e..b23bd8045 100644
--- a/src/documents/models.py
+++ b/src/documents/models.py
@@ -1031,6 +1031,7 @@ class WorkflowTrigger(models.Model):
CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder")
API_UPLOAD = DocumentSource.ApiUpload.value, _("Api Upload")
MAIL_FETCH = DocumentSource.MailFetch.value, _("Mail Fetch")
+ WEB_UI = DocumentSource.WebUI.value, _("Web UI")
class ScheduleDateField(models.TextChoices):
ADDED = "added", _("Added")
@@ -1045,9 +1046,9 @@ class WorkflowTrigger(models.Model):
)
sources = MultiSelectField(
- max_length=5,
+ max_length=7,
choices=DocumentSourceChoices.choices,
- default=f"{DocumentSource.ConsumeFolder},{DocumentSource.ApiUpload},{DocumentSource.MailFetch}",
+ default=f"{DocumentSource.ConsumeFolder},{DocumentSource.ApiUpload},{DocumentSource.MailFetch},{DocumentSource.WebUI}",
)
filter_path = models.CharField(
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index 75896ecdd..aeba5a721 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -1546,6 +1546,12 @@ class PostDocumentSerializer(serializers.Serializer):
required=False,
)
+ from_webui = serializers.BooleanField(
+ label="Documents are from Paperless-ngx WebUI",
+ write_only=True,
+ required=False,
+ )
+
def validate_document(self, document):
document_data = document.file.read()
mime_type = magic.from_buffer(document_data, mime=True)
diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py
index a1bea944a..28261b392 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
@@ -38,6 +39,7 @@ from documents.models import SavedView
from documents.models import ShareLink
from documents.models import StoragePath
from documents.models import Tag
+from documents.models import WorkflowTrigger
from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import DocumentConsumeDelayMixin
@@ -1362,6 +1364,30 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
self.assertEqual(overrides.filename, "simple.pdf")
self.assertEqual(overrides.custom_field_ids, [custom_field.id])
+ def test_upload_with_webui_source(self):
+ """
+ GIVEN: A document with a source file
+ WHEN: Upload the document with 'from_webui' flag
+ THEN: Consume is called with the source set as WebUI
+ """
+ self.consume_file_mock.return_value = celery.result.AsyncResult(
+ id=str(uuid.uuid4()),
+ )
+
+ with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
+ response = self.client.post(
+ "/api/documents/post_document/",
+ {"document": f, "from_webui": True},
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ self.consume_file_mock.assert_called_once()
+
+ input_doc, overrides = self.get_last_consume_delay_call_args()
+
+ self.assertEqual(input_doc.source, WorkflowTrigger.DocumentSourceChoices.WEB_UI)
+
def test_upload_invalid_pdf(self):
"""
GIVEN: Invalid PDF named "*.pdf" that mime_type is in settings.CONSUMER_PDF_RECOVERABLE_MIME_TYPES
@@ -2626,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 = Path(__file__).parent / "samples" / "simple.pdf"
+ source_file = Path(__file__).parent / "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 a856883f3..a4e35a2f4 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -37,6 +37,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
@@ -106,6 +107,7 @@ from documents.filters import ObjectOwnedPermissionsFilter
from documents.filters import ShareLinkFilterSet
from documents.filters import StoragePathFilterSet
from documents.filters import TagFilterSet
+from documents.mail import send_email
from documents.matching import match_correspondents
from documents.matching import match_document_types
from documents.matching import match_storage_paths
@@ -1023,6 +1025,57 @@ 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")
+
+ send_email(
+ 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
+ ),
+ attachment_mime_type=doc.mime_type,
+ )
+ 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(
@@ -1385,6 +1438,7 @@ class PostDocumentView(GenericAPIView):
created = serializer.validated_data.get("created")
archive_serial_number = serializer.validated_data.get("archive_serial_number")
custom_field_ids = serializer.validated_data.get("custom_fields")
+ from_webui = serializer.validated_data.get("from_webui")
t = int(mktime(datetime.now().timetuple()))
@@ -1399,7 +1453,7 @@ class PostDocumentView(GenericAPIView):
os.utime(temp_file_path, times=(t, t))
input_doc = ConsumableDocument(
- source=DocumentSource.ApiUpload,
+ source=DocumentSource.WebUI if from_webui else DocumentSource.ApiUpload,
original_file=temp_file_path,
)
input_doc_overrides = DocumentMetadataOverrides(