diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index c163b4e5..8ae59e1b 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -197,6 +197,21 @@ "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN": { "type": "object", "readOnly": true + }, + "MAX_OUTGOING_ATTACHMENT_SIZE": { + "type": "integer", + "description": "Maximum size in bytes for outgoing email attachments", + "readOnly": true + }, + "MAX_OUTGOING_BODY_SIZE": { + "type": "integer", + "description": "Maximum size in bytes for outgoing email body (text + HTML)", + "readOnly": true + }, + "MAX_INCOMING_EMAIL_SIZE": { + "type": "integer", + "description": "Maximum size in bytes for incoming email (including attachments and body)", + "readOnly": true } }, "required": [ @@ -207,7 +222,10 @@ "FEATURE_AI_SUMMARY", "FEATURE_AI_AUTOLABELS", "SCHEMA_CUSTOM_ATTRIBUTES_USER", - "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN" + "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN", + "MAX_OUTGOING_ATTACHMENT_SIZE", + "MAX_OUTGOING_BODY_SIZE", + "MAX_INCOMING_EMAIL_SIZE" ] } } diff --git a/src/backend/core/api/viewsets/config.py b/src/backend/core/api/viewsets/config.py index a33402d1..2ca6c685 100644 --- a/src/backend/core/api/viewsets/config.py +++ b/src/backend/core/api/viewsets/config.py @@ -62,6 +62,21 @@ class ConfigView(drf.views.APIView): "type": "object", "readOnly": True, }, + "MAX_OUTGOING_ATTACHMENT_SIZE": { + "type": "integer", + "description": "Maximum size in bytes for outgoing email attachments", + "readOnly": True, + }, + "MAX_OUTGOING_BODY_SIZE": { + "type": "integer", + "description": "Maximum size in bytes for outgoing email body (text + HTML)", + "readOnly": True, + }, + "MAX_INCOMING_EMAIL_SIZE": { + "type": "integer", + "description": "Maximum size in bytes for incoming email (including attachments and body)", + "readOnly": True, + }, }, "required": [ "ENVIRONMENT", @@ -72,6 +87,9 @@ class ConfigView(drf.views.APIView): "FEATURE_AI_AUTOLABELS", "SCHEMA_CUSTOM_ATTRIBUTES_USER", "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN", + "MAX_OUTGOING_ATTACHMENT_SIZE", + "MAX_OUTGOING_BODY_SIZE", + "MAX_INCOMING_EMAIL_SIZE", ], }, ) @@ -100,6 +118,13 @@ class ConfigView(drf.views.APIView): dict_settings["FEATURE_AI_SUMMARY"] = is_ai_summary_enabled() dict_settings["FEATURE_AI_AUTOLABELS"] = is_auto_labels_enabled() + # Email size limits + dict_settings["MAX_OUTGOING_ATTACHMENT_SIZE"] = ( + settings.MAX_OUTGOING_ATTACHMENT_SIZE + ) + dict_settings["MAX_OUTGOING_BODY_SIZE"] = settings.MAX_OUTGOING_BODY_SIZE + dict_settings["MAX_INCOMING_EMAIL_SIZE"] = settings.MAX_INCOMING_EMAIL_SIZE + # Drive service if base_url := settings.DRIVE_CONFIG.get("base_url"): dict_settings.update( diff --git a/src/backend/core/api/viewsets/inbound/mta.py b/src/backend/core/api/viewsets/inbound/mta.py index 887fdfdb..75a394bd 100644 --- a/src/backend/core/api/viewsets/inbound/mta.py +++ b/src/backend/core/api/viewsets/inbound/mta.py @@ -139,6 +139,23 @@ class InboundMTAViewSet(viewsets.GenericViewSet): status=status.HTTP_400_BAD_REQUEST, ) + # Validate incoming email size + email_size = len(raw_data) + if email_size > settings.MAX_INCOMING_EMAIL_SIZE: + logger.warning( + "Incoming email size (%d bytes) exceeds maximum allowed size (%d bytes)", + email_size, + settings.MAX_INCOMING_EMAIL_SIZE, + ) + return Response( + { + "status": "error", + "detail": f"Incoming email size ({email_size} bytes) exceeds maximum allowed size " + + f"({settings.MAX_INCOMING_EMAIL_SIZE} bytes)", + }, + status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + ) + logger.info( "Raw email received: %d bytes for %s", len(raw_data), diff --git a/src/backend/core/mda/draft.py b/src/backend/core/mda/draft.py index c1e9f27e..2df538f3 100644 --- a/src/backend/core/mda/draft.py +++ b/src/backend/core/mda/draft.py @@ -4,7 +4,9 @@ import logging import uuid from typing import Optional +from django.conf import settings from django.utils import timezone +from django.utils.translation import gettext_lazy as _ import rest_framework as drf @@ -13,6 +15,56 @@ from core import enums, models logger = logging.getLogger(__name__) +def validate_body_size(body_bytes: bytes) -> None: + """Validate the size of the body.""" + if len(body_bytes) > settings.MAX_OUTGOING_BODY_SIZE: + # Use binary (MiB) to match frontend formatting + body_mb = len(body_bytes) / (1024 * 1024) + max_body_mb = settings.MAX_OUTGOING_BODY_SIZE / (1024 * 1024) + + raise drf.exceptions.ValidationError( + { + "draftBody": _( + "Message body size (%(body_size)s MB) exceeds the %(max_size)s MB limit. " + "Please reduce message content." + ) + % { + "body_size": f"{body_mb:.1f}", + "max_size": f"{max_body_mb:.0f}", + } + } + ) + + +def validate_attachment_size(current_total_size: int, new_total_size: int) -> None: + """Validate the size of the attachments.""" + + total_attachment_size = current_total_size + new_total_size + + if total_attachment_size > settings.MAX_OUTGOING_ATTACHMENT_SIZE: + # Use binary (MiB) to match frontend formatting + total_mb = total_attachment_size / (1024 * 1024) + max_mb = settings.MAX_OUTGOING_ATTACHMENT_SIZE / (1024 * 1024) + current_mb = current_total_size / (1024 * 1024) + new_mb = new_total_size / (1024 * 1024) + + raise drf.exceptions.ValidationError( + { + "attachments": _( + "Cannot add attachment(s) (%(new_size)s MB). " + "Total attachments would be %(total_size)s MB, exceeding the %(max_size)s MB limit. " + "Current attachments: %(current_size)s MB." + ) + % { + "new_size": f"{new_mb:.1f}", + "total_size": f"{total_mb:.1f}", + "max_size": f"{max_mb:.0f}", + "current_size": f"{current_mb:.1f}", + } + } + ) + + def create_draft( mailbox: models.Mailbox, subject: str = "", @@ -48,7 +100,7 @@ def create_draft( # Get or create sender contact mailbox_email = f"{mailbox.local_part}@{mailbox.domain.name}" - sender_contact, _ = models.Contact.objects.get_or_create( + sender_contact, _created = models.Contact.objects.get_or_create( email=mailbox_email, mailbox=mailbox, defaults={ @@ -88,6 +140,18 @@ def create_draft( # Validate and get signature if provided signature = mailbox.get_validated_signature(signature_id) + # Validate and prepare draft body + draft_blob = None + if draft_body: + draft_body_bytes = draft_body.encode("utf-8") + + validate_body_size(draft_body_bytes) + + draft_blob = mailbox.create_blob( + content=draft_body_bytes, + content_type="application/json", + ) + # Create message instance message = models.Message( thread=thread, @@ -97,12 +161,7 @@ def create_draft( read_at=timezone.now(), is_draft=True, is_sender=True, - draft_blob=mailbox.create_blob( - content=draft_body.encode("utf-8"), - content_type="application/json", - ) - if draft_body - else None, + draft_blob=draft_blob, signature=signature, ) message.save() @@ -195,7 +254,7 @@ def update_draft( # Create new recipients emails = update_data.get(recipient_type) or [] for email in emails: - contact, _ = models.Contact.objects.get_or_create( + contact, _created = models.Contact.objects.get_or_create( email=email, mailbox=mailbox, defaults={ @@ -220,8 +279,12 @@ def update_draft( except models.Blob.DoesNotExist: pass if update_data["draftBody"]: + draft_body_bytes = update_data["draftBody"].encode("utf-8") + + validate_body_size(draft_body_bytes) + message.draft_blob = mailbox.create_blob( - content=update_data["draftBody"].encode("utf-8"), + content=draft_body_bytes, content_type="application/json", ) updated_fields.append("draft_blob") @@ -292,6 +355,23 @@ def update_draft( to_add = new_attachments - current_attachment_ids to_remove = current_attachment_ids - new_attachments + # Validate total attachment size before adding + if to_add: + # Calculate current total (excluding attachments about to be removed) + current_attachments = message.attachments.exclude(id__in=to_remove) + current_total_size = sum( + att.blob.size for att in current_attachments.select_related("blob") + ) + + # Calculate size of new attachments being added + new_attachments_objs = models.Attachment.objects.filter( + id__in=to_add + ).select_related("blob") + new_total_size = sum(att.blob.size for att in new_attachments_objs) + + # Check if adding these would exceed the attachment limit + validate_attachment_size(current_total_size, new_total_size) + # Remove attachments no longer in the list if to_remove: message.attachments.remove(*to_remove) diff --git a/src/backend/core/mda/outbound.py b/src/backend/core/mda/outbound.py index 55d1a63d..b7a84e12 100644 --- a/src/backend/core/mda/outbound.py +++ b/src/backend/core/mda/outbound.py @@ -7,6 +7,9 @@ from typing import Any, Optional from django.conf import settings from django.core.cache import cache from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +import rest_framework as drf from core import models from core.enums import MessageDeliveryStatusChoices @@ -150,9 +153,12 @@ def prepare_outbound_message( # Add attachments if present if message.attachments.exists(): attachments = [] + total_attachment_size = 0 + for attachment in message.attachments.select_related("blob").all(): # Get the blob data blob = attachment.blob + total_attachment_size += blob.size # Add the attachment to the MIME data attachments.append( @@ -165,6 +171,34 @@ def prepare_outbound_message( } ) + # Validate total attachment size before composing + if total_attachment_size > settings.MAX_OUTGOING_ATTACHMENT_SIZE: + # Use binary MB (MiB) to match frontend formatting + total_mb = total_attachment_size / (1024 * 1024) + max_mb = settings.MAX_OUTGOING_ATTACHMENT_SIZE / (1024 * 1024) + + logger.error( + "Total attachment size for message %s exceeds limit: %d bytes (%.1f MB) > %d bytes (%.0f MB)", + message.id, + total_attachment_size, + total_mb, + settings.MAX_OUTGOING_ATTACHMENT_SIZE, + max_mb, + ) + + raise drf.exceptions.ValidationError( + { + "message": _( + "Total attachment size (%(total_size)s MB) exceeds the %(max_size)s MB limit. " + "Please remove or reduce attachments." + ) + % { + "total_size": f"{total_mb:.1f}", + "max_size": f"{max_mb:.0f}", + } + } + ) + # Add attachments to the MIME data if attachments: mime_data["attachments"] = attachments @@ -180,6 +214,38 @@ def prepare_outbound_message( logger.error("Failed to compose MIME for message %s: %s", message.id, e) return False + # Validate the composed MIME size + mime_size = len(raw_mime) + max_total_size = ( + settings.MAX_OUTGOING_BODY_SIZE + settings.MAX_OUTGOING_ATTACHMENT_SIZE + ) + if mime_size > max_total_size: + # Use binary MB (MiB) to match frontend formatting + mime_mb = mime_size / (1024 * 1024) + max_mb = max_total_size / (1024 * 1024) + + logger.error( + "Composed MIME for message %s exceeds size limit: %d bytes (%.1f MB) > %d bytes (%.0f MB)", + message.id, + mime_size, + mime_mb, + max_total_size, + max_mb, + ) + + raise drf.exceptions.ValidationError( + { + "message": _( + "The composed email (%(mime_size)s MB) exceeds the maximum allowed size of %(max_size)s MB. " + "Please reduce message content or attachments." + ) + % { + "mime_size": f"{mime_mb:.1f}", + "max_size": f"{max_mb:.0f}", + } + } + ) + # Sign the message with DKIM dkim_signature_header: Optional[bytes] = sign_message_dkim( raw_mime_message=raw_mime, maildomain=mailbox_sender.domain diff --git a/src/backend/core/tests/api/test_attachments.py b/src/backend/core/tests/api/test_attachments.py index e0c3ad06..6fe09442 100644 --- a/src/backend/core/tests/api/test_attachments.py +++ b/src/backend/core/tests/api/test_attachments.py @@ -7,6 +7,7 @@ import random import uuid from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import override_settings from django.urls import reverse import pytest @@ -336,3 +337,283 @@ class TestDraftWithAttachments: assert parts[4].get_payload(decode=True).decode() == blob.get_content().decode() assert parts[4].get_content_disposition() == "attachment" assert parts[4].get_filename() == "test_attachment.txt" + + def test_draft_attachment_size_limit_exceeded(self, api_client, user_mailbox): + """Test that adding attachments exceeding the size limit raises ValidationError.""" + client, _ = api_client + + # Set a small attachment size limit for testing (1 KB) + with override_settings(MAX_OUTGOING_ATTACHMENT_SIZE=1024): + # Create a large blob (2 KB) that exceeds the limit + large_content = b"x" * 2048 + blob = user_mailbox.create_blob( + content=large_content, + content_type="text/plain", + ) + + # Try to create a draft with the large attachment + url = reverse("draft-message") + response = client.post( + url, + { + "senderId": str(user_mailbox.id), + "subject": "Draft with large attachment", + "draftBody": json.dumps({"text": "Test"}), + "to": ["recipient@example.com"], + "attachments": [ + { + "partId": "att-1", + "blobId": str(blob.id), + "name": "large_file.txt", + } + ], + }, + format="json", + ) + + # Should fail with validation error + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "attachments" in response.data + + def test_draft_attachment_cumulative_size_limit(self, api_client, user_mailbox): + """Test that cumulative attachment size is validated when adding multiple attachments.""" + client, _ = api_client + + # Set attachment size limit to 2 KB + with override_settings(MAX_OUTGOING_ATTACHMENT_SIZE=2048): + # Create first blob (1 KB) + blob1_content = b"x" * 1024 + blob1 = user_mailbox.create_blob( + content=blob1_content, + content_type="text/plain", + ) + + # Create draft with first attachment + url = reverse("draft-message") + response = client.post( + url, + { + "senderId": str(user_mailbox.id), + "subject": "Draft with attachments", + "draftBody": json.dumps({"text": "Test"}), + "to": ["recipient@example.com"], + "attachments": [ + { + "partId": "att-1", + "blobId": str(blob1.id), + "name": "file1.txt", + } + ], + }, + format="json", + ) + + # Should succeed + assert response.status_code == status.HTTP_201_CREATED + draft_id = response.data["id"] + + # Create second blob (1.5 KB) + blob2_content = b"y" * 1536 + blob2 = user_mailbox.create_blob( + content=blob2_content, + content_type="text/plain", + ) + + # Try to add second attachment (total would be 2.5 KB > 2 KB limit) + url = reverse("draft-message-detail", kwargs={"message_id": draft_id}) + response = client.put( + url, + { + "senderId": str(user_mailbox.id), + "attachments": [ + { + "partId": "att-1", + "blobId": str(blob1.id), + "name": "file1.txt", + }, + { + "partId": "att-2", + "blobId": str(blob2.id), + "name": "file2.txt", + }, + ], + }, + format="json", + ) + + # Should fail with validation error + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "attachments" in response.data + + def test_draft_attachment_within_size_limit(self, api_client, user_mailbox): + """Test that attachments within the size limit are accepted.""" + client, _ = api_client + + # Set attachment size limit to 10 KB + with override_settings(MAX_OUTGOING_ATTACHMENT_SIZE=10240): + # Create two blobs totaling 8 KB (within limit) + blob1_content = b"x" * 4096 + blob1 = user_mailbox.create_blob( + content=blob1_content, + content_type="text/plain", + ) + + blob2_content = b"y" * 4096 + blob2 = user_mailbox.create_blob( + content=blob2_content, + content_type="text/plain", + ) + + # Create draft with both attachments + url = reverse("draft-message") + response = client.post( + url, + { + "senderId": str(user_mailbox.id), + "subject": "Draft with multiple attachments", + "draftBody": json.dumps({"text": "Test"}), + "to": ["recipient@example.com"], + "attachments": [ + { + "partId": "att-1", + "blobId": str(blob1.id), + "name": "file1.txt", + }, + { + "partId": "att-2", + "blobId": str(blob2.id), + "name": "file2.txt", + }, + ], + }, + format="json", + ) + + # Should succeed + assert response.status_code == status.HTTP_201_CREATED + assert len(response.data["attachments"]) == 2 + + def test_draft_replace_attachment_allows_new_within_limit( + self, api_client, user_mailbox + ): + """Test that removing an attachment allows adding a new one within the limit.""" + client, _ = api_client + + # Set attachment size limit to 2 KB + with override_settings(MAX_OUTGOING_ATTACHMENT_SIZE=2048): + # Create first blob (1.5 KB) + blob1_content = b"x" * 1536 + blob1 = user_mailbox.create_blob( + content=blob1_content, + content_type="text/plain", + ) + + # Create draft with first attachment + url = reverse("draft-message") + response = client.post( + url, + { + "senderId": str(user_mailbox.id), + "subject": "Draft", + "draftBody": json.dumps({"text": "Test"}), + "to": ["recipient@example.com"], + "attachments": [ + { + "partId": "att-1", + "blobId": str(blob1.id), + "name": "file1.txt", + } + ], + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + draft_id = response.data["id"] + + # Create second blob (1.5 KB) + blob2_content = b"y" * 1536 + blob2 = user_mailbox.create_blob( + content=blob2_content, + content_type="text/plain", + ) + + # Replace first attachment with second (removing first, adding second) + url = reverse("draft-message-detail", kwargs={"message_id": draft_id}) + response = client.put( + url, + { + "senderId": str(user_mailbox.id), + "attachments": [ + { + "partId": "att-2", + "blobId": str(blob2.id), + "name": "file2.txt", + } + ], + }, + format="json", + ) + + # Should succeed since we're replacing, not adding + assert response.status_code == status.HTTP_200_OK + assert len(response.data["attachments"]) == 1 + assert response.data["attachments"][0]["blobId"] == str(blob2.id) + + def test_send_draft_with_attachments_exceeding_size_limit( + self, api_client, user_mailbox + ): + """Test that sending a draft with attachments exceeding the size limit fails.""" + client, _ = api_client + + # Set a small attachment size limit for testing (1 KB) + with override_settings(MAX_OUTGOING_ATTACHMENT_SIZE=1024): + # Create a large blob (2 KB) that exceeds the limit + large_content = b"x" * 2048 + blob = user_mailbox.create_blob( + content=large_content, + content_type="text/plain", + ) + + # Create attachment + attachment = models.Attachment.objects.create( + mailbox=user_mailbox, name="large_file.txt", blob=blob + ) + + # Create a draft thread and message + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + thread=thread, + mailbox=user_mailbox, + role=ThreadAccessRoleChoices.EDITOR, + ) + + sender_email = f"{user_mailbox.local_part}@{user_mailbox.domain.name}" + sender = factories.ContactFactory( + mailbox=user_mailbox, email=sender_email, name=user_mailbox.local_part + ) + + draft = factories.MessageFactory( + thread=thread, sender=sender, is_draft=True, subject="Test draft" + ) + + # Manually add the attachment (bypassing the validation in draft.py) + draft.attachments.add(attachment) + + # Try to send the draft + send_response = client.post( + reverse("send-message"), + { + "messageId": draft.id, + "textBody": "Test email body", + "htmlBody": "
Test email body
", + "senderId": user_mailbox.id, + }, + format="json", + ) + + # Should fail because the total message size exceeds the limit + assert send_response.status_code == status.HTTP_400_BAD_REQUEST + assert "message" in send_response.data + assert "exceeds the" in str(send_response.data["message"]) + assert "MB limit" in str(send_response.data["message"]) diff --git a/src/backend/core/tests/api/test_config.py b/src/backend/core/tests/api/test_config.py index 6abe66b7..d1ad4fa2 100644 --- a/src/backend/core/tests/api/test_config.py +++ b/src/backend/core/tests/api/test_config.py @@ -24,6 +24,9 @@ pytestmark = pytest.mark.django_db FEATURE_AI_SUMMARY=False, FEATURE_AI_AUTOLABELS=False, DRIVE_CONFIG={"base_url": None}, + MAX_OUTGOING_ATTACHMENT_SIZE=20971520, # 20MB + MAX_OUTGOING_BODY_SIZE=5242880, # 5MB + MAX_INCOMING_EMAIL_SIZE=10485760, # 10MB ) @pytest.mark.parametrize("is_authenticated", [False, True]) def test_api_config(is_authenticated): @@ -45,6 +48,9 @@ def test_api_config(is_authenticated): "FEATURE_AI_AUTOLABELS": False, "SCHEMA_CUSTOM_ATTRIBUTES_USER": {}, "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN": {}, + "MAX_INCOMING_EMAIL_SIZE": 10485760, + "MAX_OUTGOING_ATTACHMENT_SIZE": 20971520, + "MAX_OUTGOING_BODY_SIZE": 5242880, } diff --git a/src/backend/core/tests/api/test_inbound_mta.py b/src/backend/core/tests/api/test_inbound_mta.py index e2f828de..de0dabf2 100644 --- a/src/backend/core/tests/api/test_inbound_mta.py +++ b/src/backend/core/tests/api/test_inbound_mta.py @@ -1,6 +1,6 @@ """Tests for MTA API endpoints.""" -# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-positional-arguments,too-many-lines import datetime import hashlib @@ -336,6 +336,64 @@ class TestMTAInboundEmail: ) assert response.status_code == status.HTTP_401_UNAUTHORIZED + @override_settings(MAX_INCOMING_EMAIL_SIZE=1024) # Set a small limit (1 KB) + def test_incoming_email_size_limit_exceeded( + self, api_client: APIClient, valid_jwt_token + ): + """Test that incoming emails exceeding the size limit are rejected.""" + # Create a large email body (2 KB) that exceeds the limit + large_email_body = ( + b"From: sender@example.com\r\n" + b"To: recipient@example.com\r\n" + b"Subject: Large Email Test\r\n" + b"\r\n" + b"x" * 2048 # Large body content + ) + + recipients = ["recipient@example.com"] + token = valid_jwt_token(large_email_body, {"original_recipients": recipients}) + + response = api_client.post( + "/api/v1.0/inbound/mta/deliver/", + data=large_email_body, + content_type="message/rfc822", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + # Should fail with 413 Request Entity Too Large + assert response.status_code == status.HTTP_413_REQUEST_ENTITY_TOO_LARGE + assert response.json()["status"] == "error" + assert "exceeds maximum allowed size" in response.json()["detail"] + + @override_settings(MAX_INCOMING_EMAIL_SIZE=10240) # Set limit to 10 KB + def test_incoming_email_within_size_limit( + self, api_client: APIClient, valid_jwt_token + ): + """Test that incoming emails within the size limit are accepted.""" + mailbox = factories.MailboxFactory() + email = f"{mailbox.local_part}@{mailbox.domain.name}" + + # Create an email body (5 KB) that is within the limit + email_body = ( + f"From: sender@example.com\r\n" + f"To: {email}\r\n" + f"Subject: Normal Email Test\r\n" + f"\r\n" + ).encode("utf-8") + b"x" * 5000 + + recipients = [email] + token = valid_jwt_token(email_body, {"original_recipients": recipients}) + + response = api_client.post( + "/api/v1.0/inbound/mta/deliver/", + data=email_body, + content_type="message/rfc822", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + # Should succeed + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"status": "ok", "delivered": 1} + @pytest.mark.django_db class TestMTACheckRecipients: diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index 0803b58a..162dd235 100755 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -87,6 +87,30 @@ class Base(Configuration): None, environ_name="OPENSEARCH_CA_CERTS", environ_prefix=None ) + # Upload limits + DATA_UPLOAD_MAX_MEMORY_SIZE = values.PositiveIntegerValue( + 2621440, environ_name="DATA_UPLOAD_MAX_MEMORY_SIZE", environ_prefix=None + ) # Default 2.5MB + + # Email size limits (using binary MiB for actual limits) + MAX_INCOMING_EMAIL_SIZE = values.PositiveIntegerValue( + 10 * 1024 * 1024, # 10 MiB + environ_name="MAX_INCOMING_EMAIL_SIZE", + environ_prefix=None, + ) + + MAX_OUTGOING_ATTACHMENT_SIZE = values.PositiveIntegerValue( + 20 * 1024 * 1024, # 20 MiB + environ_name="MAX_OUTGOING_ATTACHMENT_SIZE", + environ_prefix=None, + ) + + MAX_OUTGOING_BODY_SIZE = values.PositiveIntegerValue( + 5 * 1024 * 1024, # 5 MiB + environ_name="MAX_OUTGOING_BODY_SIZE", + environ_prefix=None, + ) + # Security ALLOWED_HOSTS = values.ListValue([]) SECRET_KEY = values.Value(None) @@ -745,6 +769,19 @@ class Base(Configuration): # pylint: disable=invalid-name def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + + # Ensure Django's upload limit accommodates the larger of the email size limits + # Body and attachments are uploaded separately (body as JSON, attachments as blobs), + # so we take the maximum of individual limits, not their sum. + # Apply 1.4x factor only for incoming emails (which arrive MIME-encoded with ~33% overhead). + # Outgoing attachments are uploaded as raw binary files, so no encoding overhead. + self.DATA_UPLOAD_MAX_MEMORY_SIZE = max( + self.DATA_UPLOAD_MAX_MEMORY_SIZE, + int(self.MAX_INCOMING_EMAIL_SIZE * 1.4), # MIME encoding overhead + self.MAX_OUTGOING_BODY_SIZE, # Raw JSON, minimal overhead + self.MAX_OUTGOING_ATTACHMENT_SIZE, # Raw binary upload, no encoding + ) + if self.ENABLE_PROMETHEUS: self.INSTALLED_APPS += ["django_prometheus"] self.MIDDLEWARE = [ diff --git a/src/frontend/public/locales/common/en-US.json b/src/frontend/public/locales/common/en-US.json index 5b7d6fe7..047d42cb 100644 --- a/src/frontend/public/locales/common/en-US.json +++ b/src/frontend/public/locales/common/en-US.json @@ -51,6 +51,7 @@ "Are you sure you want to delete this template? This action is irreversible!": "Are you sure you want to delete this template? This action is irreversible!", "Are you sure you want to reset the password?": "Are you sure you want to reset the password?", "At least one recipient is required.": "At least one recipient is required.", + "Attachment size limit exceeded": "Attachment size limit exceeded", "Attachments must be less than {{size}}.": "Attachments must be less than {{size}}.", "Authentication failed. Please check your credentials and ensure you have enabled IMAP connections in your account.": "Authentication failed. Please check your credentials and ensure you have enabled IMAP connections in your account.", "Auto-labeling": "Auto-labeling", @@ -60,6 +61,7 @@ "BCC: ": "BCC: ", "Blind copy: ": "Blind copy: ", "Cancel": "Cancel", + "Cannot add attachment(s). Total size would be more than {{maxSize}}.": "Cannot add attachment(s). Total size would be more than {{maxSize}}.", "Check DNS again": "Check DNS again", "Checking DNS records...": "Checking DNS records...", "Close": "Close", @@ -132,6 +134,7 @@ "Failed to refresh summary.": "Failed to refresh summary.", "Failed to save template. Please try again.": "Failed to save template. Please try again.", "Feedback?": "Feedback?", + "File too large": "File too large", "First name": "First name", "First name is required.": "First name is required.", "First, we need some information about your old mailbox": "First, we need some information about your old mailbox", diff --git a/src/frontend/src/features/api/gen/models/config_retrieve200.ts b/src/frontend/src/features/api/gen/models/config_retrieve200.ts index be33b798..1b0f1b81 100644 --- a/src/frontend/src/features/api/gen/models/config_retrieve200.ts +++ b/src/frontend/src/features/api/gen/models/config_retrieve200.ts @@ -20,4 +20,10 @@ export type ConfigRetrieve200 = { readonly DRIVE?: ConfigRetrieve200DRIVE; readonly SCHEMA_CUSTOM_ATTRIBUTES_USER: ConfigRetrieve200SCHEMACUSTOMATTRIBUTESUSER; readonly SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN: ConfigRetrieve200SCHEMACUSTOMATTRIBUTESMAILDOMAIN; + /** Maximum size in bytes for outgoing email attachments */ + readonly MAX_OUTGOING_ATTACHMENT_SIZE: number; + /** Maximum size in bytes for outgoing email body (text + HTML) */ + readonly MAX_OUTGOING_BODY_SIZE: number; + /** Maximum size in bytes for incoming email (including attachments and body) */ + readonly MAX_INCOMING_EMAIL_SIZE: number; }; diff --git a/src/frontend/src/features/forms/components/message-form/attachment-uploader.tsx b/src/frontend/src/features/forms/components/message-form/attachment-uploader.tsx index 32af5ff0..4647d42c 100644 --- a/src/frontend/src/features/forms/components/message-form/attachment-uploader.tsx +++ b/src/frontend/src/features/forms/components/message-form/attachment-uploader.tsx @@ -2,8 +2,9 @@ import { useState, useEffect, MouseEventHandler } from 'react'; import { Attachment } from "@/features/api/gen/models"; import { useBlobUploadCreate } from "@/features/api/gen/blob/blob"; import { useMailboxContext } from '@/features/providers/mailbox'; +import { useConfig } from '@/features/providers/config'; import { useFormContext } from 'react-hook-form'; -import { Button, Field } from '@openfun/cunningham-react'; +import { Button, Field, useModals, VariantType } from '@openfun/cunningham-react'; import { AttachmentItem } from '@/features/layouts/components/thread-view/components/thread-attachment-list/attachment-item'; import { useTranslation } from 'react-i18next'; import { useDropzone } from 'react-dropzone'; @@ -20,8 +21,6 @@ interface AttachmentUploaderProps { disabled?: boolean; } -const MAX_ATTACHMENT_SIZE = 24 * 1024 * 1024; // 25MB - export const AttachmentUploader = ({ initialAttachments = [], disabled = false, @@ -30,20 +29,59 @@ export const AttachmentUploader = ({ const form = useFormContext(); const { t, i18n } = useTranslation(); const { selectedMailbox } = useMailboxContext(); + const config = useConfig(); + const modals = useModals(); + const MAX_ATTACHMENT_SIZE = config.MAX_OUTGOING_ATTACHMENT_SIZE; const [attachments, setAttachments] = useState<(DriveFile | Attachment)[]>(initialAttachments.map((a) => ({ ...a, state: 'idle' }))); const [uploadingQueue, setUploadingQueue] = useState