(attachments) configurable size limits (#430)

Adds new config vars for incoming & outgoing attachment sizes, validated at draft stage
and when receiving emails.

---------

Co-authored-by: Riël Notermans <riel@mosa.cloud>
This commit is contained in:
Sylvain Zimmer
2025-11-21 13:55:26 +01:00
committed by GitHub
co-authored by Riël Notermans
parent e89c2c179a
commit 670ba5699a
15 changed files with 713 additions and 35 deletions
+19 -1
View File
@@ -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"
]
}
}
+25
View File
@@ -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(
@@ -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),
+89 -9
View File
@@ -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)
+66
View File
@@ -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
@@ -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": "<p>Test email body</p>",
"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"])
@@ -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,
}
+59 -1
View File
@@ -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:
+37
View File
@@ -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 = [
@@ -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",
@@ -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;
};
@@ -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<File[]>([]);
const [failedQueue, setFailedQueue] = useState<File[]>([]);
const { mutateAsync: uploadBlob } = useBlobUploadCreate();
const debouncedOnChange = useDebounceCallback(onChange, 100);
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: <span className="c__modal__text--centered">{t("Attachment size limit exceeded")}</span>,
children: <span className="c__modal__text--centered">{t("Cannot add attachment(s). Total size would be more than {{maxSize}}.", {
maxSize: AttachmentHelper.getFormattedSize(MAX_ATTACHMENT_SIZE, i18n.resolvedLanguage)
})}</span>,
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: <span className="c__modal__text--centered">{t("File too large")}</span>,
children: <span className="c__modal__text--centered">{t("The file is too large. It must be less than {{size}}.", {
size: AttachmentHelper.getFormattedSize(MAX_ATTACHMENT_SIZE, i18n.resolvedLanguage)
})}</span>,
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: <span className="c__modal__text--centered">{t("Attachment size limit exceeded")}</span>,
children: <span className="c__modal__text--centered">{t("Cannot add attachment(s). Total size would be more than {{maxSize}}.", {
maxSize: AttachmentHelper.getFormattedSize(MAX_ATTACHMENT_SIZE, i18n.resolvedLanguage)
})}</span>,
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 (
<Field
text={isFileTooLarge ? t("The file is too large. It must be less than {{size}}.", { size: AttachmentHelper.getFormattedSize(MAX_ATTACHMENT_SIZE, i18n.language) }) : t("Attachments must be less than {{size}}.", { size: AttachmentHelper.getFormattedSize(MAX_ATTACHMENT_SIZE, i18n.language) })}
state={isFileTooLarge ? 'error' : 'default'}
text={infoText}
state='default'
fullWidth
>
<section className={clsx("attachment-uploader", { 'attachment-uploader--disabled': disabled })} {...getRootProps()} onClick={handleClick}>
@@ -11,6 +11,9 @@ const DEFAULT_CONFIG: ConfigRetrieve200 = {
FEATURE_AI_AUTOLABELS: false,
SCHEMA_CUSTOM_ATTRIBUTES_USER: {},
SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN: {},
MAX_OUTGOING_ATTACHMENT_SIZE: 0,
MAX_OUTGOING_BODY_SIZE: 0,
MAX_INCOMING_EMAIL_SIZE: 0,
}
const ConfigContext = createContext<ConfigRetrieve200>(DEFAULT_CONFIG)
@@ -115,31 +115,31 @@ describe("AttachmentHelper", () => {
});
it("should format size in kilobytes", () => {
expect(AttachmentHelper.getFormattedSize(1500)).toBe("1.5KB");
expect(AttachmentHelper.getFormattedSize(1500)).toBe("1.5kB");
});
it("should format size in megabytes", () => {
expect(AttachmentHelper.getFormattedSize(1500000)).toBe("1.5MB");
expect(AttachmentHelper.getFormattedSize(1500*1024)).toBe("1.5MB");
});
it("should format size in gigabytes", () => {
expect(AttachmentHelper.getFormattedSize(1500000000)).toBe("1.5BB");
expect(AttachmentHelper.getFormattedSize(1500*1024*1024)).toBe("1.5GB");
});
it("should use specified language for formatting", () => {
// French uses comma as decimal separator
expect(AttachmentHelper.getFormattedSize(1500, 'fr')).toBe("1,5 ko");
expect(AttachmentHelper.getFormattedSize(1500, 'fr')).toBe("1,5ko");
});
});
describe("getFormattedTotalSize", () => {
it("should calculate total size of multiple attachments", () => {
const attachments = [
{ size: 1000 } as Attachment,
{ size: 2000 } as Attachment,
{ size: 3000 } as Attachment
{ size: 1024 } as Attachment,
{ size: 2*1024 } as Attachment,
{ size: 3*1024 } as Attachment
];
expect(AttachmentHelper.getFormattedTotalSize(attachments)).toBe("6KB");
expect(AttachmentHelper.getFormattedTotalSize(attachments)).toBe("6kB");
});
it("should handle empty array of attachments", () => {
@@ -148,10 +148,10 @@ describe("AttachmentHelper", () => {
it("should use specified language for formatting", () => {
const attachments = [
{ size: 1500 } as Attachment,
{ size: 2500 } as Attachment
{ size: 1*1024 } as Attachment,
{ size: 3*1024 } as Attachment
];
expect(AttachmentHelper.getFormattedTotalSize(attachments, 'fr')).toBe("4 ko");
expect(AttachmentHelper.getFormattedTotalSize(attachments, 'fr')).toBe("4ko");
});
});
});
@@ -67,14 +67,36 @@ export class AttachmentHelper {
}
static getFormattedSize(size: number, language: string = 'en') {
const formatter = Intl.NumberFormat(language, {
// Determine the appropriate unit using binary (1024) calculation
const units: Array<{ divisor: number; unit: Intl.NumberFormatOptions['unit'] }> = [
{ divisor: 1024 ** 4, unit: 'terabyte' },
{ divisor: 1024 ** 3, unit: 'gigabyte' },
{ divisor: 1024 ** 2, unit: 'megabyte' },
{ divisor: 1024, unit: 'kilobyte' },
{ divisor: 1, unit: 'byte' },
];
for (const { divisor, unit } of units) {
if (size >= divisor) {
const value = size / divisor;
const formatter = new Intl.NumberFormat(language, {
notation: "compact",
style: "unit",
unit: unit,
unitDisplay: "narrow",
});
return formatter.format(value);
}
}
// Fallback for 0 bytes
const formatter = new Intl.NumberFormat(language, {
notation: "compact",
style: "unit",
unit: "byte",
unitDisplay: "narrow",
});
return formatter.format(size);
});
return formatter.format(size);
}
static getFormattedTotalSize(attachments: readonly (DriveFile | Attachment | File)[], language: string = 'en') {