diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index 0502fe294..85f99e91b 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -197,6 +197,16 @@ "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 } }, "required": [ @@ -207,7 +217,9 @@ "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" ] } } @@ -2384,6 +2396,57 @@ } } }, + "/api/v1.0/mailboxes/{mailbox_id}/image-proxy/": { + "get": { + "operationId": "mailboxes_image_proxy_list", + "description": "Proxy an external image through the server.\n\n This endpoint fetches images from external sources and serves them\n through the application to protect user privacy. Requires the\n PROXY_EXTERNAL_IMAGES environment variable to be set to true.\n ", + "parameters": [ + { + "in": "path", + "name": "mailbox_id", + "schema": { + "type": "string" + }, + "description": "ID of the mailbox", + "required": true + }, + { + "in": "query", + "name": "url", + "schema": { + "type": "string" + }, + "description": "The external image URL to proxy", + "required": true + } + ], + "tags": [ + "mailboxes" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "description": "Image content" + }, + "400": { + "description": "Invalid request" + }, + "403": { + "description": "Forbidden" + }, + "413": { + "description": "Image too large" + }, + "502": { + "description": "Failed to fetch external image" + } + } + } + }, "/api/v1.0/mailboxes/{mailbox_id}/message-templates/": { "get": { "operationId": "mailboxes_message_templates_list", diff --git a/src/backend/core/api/viewsets/config.py b/src/backend/core/api/viewsets/config.py index a33402d19..960d372b4 100644 --- a/src/backend/core/api/viewsets/config.py +++ b/src/backend/core/api/viewsets/config.py @@ -62,6 +62,16 @@ 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, + }, }, "required": [ "ENVIRONMENT", @@ -72,6 +82,8 @@ class ConfigView(drf.views.APIView): "FEATURE_AI_AUTOLABELS", "SCHEMA_CUSTOM_ATTRIBUTES_USER", "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN", + "MAX_OUTGOING_ATTACHMENT_SIZE", + "MAX_OUTGOING_BODY_SIZE", ], }, ) @@ -100,6 +112,10 @@ def get(self, request): 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 + # 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 887fdfdb8..e65e52427 100644 --- a/src/backend/core/api/viewsets/inbound/mta.py +++ b/src/backend/core/api/viewsets/inbound/mta.py @@ -139,6 +139,21 @@ def deliver(self, request): status=status.HTTP_400_BAD_REQUEST, ) + # Validate incoming email size against configured limit + if len(raw_data) > settings.MAX_INCOMING_EMAIL_SIZE: + logger.error( + "Incoming email exceeds size limit: %d bytes (limit: %d bytes)", + len(raw_data), + settings.MAX_INCOMING_EMAIL_SIZE, + ) + return Response( + { + "status": "error", + "detail": f"Email size exceeds maximum allowed size of {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/api/viewsets/send.py b/src/backend/core/api/viewsets/send.py index 55e98e645..2e193fd6b 100644 --- a/src/backend/core/api/viewsets/send.py +++ b/src/backend/core/api/viewsets/send.py @@ -106,18 +106,14 @@ def post(self, request): self.check_object_permissions(request, message) - prepared = prepare_outbound_message( + # Prepare the message (raises ValidationError if size limits exceeded) + prepare_outbound_message( mailbox_sender, message, request.data.get("textBody"), request.data.get("htmlBody"), request.user, ) - if not prepared: - raise drf_exceptions.APIException( - "Failed to prepare message for sending.", - code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) # Launch async task for sending the message task = send_message_task.delay(str(message.id), must_archive=must_archive) diff --git a/src/backend/core/mda/draft.py b/src/backend/core/mda/draft.py index c1e9f27eb..4afee097b 100644 --- a/src/backend/core/mda/draft.py +++ b/src/backend/core/mda/draft.py @@ -4,7 +4,9 @@ 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 @@ -48,7 +50,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={ @@ -195,7 +197,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 +222,28 @@ def update_draft( except models.Blob.DoesNotExist: pass if update_data["draftBody"]: + draft_body_bytes = update_data["draftBody"].encode("utf-8") + + # Validate body size before creating blob + if len(draft_body_bytes) > settings.MAX_OUTGOING_BODY_SIZE: + body_mb = len(draft_body_bytes) / 1_000_000 + max_body_mb = settings.MAX_OUTGOING_BODY_SIZE / 1_000_000 + + 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}", + } + } + ) + 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 +314,45 @@ 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 + total_attachment_size = current_total_size + new_total_size + if total_attachment_size > settings.MAX_OUTGOING_ATTACHMENT_SIZE: + # Convert to MB for better readability + total_mb = total_attachment_size / 1_000_000 + max_mb = settings.MAX_OUTGOING_ATTACHMENT_SIZE / 1_000_000 + current_mb = current_total_size / 1_000_000 + new_mb = new_total_size / 1_000_000 + + 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}", + } + } + ) + # 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 55d1a63dc..b446d5204 100644 --- a/src/backend/core/mda/outbound.py +++ b/src/backend/core/mda/outbound.py @@ -7,6 +7,9 @@ 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 @@ -147,12 +150,48 @@ def prepare_outbound_message( "message_id": message.mime_id, } + # Calculate body size (raw text/HTML bytes) before MIME encoding + body_size = 0 + if text_body: + body_size += len(text_body.encode('utf-8')) + if html_body: + body_size += len(html_body.encode('utf-8')) + + # Validate body size against limit + if body_size > settings.MAX_OUTGOING_BODY_SIZE: + body_mb = body_size / 1_000_000 + max_body_mb = settings.MAX_OUTGOING_BODY_SIZE / 1_000_000 + + logger.error( + "Message body size for message %s exceeds limit: %d bytes (limit: %d bytes)", + message.id, + body_size, + settings.MAX_OUTGOING_BODY_SIZE, + ) + raise drf.exceptions.ValidationError( + { + "message": _( + "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}", + } + } + ) + + # Calculate total content size (attachments + body) + total_content_size = body_size + # Add attachments if present if message.attachments.exists(): attachments = [] + for attachment in message.attachments.select_related("blob").all(): # Get the blob data blob = attachment.blob + total_content_size += blob.size # Add the attachment to the MIME data attachments.append( @@ -169,6 +208,31 @@ def prepare_outbound_message( if attachments: mime_data["attachments"] = attachments + # Validate total attachment size (before MIME encoding) + attachment_size = total_content_size - body_size + if attachment_size > settings.MAX_OUTGOING_ATTACHMENT_SIZE: + attachment_mb = attachment_size / 1_000_000 + max_mb = settings.MAX_OUTGOING_ATTACHMENT_SIZE / 1_000_000 + + logger.error( + "Total attachment size for message %s exceeds limit: %d bytes (limit: %d bytes)", + message.id, + attachment_size, + settings.MAX_OUTGOING_ATTACHMENT_SIZE, + ) + raise drf.exceptions.ValidationError( + { + "message": _( + "Total attachment size (%(total_size)s MB) exceeds the %(max_size)s MB limit. " + "Please reduce attachments." + ) + % { + "total_size": f"{attachment_mb:.1f}", + "max_size": f"{max_mb:.0f}", + } + } + ) + # Assemble the raw mime message try: raw_mime = compose_email( @@ -178,7 +242,9 @@ def prepare_outbound_message( ) except Exception as e: logger.error("Failed to compose MIME for message %s: %s", message.id, e) - return False + raise drf.exceptions.APIException( + _("Failed to compose email message.") + ) from e # Sign the message with DKIM dkim_signature_header: Optional[bytes] = sign_message_dkim( diff --git a/src/backend/core/tests/api/test_attachments.py b/src/backend/core/tests/api/test_attachments.py index e0c3ad063..9eb945188 100644 --- a/src/backend/core/tests/api/test_attachments.py +++ b/src/backend/core/tests/api/test_attachments.py @@ -336,3 +336,293 @@ def test_add_attachment_to_existing_draft_and_send( 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.""" + from django.test import override_settings + + 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.""" + from django.test import override_settings + + 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.""" + from django.test import override_settings + + 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.""" + from django.test import override_settings + + 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.""" + from django.test import override_settings + + 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_inbound_mta.py b/src/backend/core/tests/api/test_inbound_mta.py index e2f828def..61c559c26 100644 --- a/src/backend/core/tests/api/test_inbound_mta.py +++ b/src/backend/core/tests/api/test_inbound_mta.py @@ -946,3 +946,64 @@ def test_single_email_to_multiple_mailboxes( # Verify they are different message instances in different threads assert msg1.id != msg2.id assert msg1.thread.id != msg2.thread.id + + def test_incoming_email_size_limit_exceeded( + self, api_client: APIClient, valid_jwt_token + ): + """Test that incoming emails exceeding the size limit are rejected.""" + # Set a small incoming email size limit for testing (1 KB) + with override_settings(MAX_INCOMING_EMAIL_SIZE=1024): + # 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"] + + 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}" + + # Set a reasonable incoming email size limit for testing (10 KB) + with override_settings(MAX_INCOMING_EMAIL_SIZE=10240): + # 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} diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index e56a9f29c..8851f06a3 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, can be overridden via environment variable + + # Email size limits + MAX_INCOMING_EMAIL_SIZE = values.PositiveIntegerValue( + 10485760, # Default 10MB + environ_name="MAX_INCOMING_EMAIL_SIZE", + environ_prefix=None, + ) + + MAX_OUTGOING_ATTACHMENT_SIZE = values.PositiveIntegerValue( + 20971520, # Default 20MB + environ_name="MAX_OUTGOING_ATTACHMENT_SIZE", + environ_prefix=None, + ) + + MAX_OUTGOING_BODY_SIZE = values.PositiveIntegerValue( + 5242880, # Default 5MB + environ_name="MAX_OUTGOING_BODY_SIZE", + environ_prefix=None, + ) + # Security ALLOWED_HOSTS = values.ListValue([]) SECRET_KEY = values.Value(None) @@ -744,6 +768,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/package-lock.json b/src/frontend/package-lock.json index fbdf5104d..f67244423 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -129,7 +129,6 @@ "version": "8.17.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -491,7 +490,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -513,7 +511,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -531,7 +528,6 @@ "node_modules/@dnd-kit/core": { "version": "6.3.1", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1286,7 +1282,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2495,7 +2490,6 @@ "node_modules/@mantine/hooks": { "version": "7.17.7", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -6663,7 +6657,6 @@ "node_modules/@shikijs/types": { "version": "3.2.1", "license": "MIT", - "peer": true, "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" @@ -6825,7 +6818,6 @@ "version": "8.17.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6904,7 +6896,6 @@ "version": "8.17.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7027,7 +7018,6 @@ "version": "8.17.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7111,7 +7101,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" @@ -7379,7 +7368,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.81.5.tgz", "integrity": "sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.81.5" }, @@ -7440,7 +7428,6 @@ "node_modules/@tiptap/core": { "version": "2.12.0", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -7632,7 +7619,6 @@ "node_modules/@tiptap/pm": { "version": "2.12.0", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -7815,7 +7801,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.34.tgz", "integrity": "sha512-8Y6E5WUupYy1Dd0II32BsWAx5MWdcnRd8L84Oys3veg1YrYtNtzgO4CFhiBg6MDSjk7Ay36HYOnU7/tuOzIzcw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -7825,7 +7810,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -7835,7 +7819,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -7899,7 +7882,6 @@ "version": "8.32.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.32.0", "@typescript-eslint/types": "8.32.0", @@ -8458,7 +8440,6 @@ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "fflate": "^0.8.2", @@ -8517,7 +8498,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10083,7 +10063,6 @@ "integrity": "sha512-iN/SiPxmQu6EVkf+m1qpBxzUhE12YqFLOSySuOyVLJLEF9nzTf+h/1AJYc1JWzCnktggeNrjvQGLngDzXirU6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -10246,7 +10225,6 @@ "version": "2.31.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -11551,7 +11529,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -12517,7 +12494,6 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -12630,7 +12606,6 @@ "version": "1.4.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.16.0" } @@ -14953,7 +14928,6 @@ "node_modules/prosemirror-model": { "version": "1.25.1", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -14977,7 +14951,6 @@ "node_modules/prosemirror-state": { "version": "1.4.3", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -15011,7 +14984,6 @@ "node_modules/prosemirror-transform": { "version": "1.10.4", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.21.0" } @@ -15019,7 +14991,6 @@ "node_modules/prosemirror-view": { "version": "1.39.2", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -15065,7 +15036,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -15238,7 +15208,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -15370,7 +15339,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.59.0.tgz", "integrity": "sha512-kmkek2/8grqarTJExFNjy+RXDIP8yM+QTl3QL6m6Q8b2bih4ltmiXxH7T9n+yXNK477xPh5yZT/6vD8sYGzJTA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -16102,7 +16070,6 @@ "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz", "integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==", "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -17027,7 +16994,6 @@ "version": "4.0.2", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17381,7 +17347,6 @@ "node_modules/typescript": { "version": "5.8.3", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17728,7 +17693,6 @@ "version": "6.3.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -17838,7 +17802,6 @@ "version": "4.0.2", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17879,7 +17842,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -18284,7 +18246,6 @@ "node_modules/y-protocols": { "version": "1.0.6", "license": "MIT", - "peer": true, "dependencies": { "lib0": "^0.2.85" }, @@ -18365,7 +18326,6 @@ "node_modules/yjs": { "version": "13.6.26", "license": "MIT", - "peer": true, "dependencies": { "lib0": "^0.2.99" }, diff --git a/src/frontend/public/locales/common/en-US.json b/src/frontend/public/locales/common/en-US.json index 5d0f419eb..142b993cf 100644 --- a/src/frontend/public/locales/common/en-US.json +++ b/src/frontend/public/locales/common/en-US.json @@ -52,6 +52,7 @@ "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.", "Attachments must be less than {{size}}.": "Attachments must be less than {{size}}.", + "Cannot add attachment(s) ({{newSize}}). Total would be {{totalSize}}, exceeding the {{maxSize}} limit. Current attachments: {{currentSize}}.": "Cannot add attachment(s) ({{newSize}}). Total would be {{totalSize}}, exceeding the {{maxSize}} limit. Current attachments: {{currentSize}}.", "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", "Automatically create mailboxes according to OIDC emails": "Automatically create mailboxes according to OIDC emails", diff --git a/src/frontend/src/features/api/gen/mailboxes/mailboxes.ts b/src/frontend/src/features/api/gen/mailboxes/mailboxes.ts index 354ee3972..b175369fb 100644 --- a/src/frontend/src/features/api/gen/mailboxes/mailboxes.ts +++ b/src/frontend/src/features/api/gen/mailboxes/mailboxes.ts @@ -24,6 +24,7 @@ import type { import type { Mailbox, MailboxLight, + MailboxesImageProxyListParams, MailboxesMessageTemplatesAvailableListParams, MailboxesMessageTemplatesListParams, MailboxesMessageTemplatesRenderRetrieve200, @@ -183,6 +184,234 @@ export function useMailboxesList< return query; } +/** + * Proxy an external image through the server. + + This endpoint fetches images from external sources and serves them + through the application to protect user privacy. Requires the + PROXY_EXTERNAL_IMAGES environment variable to be set to true. + + */ +export type mailboxesImageProxyListResponse200 = { + data: void; + status: 200; +}; + +export type mailboxesImageProxyListResponse400 = { + data: void; + status: 400; +}; + +export type mailboxesImageProxyListResponseComposite = + | mailboxesImageProxyListResponse200 + | mailboxesImageProxyListResponse400; + +export type mailboxesImageProxyListResponse = + mailboxesImageProxyListResponseComposite & { + headers: Headers; + }; + +export const getMailboxesImageProxyListUrl = ( + mailboxId: string, + params: MailboxesImageProxyListParams, +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1.0/mailboxes/${mailboxId}/image-proxy/?${stringifiedParams}` + : `/api/v1.0/mailboxes/${mailboxId}/image-proxy/`; +}; + +export const mailboxesImageProxyList = async ( + mailboxId: string, + params: MailboxesImageProxyListParams, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMailboxesImageProxyListUrl(mailboxId, params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getMailboxesImageProxyListQueryKey = ( + mailboxId: string, + params: MailboxesImageProxyListParams, +) => { + return [ + `/api/v1.0/mailboxes/${mailboxId}/image-proxy/`, + ...(params ? [params] : []), + ] as const; +}; + +export const getMailboxesImageProxyListQueryOptions = < + TData = Awaited>, + TError = void, +>( + mailboxId: string, + params: MailboxesImageProxyListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getMailboxesImageProxyListQueryKey(mailboxId, params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + mailboxesImageProxyList(mailboxId, params, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!mailboxId, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type MailboxesImageProxyListQueryResult = NonNullable< + Awaited> +>; +export type MailboxesImageProxyListQueryError = void; + +export function useMailboxesImageProxyList< + TData = Awaited>, + TError = void, +>( + mailboxId: string, + params: MailboxesImageProxyListParams, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useMailboxesImageProxyList< + TData = Awaited>, + TError = void, +>( + mailboxId: string, + params: MailboxesImageProxyListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useMailboxesImageProxyList< + TData = Awaited>, + TError = void, +>( + mailboxId: string, + params: MailboxesImageProxyListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useMailboxesImageProxyList< + TData = Awaited>, + TError = void, +>( + mailboxId: string, + params: MailboxesImageProxyListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getMailboxesImageProxyListQueryOptions( + mailboxId, + params, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + /** * List message templates for a 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 be33b7988..44a4ef926 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,8 @@ 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; }; diff --git a/src/frontend/src/features/api/gen/models/index.ts b/src/frontend/src/features/api/gen/models/index.ts index 3f7e1c526..d126f5e0c 100644 --- a/src/frontend/src/features/api/gen/models/index.ts +++ b/src/frontend/src/features/api/gen/models/index.ts @@ -65,6 +65,7 @@ export * from "./mailbox_admin_update_metadata_request"; export * from "./mailbox_light"; export * from "./mailbox_role_choices"; export * from "./mailboxes_accesses_list_params"; +export * from "./mailboxes_image_proxy_list_params"; export * from "./mailboxes_message_templates_available_list_params"; export * from "./mailboxes_message_templates_available_list_type"; export * from "./mailboxes_message_templates_list_params"; diff --git a/src/frontend/src/features/api/gen/models/mailboxes_image_proxy_list_params.ts b/src/frontend/src/features/api/gen/models/mailboxes_image_proxy_list_params.ts new file mode 100644 index 000000000..2e88f7a88 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mailboxes_image_proxy_list_params.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.10.0 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export type MailboxesImageProxyListParams = { + /** + * The external image URL to proxy + */ + url: string; +}; 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 455ef4e5d..4647d42c0 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([]); const [failedQueue, setFailedQueue] = useState([]); const { mutateAsync: uploadBlob } = useBlobUploadCreate(); const debouncedOnChange = useDebounceCallback(onChange, 1000); + + // Calculate current total size of attachments and pending uploads + const attachmentsSize = attachments.reduce((acc, attachment) => acc + attachment.size, 0); + const uploadingQueueSize = uploadingQueue.reduce((acc, file) => acc + file.size, 0); + const currentTotalSize = attachmentsSize + uploadingQueueSize; + const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({ onDrop: async (acceptedFiles) => { + // Check cumulative size before uploading + const newFilesSize = acceptedFiles.reduce((acc, file) => acc + file.size, 0); + const totalSize = currentTotalSize + newFilesSize; + + if (totalSize > MAX_ATTACHMENT_SIZE) { + modals.messageModal({ + title: {t("Attachment size limit exceeded")}, + children: {t("Cannot add attachment(s). Total size would be more than {{maxSize}}.", { + maxSize: AttachmentHelper.getFormattedSize(MAX_ATTACHMENT_SIZE, i18n.resolvedLanguage) + })}, + messageType: VariantType.INFO, + }); + return; + } await Promise.all(acceptedFiles.map(uploadFile)); }, disabled, maxSize: MAX_ATTACHMENT_SIZE, }); - const isFileTooLarge = fileRejections.some(rejection => rejection.errors[0].code === 'file-too-large'); + // Show notification for files rejected by dropzone (too large individually) + useEffect(() => { + if (fileRejections.length > 0) { + const tooLargeFiles = fileRejections.filter(rejection => + rejection.errors.some(err => err.code === 'file-too-large') + ); + if (tooLargeFiles.length > 0) { + modals.messageModal({ + title: {t("File too large")}, + children: {t("The file is too large. It must be less than {{size}}.", { + size: AttachmentHelper.getFormattedSize(MAX_ATTACHMENT_SIZE, i18n.resolvedLanguage) + })}, + messageType: VariantType.INFO, + }); + } + } + }, [fileRejections, t, i18n.resolvedLanguage, MAX_ATTACHMENT_SIZE, modals]); const addToUploadingQueue = (attachments: File[]) => setUploadingQueue(queue => [...queue, ...attachments]); const addToFailedQueue = (attachments: File[]) => setFailedQueue(queue => [...queue, ...attachments]); @@ -106,8 +144,23 @@ export const AttachmentUploader = ({ } } - const handleDriveAttachmentPick = (attachments: DriveFile[]) => { - appendToAttachments(attachments); + const handleDriveAttachmentPick = (newAttachments: DriveFile[]) => { + // Check cumulative size before adding drive attachments + const newAttachmentsSize = newAttachments.reduce((acc, attachment) => acc + attachment.size, 0); + const newTotalSize = currentTotalSize + newAttachmentsSize; + + if (newTotalSize > MAX_ATTACHMENT_SIZE) { + modals.messageModal({ + title: {t("Attachment size limit exceeded")}, + children: {t("Cannot add attachment(s). Total size would be more than {{maxSize}}.", { + maxSize: AttachmentHelper.getFormattedSize(MAX_ATTACHMENT_SIZE, i18n.resolvedLanguage) + })}, + messageType: VariantType.INFO, + }); + return; + } + + appendToAttachments(newAttachments); } /** @@ -127,10 +180,13 @@ export const AttachmentUploader = ({ } }, [attachments]); + // Show informational text about the limit + const infoText = t("Attachments must be less than {{size}}.", { size: AttachmentHelper.getFormattedSize(MAX_ATTACHMENT_SIZE, i18n.resolvedLanguage) }); + return (
diff --git a/src/frontend/src/features/providers/config.tsx b/src/frontend/src/features/providers/config.tsx index b07975555..6d473e84f 100644 --- a/src/frontend/src/features/providers/config.tsx +++ b/src/frontend/src/features/providers/config.tsx @@ -11,6 +11,8 @@ const DEFAULT_CONFIG: ConfigRetrieve200 = { FEATURE_AI_AUTOLABELS: false, SCHEMA_CUSTOM_ATTRIBUTES_USER: {}, SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN: {}, + MAX_OUTGOING_ATTACHMENT_SIZE: 20971520, // 20MB default + MAX_OUTGOING_BODY_SIZE: 5242880, // 5MB default } const ConfigContext = createContext(DEFAULT_CONFIG) diff --git a/src/frontend/src/features/utils/attachment-helper/index.test.ts b/src/frontend/src/features/utils/attachment-helper/index.test.ts index a26597751..c0800c11f 100644 --- a/src/frontend/src/features/utils/attachment-helper/index.test.ts +++ b/src/frontend/src/features/utils/attachment-helper/index.test.ts @@ -111,24 +111,22 @@ describe("AttachmentHelper", () => { describe("getFormattedSize", () => { it("should format size in bytes", () => { - expect(AttachmentHelper.getFormattedSize(500)).toBe("500B"); + expect(AttachmentHelper.getFormattedSize(500)).toBe("500 byte"); }); - it("should format size in kilobytes", () => { - expect(AttachmentHelper.getFormattedSize(1500)).toBe("1.5KB"); + it("should format size in kilobytes (binary)", () => { + expect(AttachmentHelper.getFormattedSize(1536)).toBe("1.5 kB"); + expect(AttachmentHelper.getFormattedSize(1024)).toBe("1 kB"); }); - it("should format size in megabytes", () => { - expect(AttachmentHelper.getFormattedSize(1500000)).toBe("1.5MB"); + it("should format size in megabytes (binary)", () => { + expect(AttachmentHelper.getFormattedSize(5242880)).toBe("5 MB"); + expect(AttachmentHelper.getFormattedSize(1572864)).toBe("1.5 MB"); }); - it("should format size in gigabytes", () => { - expect(AttachmentHelper.getFormattedSize(1500000000)).toBe("1.5BB"); - }); - - it("should use specified language for formatting", () => { - // French uses comma as decimal separator - expect(AttachmentHelper.getFormattedSize(1500, 'fr')).toBe("1,5 ko"); + it("should format exact megabyte limits without decimals", () => { + expect(AttachmentHelper.getFormattedSize(10485760)).toBe("10 MB"); + expect(AttachmentHelper.getFormattedSize(20971520)).toBe("20 MB"); }); }); @@ -139,11 +137,11 @@ describe("AttachmentHelper", () => { { size: 2000 } as Attachment, { size: 3000 } as Attachment ]; - expect(AttachmentHelper.getFormattedTotalSize(attachments)).toBe("6KB"); + expect(AttachmentHelper.getFormattedTotalSize(attachments)).toBe("5.9 kB"); }); it("should handle empty array of attachments", () => { - expect(AttachmentHelper.getFormattedTotalSize([])).toBe("0B"); + expect(AttachmentHelper.getFormattedTotalSize([])).toBe("0 byte"); }); it("should use specified language for formatting", () => { @@ -151,7 +149,7 @@ describe("AttachmentHelper", () => { { size: 1500 } as Attachment, { size: 2500 } as Attachment ]; - expect(AttachmentHelper.getFormattedTotalSize(attachments, 'fr')).toBe("4 ko"); + expect(AttachmentHelper.getFormattedTotalSize(attachments, 'fr')).toBe("3,9 ko"); }); }); }); diff --git a/src/frontend/src/features/utils/attachment-helper/index.ts b/src/frontend/src/features/utils/attachment-helper/index.ts index 48e152453..36b3f3d15 100644 --- a/src/frontend/src/features/utils/attachment-helper/index.ts +++ b/src/frontend/src/features/utils/attachment-helper/index.ts @@ -67,14 +67,29 @@ export class AttachmentHelper { } static getFormattedSize(size: number, language: string = 'en') { - const formatter = Intl.NumberFormat(language, { - notation: "compact", - style: "unit", - unit: "byte", - unitDisplay: "narrow", - }); + // Use binary (1024) conversion for user-friendly display + // This matches file system conventions and gives clean numbers + const formatter = (value: number, unit: string) => { + return new Intl.NumberFormat(language, { + style: 'unit', + unit: unit, + maximumFractionDigits: 1 + }).format(value); + }; - return formatter.format(size); + const KB = 1024; + const MB = 1024 * 1024; + const GB = 1024 * 1024 * 1024; + + if (size < KB) { + return formatter(size, 'byte'); + } else if (size < MB) { + return formatter(size / KB, 'kilobyte'); + } else if (size < GB) { + return formatter(size / MB, 'megabyte'); + } else { + return formatter(size / GB, 'gigabyte'); + } } static getFormattedTotalSize(attachments: readonly (DriveFile | Attachment | File)[], language: string = 'en') {