Enhancement: use friendly file names when emailing documents (#11055)

This commit is contained in:
Jan Kleine
2025-10-15 19:10:25 +02:00
committed by GitHub
parent 39c429bb87
commit 340754d865
5 changed files with 142 additions and 29 deletions

View File

@@ -1,16 +1,23 @@
from __future__ import annotations
from email import message_from_bytes from email import message_from_bytes
from pathlib import Path from typing import TYPE_CHECKING
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from filelock import FileLock from filelock import FileLock
if TYPE_CHECKING:
from documents.models import Document
def send_email( def send_email(
subject: str, subject: str,
body: str, body: str,
to: list[str], to: list[str],
attachments: list[tuple[Path, str]], attachments: list[Document],
*,
use_archive: bool,
) -> int: ) -> int:
""" """
Send an email with attachments. Send an email with attachments.
@@ -19,7 +26,8 @@ def send_email(
subject: Email subject subject: Email subject
body: Email body text body: Email body text
to: List of recipient email addresses to: List of recipient email addresses
attachments: List of (path, mime_type) tuples for attachments (the list may be empty) attachments: List of documents to attach (the list may be empty)
use_archive: Whether to attach archive versions when available
Returns: Returns:
Number of emails sent Number of emails sent
@@ -32,19 +40,48 @@ def send_email(
to=to, to=to,
) )
used_filenames: set[str] = set()
# Something could be renaming the file concurrently so it can't be attached # Something could be renaming the file concurrently so it can't be attached
with FileLock(settings.MEDIA_LOCK): with FileLock(settings.MEDIA_LOCK):
for attachment_path, mime_type in attachments: for document in attachments:
attachment_path = (
document.archive_path
if use_archive and document.has_archive_version
else document.source_path
)
friendly_filename = _get_unique_filename(
document,
used_filenames,
archive=use_archive and document.has_archive_version,
)
used_filenames.add(friendly_filename)
with attachment_path.open("rb") as f: with attachment_path.open("rb") as f:
content = f.read() content = f.read()
if mime_type == "message/rfc822": if document.mime_type == "message/rfc822":
# See https://forum.djangoproject.com/t/using-emailmessage-with-an-attached-email-file-crashes-due-to-non-ascii/37981 # See https://forum.djangoproject.com/t/using-emailmessage-with-an-attached-email-file-crashes-due-to-non-ascii/37981
content = message_from_bytes(content) content = message_from_bytes(content)
email.attach( email.attach(
filename=attachment_path.name, filename=friendly_filename,
content=content, content=content,
mimetype=mime_type, mimetype=document.mime_type,
) )
return email.send() return email.send()
def _get_unique_filename(doc: Document, used_names: set[str], *, archive: bool) -> str:
"""
Constructs a unique friendly filename for the given document.
The filename might not be unique enough, so a counter is appended if needed.
"""
counter = 0
while True:
filename = doc.get_public_filename(archive=archive, counter=counter)
if filename not in used_names:
return filename
counter += 1

View File

@@ -1164,12 +1164,13 @@ def run_workflows(
try: try:
attachments = [] attachments = []
if action.email.include_document and original_file: if action.email.include_document and original_file:
attachments = [(original_file, document.mime_type)] attachments = [document]
n_messages = send_email( n_messages = send_email(
subject=subject, subject=subject,
body=body, body=body,
to=action.email.to.split(","), to=action.email.to.split(","),
attachments=attachments, attachments=attachments,
use_archive=False,
) )
logger.debug( logger.debug(
f"Sent {n_messages} notification email(s) to {action.email.to}", f"Sent {n_messages} notification email(s) to {action.email.to}",

View File

@@ -3022,7 +3022,8 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
) )
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].attachments[0][0], "archive.pdf") expected_filename = f"{doc.created} test.pdf"
self.assertEqual(mail.outbox[0].attachments[0][0], expected_filename)
self.client.post( self.client.post(
f"/api/documents/{doc2.pk}/email/", f"/api/documents/{doc2.pk}/email/",
@@ -3035,7 +3036,8 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
) )
self.assertEqual(len(mail.outbox), 2) self.assertEqual(len(mail.outbox), 2)
self.assertEqual(mail.outbox[1].attachments[0][0], "test2.pdf") expected_filename2 = f"{doc2.created} test2.pdf"
self.assertEqual(mail.outbox[1].attachments[0][0], expected_filename2)
@mock.patch("django.core.mail.message.EmailMessage.send", side_effect=Exception) @mock.patch("django.core.mail.message.EmailMessage.send", side_effect=Exception)
def test_email_document_errors(self, mocked_send): def test_email_document_errors(self, mocked_send):

View File

@@ -40,10 +40,19 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
filename="test2.pdf", filename="test2.pdf",
) )
# Copy sample files to document paths # Copy sample files to document paths (using different files to distinguish versions)
shutil.copy(self.SAMPLE_DIR / "simple.pdf", self.doc1.archive_path) shutil.copy(
shutil.copy(self.SAMPLE_DIR / "simple.pdf", self.doc1.source_path) self.SAMPLE_DIR / "documents" / "originals" / "0000001.pdf",
shutil.copy(self.SAMPLE_DIR / "simple.pdf", self.doc2.source_path) self.doc1.archive_path,
)
shutil.copy(
self.SAMPLE_DIR / "documents" / "originals" / "0000002.pdf",
self.doc1.source_path,
)
shutil.copy(
self.SAMPLE_DIR / "documents" / "originals" / "0000003.pdf",
self.doc2.source_path,
)
@override_settings( @override_settings(
EMAIL_ENABLED=True, EMAIL_ENABLED=True,
@@ -52,11 +61,13 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
def test_email_success(self): def test_email_success(self):
""" """
GIVEN: GIVEN:
- Multiple existing documents - Multiple existing documents (doc1 with archive, doc2 without)
WHEN: WHEN:
- API request is made to bulk email documents - API request is made to bulk email documents
THEN: THEN:
- Email is sent with all documents attached - Email is sent with all documents attached
- Archive version used by default for doc1
- Original version used for doc2 (no archive available)
""" """
response = self.client.post( response = self.client.post(
self.ENDPOINT, self.ENDPOINT,
@@ -81,10 +92,22 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
self.assertEqual(email.body, "Here are your documents") self.assertEqual(email.body, "Here are your documents")
self.assertEqual(len(email.attachments), 2) 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] attachment_names = [att[0] for att in email.attachments]
self.assertIn("archive1.pdf", attachment_names) self.assertEqual(len(attachment_names), 2)
self.assertIn("test2.pdf", attachment_names) self.assertIn(f"{self.doc1!s}.pdf", attachment_names)
self.assertIn(f"{self.doc2!s}.pdf", attachment_names)
doc1_attachment = next(
att for att in email.attachments if att[0] == f"{self.doc1!s}.pdf"
)
archive_size = self.doc1.archive_path.stat().st_size
self.assertEqual(len(doc1_attachment[1]), archive_size)
doc2_attachment = next(
att for att in email.attachments if att[0] == f"{self.doc2!s}.pdf"
)
original_size = self.doc2.source_path.stat().st_size
self.assertEqual(len(doc2_attachment[1]), original_size)
@override_settings( @override_settings(
EMAIL_ENABLED=True, EMAIL_ENABLED=True,
@@ -115,7 +138,12 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].attachments[0][0], "test1.pdf")
attachment = mail.outbox[0].attachments[0]
self.assertEqual(attachment[0], f"{self.doc1!s}.pdf")
original_size = self.doc1.source_path.stat().st_size
self.assertEqual(len(attachment[1]), original_size)
def test_email_missing_required_fields(self): def test_email_missing_required_fields(self):
""" """
@@ -301,6 +329,59 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
) )
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
@override_settings(
EMAIL_ENABLED=True,
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
)
def test_email_duplicate_filenames(self):
"""
GIVEN:
- Multiple documents with the same title
WHEN:
- API request is made to bulk email documents
THEN:
- Filenames are made unique with counters
"""
doc3 = Document.objects.create(
title="test1",
mime_type="application/pdf",
content="this is document 3",
checksum="3",
filename="test3.pdf",
)
shutil.copy(self.SAMPLE_DIR / "simple.pdf", doc3.source_path)
doc4 = Document.objects.create(
title="test1",
mime_type="application/pdf",
content="this is document 4",
checksum="4",
filename="test4.pdf",
)
shutil.copy(self.SAMPLE_DIR / "simple.pdf", doc4.source_path)
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"documents": [self.doc1.pk, doc3.pk, doc4.pk],
"addresses": "test@example.com",
"subject": "Test",
"message": "Test message",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(mail.outbox), 1)
attachment_names = [att[0] for att in mail.outbox[0].attachments]
self.assertEqual(len(attachment_names), 3)
self.assertIn(f"{self.doc1!s}.pdf", attachment_names)
self.assertIn(f"{doc3!s}_01.pdf", attachment_names)
self.assertIn(f"{doc3!s}_02.pdf", attachment_names)
@mock.patch( @mock.patch(
"django.core.mail.message.EmailMessage.send", "django.core.mail.message.EmailMessage.send",
side_effect=Exception("Email error"), side_effect=Exception("Email error"),

View File

@@ -1207,21 +1207,13 @@ class DocumentViewSet(
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
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: try:
send_email( send_email(
subject=subject, subject=subject,
body=message, body=message,
to=addresses, to=addresses,
attachments=attachments, attachments=documents,
use_archive=use_archive_version,
) )
logger.debug( logger.debug(