mirror of
https://github.com/suitenumerique/messages.git
synced 2026-08-17 21:25:41 +02:00
✨(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:
co-authored by
Riël Notermans
parent
e89c2c179a
commit
670ba5699a
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user