From a32077566b116ca9702a7730140165d5f807e06b Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Thu, 16 Jan 2025 10:48:19 -0800
Subject: [PATCH] Fix: use MIMEBase for email attachments (#8762)

---
 src/documents/mail.py             | 61 +++++++++++++++++++++++++++++++
 src/documents/signals/handlers.py | 12 ++----
 2 files changed, 65 insertions(+), 8 deletions(-)
 create mode 100644 src/documents/mail.py

diff --git a/src/documents/mail.py b/src/documents/mail.py
new file mode 100644
index 000000000..5183b1bae
--- /dev/null
+++ b/src/documents/mail.py
@@ -0,0 +1,61 @@
+from email.encoders import encode_base64
+from email.mime.base import MIMEBase
+from pathlib import Path
+from urllib.parse import quote
+
+from django.conf import settings
+from django.core.mail import EmailMessage
+from filelock import FileLock
+
+
+def send_email(
+    subject: str,
+    body: str,
+    to: list[str],
+    attachment: Path | None = None,
+    attachment_mime_type: str | None = None,
+) -> int:
+    """
+    Send an email with an optional attachment.
+    TODO: re-evaluate this pending https://code.djangoproject.com/ticket/35581 / https://github.com/django/django/pull/18966
+    """
+    email = EmailMessage(
+        subject=subject,
+        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:
+            file_content = f.read()
+
+            main_type, sub_type = (
+                attachment_mime_type.split("/", 1)
+                if attachment_mime_type
+                else ("application", "octet-stream")
+            )
+            mime_part = MIMEBase(main_type, sub_type)
+            mime_part.set_payload(file_content)
+
+            encode_base64(mime_part)
+
+            # see https://github.com/stumpylog/tika-client/blob/f65a2b792fc3cf15b9b119501bba9bddfac15fcc/src/tika_client/_base.py#L46-L57
+            try:
+                attachment.name.encode("ascii")
+            except UnicodeEncodeError:
+                filename_safed = attachment.name.encode("ascii", "ignore").decode(
+                    "ascii",
+                )
+                filepath_quoted = quote(attachment.name, encoding="utf-8")
+                mime_part.add_header(
+                    "Content-Disposition",
+                    f"attachment; filename={filename_safed}; filename*=UTF-8''{filepath_quoted}",
+                )
+            else:
+                mime_part.add_header(
+                    "Content-Disposition",
+                    f"attachment; filename={attachment.name}",
+                )
+
+            email.attach(mime_part)
+    return email.send()
diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index fd17bbf74..1d21b962b 100644
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -12,7 +12,6 @@ from celery.signals import task_postrun
 from celery.signals import task_prerun
 from django.conf import settings
 from django.contrib.auth.models import User
-from django.core.mail import EmailMessage
 from django.db import DatabaseError
 from django.db import close_old_connections
 from django.db import models
@@ -30,6 +29,7 @@ from documents.data_models import DocumentMetadataOverrides
 from documents.file_handling import create_source_path_directory
 from documents.file_handling import delete_empty_directories
 from documents.file_handling import generate_unique_filename
+from documents.mail import send_email
 from documents.models import Correspondent
 from documents.models import CustomField
 from documents.models import CustomFieldInstance
@@ -972,17 +972,13 @@ def run_workflows(
             doc_url,
         )
         try:
-            email = EmailMessage(
+            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,
             )
-            if action.email.include_document:
-                # Something could be renaming the file concurrently so it can't be attached
-                with FileLock(settings.MEDIA_LOCK):
-                    document.refresh_from_db()
-                    email.attach_file(original_file)
-            n_messages = email.send()
             logger.debug(
                 f"Sent {n_messages} notification email(s) to {action.email.to}",
                 extra={"group": logging_group},