mirror of
https://github.com/suitenumerique/messages.git
synced 2026-08-17 21:25:41 +02:00
The 0.3.0 parser refuses what 0.1.0 truncated and the composer raises where it silently mangled, so the app has to take a position at each seam: a ComposeError on send becomes a 400 (a property of the draft, not a server fault), an unparseable inbound message is abandoned outright instead of retried for 48h (deterministic failure — logged at error level since abandoned rows are purged after 7 days), and a stored message the stricter parser now refuses is flagged unreadable to the UI rather than rendered blank. Attachment display names move to a single service so serializer, blob download and draft builder synthesize the same name for a nameless MIME part — the bug that started this branch. The inbound retry sweep gains age-based backoff so a dependency outage is not polled harder the longer it lasts. The dev compose mounts the jmap-email working tree over the installed wheel so local edits propagate without a rebuild. Archive reconstruction (PST) composes with allow_smtputf8: an EAI address is legal in an Exchange archive and the reconstructed .eml is stored, never retransmitted, so refusing it would exclude the message from the import. The unquote-message reply patterns bound every whitespace quantifier that could cross newlines: under the m flag an unbounded \s* backtracks once per line start, quadratic in the line count of an attacker-supplied body.
198 lines
7.8 KiB
Python
198 lines
7.8 KiB
Python
"""API ViewSet for sending messages."""
|
|
|
|
import logging
|
|
import uuid
|
|
|
|
from django.db import transaction
|
|
|
|
from drf_spectacular.utils import (
|
|
OpenApiExample,
|
|
extend_schema,
|
|
inline_serializer,
|
|
)
|
|
from jmap_email import ComposeError
|
|
from rest_framework import exceptions as drf_exceptions
|
|
from rest_framework import serializers as drf_serializers
|
|
from rest_framework import status
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
from core import enums, models
|
|
from core.mda.outbound import prepare_outbound_message
|
|
from core.mda.outbound_tasks import send_message_task
|
|
from core.utils import register_task_owner
|
|
|
|
from .. import permissions, serializers
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@extend_schema(
|
|
tags=["messages"],
|
|
request=serializers.SendMessageSerializer,
|
|
responses={
|
|
200: inline_serializer(
|
|
name="SendMessageResponse",
|
|
fields={
|
|
"task_id": drf_serializers.UUIDField(help_text="Task ID for tracking"),
|
|
},
|
|
),
|
|
400: OpenApiExample(
|
|
"Validation Error",
|
|
value={"detail": "Message does not exist or is not a draft."},
|
|
),
|
|
403: OpenApiExample(
|
|
"Permission Error",
|
|
value={"detail": "You do not have permission to send this message."},
|
|
),
|
|
500: OpenApiExample(
|
|
"Prepare Failure",
|
|
value={"detail": "Failed to prepare message for sending."},
|
|
),
|
|
},
|
|
description="""
|
|
Send a previously created draft message.
|
|
|
|
This endpoint finalizes and sends a message previously saved as a draft.
|
|
The message content (subject, body, recipients) should be set when creating/updating the draft.
|
|
Returns a task ID that can be used to track the sending status.
|
|
""",
|
|
examples=[
|
|
OpenApiExample(
|
|
"Send Draft",
|
|
value={
|
|
"messageId": "123e4567-e89b-12d3-a456-426614174000",
|
|
"senderId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
|
|
"textBody": "Hello, world!",
|
|
"htmlBody": "<p>Hello, world!</p>",
|
|
},
|
|
request_only=True,
|
|
),
|
|
OpenApiExample(
|
|
"Send Draft Result",
|
|
value={"task_id": "123e4567-e89b-12d3-a456-426614174000"},
|
|
response_only=True,
|
|
),
|
|
],
|
|
)
|
|
class SendMessageView(APIView):
|
|
"""Send a previously created draft message."""
|
|
|
|
permission_classes = [permissions.IsAllowedToAccess]
|
|
# Note: IsAllowedToAccess checks object permission based on ThreadAccess now.
|
|
# We still need senderId for the sending context.
|
|
|
|
action = "send"
|
|
|
|
def post(self, request):
|
|
"""Send a draft message identified by messageId."""
|
|
serializer = serializers.SendMessageSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
message_id = serializer.validated_data.get("messageId")
|
|
sender_id = serializer.validated_data.get("senderId")
|
|
must_archive = serializer.validated_data.get("archive", False) is True
|
|
|
|
try:
|
|
mailbox_sender = models.Mailbox.objects.get(id=sender_id)
|
|
except models.Mailbox.DoesNotExist as e:
|
|
raise drf_exceptions.NotFound("Sender mailbox not found.") from e
|
|
|
|
# Pre-generate the Celery task id so we can return it to the caller
|
|
# while still deferring the actual dispatch to ``transaction.on_commit``
|
|
# below — the broker must never receive a delivery task for a message
|
|
# whose finalized state is still uncommitted (or rolled back).
|
|
task_id = str(uuid.uuid4())
|
|
|
|
with transaction.atomic():
|
|
# Fetch under a row lock: the draft PUT takes the same lock
|
|
# before rewriting the recipients, so the recipient set and
|
|
# draft state read here (MIME headers, recipient cap) can no
|
|
# longer change between this read and the commit that hands
|
|
# the message to the worker. The prefetches run under the
|
|
# lock too, so they observe the same snapshot.
|
|
try:
|
|
message = (
|
|
models.Message.objects.select_for_update(of=("self",))
|
|
.select_related("sender")
|
|
.prefetch_related(
|
|
"thread__accesses", "recipients__contact", "attachments__blob"
|
|
)
|
|
.get(
|
|
id=message_id,
|
|
is_draft=True,
|
|
thread__accesses__mailbox=mailbox_sender,
|
|
)
|
|
)
|
|
except models.Message.DoesNotExist as e:
|
|
raise drf_exceptions.NotFound(
|
|
"Draft message not found or does not belong to the specified sender mailbox."
|
|
) from e
|
|
|
|
self.check_object_permissions(request, message)
|
|
|
|
# The sender mailbox itself must be authorised to send on this thread.
|
|
# ``IsAllowedToAccess`` only proves the user can SEND through *some*
|
|
# mailbox holding EDITOR access to the thread — not necessarily
|
|
# ``mailbox_sender``. Re-check against the specific ``senderId`` so a
|
|
# VIEWER on the sender mailbox cannot send as it by piggy-backing on a
|
|
# SENDER role they hold on a different mailbox sharing the thread.
|
|
can_send_as_sender = models.ThreadAccess.objects.filter(
|
|
thread=message.thread,
|
|
mailbox=mailbox_sender,
|
|
role=enums.ThreadAccessRoleChoices.EDITOR,
|
|
mailbox__accesses__user=request.user,
|
|
mailbox__accesses__role__in=enums.MAILBOX_ROLES_CAN_SEND,
|
|
).exists()
|
|
if not can_send_as_sender:
|
|
raise drf_exceptions.PermissionDenied(
|
|
"You do not have permission to send as this mailbox."
|
|
)
|
|
|
|
# A recipient the composer cannot put on the wire — a non-ASCII
|
|
# local part needing SMTPUTF8, a malformed addr-spec — is a
|
|
# property of the draft, not a server fault, so it is a 400
|
|
# rather than the 500 an escaping ComposeError would give.
|
|
try:
|
|
prepared = prepare_outbound_message(
|
|
mailbox_sender,
|
|
message,
|
|
request.data.get("textBody"),
|
|
request.data.get("htmlBody"),
|
|
request.user,
|
|
)
|
|
except ComposeError as e:
|
|
logger.info(
|
|
"Send rejected for message %s: cannot compose MIME (%s)",
|
|
message_id,
|
|
type(e).__name__,
|
|
)
|
|
raise drf_exceptions.ValidationError(
|
|
"This message cannot be sent: one of its addresses or "
|
|
"attachments cannot be represented on the wire."
|
|
) from e
|
|
if not prepared:
|
|
raise drf_exceptions.APIException(
|
|
"Failed to prepare message for sending.",
|
|
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
)
|
|
|
|
register_task_owner(task_id, request.user.id)
|
|
|
|
# Dispatch only once the message's finalized state is durable.
|
|
transaction.on_commit(
|
|
lambda: send_message_task.apply_async(
|
|
args=[str(message.id)],
|
|
kwargs={"must_archive": must_archive},
|
|
task_id=task_id,
|
|
)
|
|
)
|
|
|
|
# --- Finalize ---
|
|
# Message state was updated by prepare_outbound_message (e.g.
|
|
# is_draft=False); refresh and update thread stats in the same
|
|
# transaction so the un-drafting and stats commit atomically.
|
|
message.refresh_from_db()
|
|
message.thread.update_stats()
|
|
|
|
return Response({"task_id": task_id}, status=status.HTTP_200_OK)
|