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:
@@ -1,5 +1,9 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<h4 class="modal-title" id="modal-basic-title" i18n>{
|
||||
documentIds.length,
|
||||
plural,
|
||||
=1 {Email Document} other {Email {{documentIds.length}} Documents}
|
||||
}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -22,11 +26,14 @@
|
||||
<input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
|
||||
<label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-primary" (click)="emailDocument()" [disabled]="loading || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0">
|
||||
<button type="submit" class="btn btn-outline-primary" (click)="emailDocuments()" [disabled]="loading || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0">
|
||||
@if (loading) {
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
}
|
||||
<ng-container i18n>Send email</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-light fst-italic small mt-2">
|
||||
<ng-container i18n>Some email servers may reject messages with large attachments.</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -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', () => {
|
||||
|
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -96,6 +96,9 @@
|
||||
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
|
||||
<i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container>
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!userCanEdit">
|
||||
<i-bs name="envelope"></i-bs> <ng-container i18n>Email</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
@@ -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(() => {
|
||||
|
@@ -256,14 +256,15 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
return this._searchQuery
|
||||
}
|
||||
|
||||
emailDocument(
|
||||
documentId: number,
|
||||
emailDocuments(
|
||||
documentIds: number[],
|
||||
addresses: string,
|
||||
subject: string,
|
||||
message: string,
|
||||
useArchiveVersion: boolean
|
||||
): Observable<any> {
|
||||
return this.http.post(this.getResourceUrl(documentId, 'email'), {
|
||||
return this.http.post(this.getResourceUrl(null, 'email'), {
|
||||
documents: documentIds,
|
||||
addresses: addresses,
|
||||
subject: subject,
|
||||
message: message,
|
||||
|
@@ -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:
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
for attachment_path, mime_type in attachments:
|
||||
with attachment_path.open("rb") as f:
|
||||
content = f.read()
|
||||
if attachment_mime_type == "message/rfc822":
|
||||
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(f.read())
|
||||
content = message_from_bytes(content)
|
||||
|
||||
email.attach(
|
||||
filename=attachment.name,
|
||||
filename=attachment_path.name,
|
||||
content=content,
|
||||
mimetype=attachment_mime_type,
|
||||
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
|
||||
|
||||
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=(
|
||||
attachments = []
|
||||
for doc in documents:
|
||||
attachment_path = (
|
||||
doc.archive_path
|
||||
if use_archive_version and doc.has_archive_version
|
||||
else doc.source_path
|
||||
),
|
||||
attachment_mime_type=doc.mime_type,
|
||||
)
|
||||
attachments.append((attachment_path, doc.mime_type))
|
||||
|
||||
try:
|
||||
send_email(
|
||||
subject=subject,
|
||||
body=message,
|
||||
to=addresses,
|
||||
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