From bd779a15d514dcedcdace8747dd1d0a57574a5e1 Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Tue, 26 May 2026 10:52:00 +0200 Subject: [PATCH 01/52] =?UTF-8?q?=E2=9C=A8(calendar)=20add=20link=20to=20a?= =?UTF-8?q?=20CalDAV=20instance=20to=20accept=20events=20directly=20(#584)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CalDAV-backed mailbox calendar actions: RSVP (Accept/Decline/Maybe), Add to calendar, calendar chooser, calendar listing, and conflict detection — actions enqueue background tasks and return task IDs; new API endpoints documented. --- src/backend/core/api/openapi.json | 447 ++++ src/backend/core/api/permissions.py | 19 + src/backend/core/api/serializers.py | 5 + src/backend/core/api/viewsets/calendar.py | 439 ++++ src/backend/core/enums.py | 1 + .../core/services/calendar/__init__.py | 0 src/backend/core/services/calendar/service.py | 1000 +++++++++ src/backend/core/services/calendar/tasks.py | 188 ++ src/backend/core/tasks.py | 1 + src/backend/core/tests/api/test_calendar.py | 1975 +++++++++++++++++ .../core/tests/api/test_messages_import.py | 2 +- src/backend/core/tests/importer/conftest.py | 2 +- src/backend/core/urls.py | 26 + src/backend/messages/settings.py | 52 +- src/backend/pyproject.toml | 3 + src/backend/uv.lock | 66 + src/frontend/package-lock.json | 2 + src/frontend/package.json | 2 +- src/frontend/public/locales/common/en-US.json | 399 ++-- src/frontend/public/locales/common/fr-FR.json | 584 ++--- .../src/features/api/gen/calendar/calendar.ts | 636 ++++++ src/frontend/src/features/api/gen/index.ts | 1 + .../calendar_add_event_request_request.ts | 21 + .../gen/models/calendar_add_event_response.ts | 11 + .../calendar_conflicts_request_request.ts | 19 + .../gen/models/calendar_conflicts_response.ts | 17 + ...endar_conflicts_response_conflicts_item.ts | 9 + .../api/gen/models/calendar_list_response.ts | 19 + .../calendar_list_response_calendars_item.ts | 9 + .../models/calendar_rsvp_request_request.ts | 28 + .../api/gen/models/calendar_rsvp_response.ts | 11 + .../src/features/api/gen/models/index.ts | 17 + .../mailboxes_calendar_add_create400.ts | 11 + .../mailboxes_calendar_add_create503.ts | 11 + ...ailboxes_calendar_calendars_retrieve502.ts | 11 + .../mailboxes_calendar_conflicts_create400.ts | 11 + .../mailboxes_calendar_conflicts_create502.ts | 11 + .../mailboxes_calendar_rsvp_create400.ts | 11 + .../mailboxes_calendar_rsvp_create503.ts | 11 + .../features/api/gen/models/response_enum.ts | 21 + .../message-importer/index.tsx | 2 +- .../message-importer/step-completed.tsx | 2 +- .../message-importer/step-loader.tsx | 36 +- .../components/main/header/authenticated.tsx | 4 +- .../components/calendar-invite/_index.scss | 330 ++- .../calendar-invite/calendar-helper.tsx | 3 +- .../calendar-invite/calendar-select.tsx | 79 + .../calendar-invite/event-display.test.ts | 129 ++ .../calendar-invite/event-display.ts | 125 ++ .../components/calendar-invite/index.tsx | 814 ++++++- .../components/thread-message/index.tsx | 46 + .../thread-message/thread-message-footer.tsx | 42 +- .../components/thread-message/types.ts | 1 + .../ui/components/contact-chip/index.tsx | 12 +- ...{use-import-task.ts => use-task-status.ts} | 40 +- 55 files changed, 7114 insertions(+), 660 deletions(-) create mode 100644 src/backend/core/api/viewsets/calendar.py create mode 100644 src/backend/core/services/calendar/__init__.py create mode 100644 src/backend/core/services/calendar/service.py create mode 100644 src/backend/core/services/calendar/tasks.py create mode 100644 src/backend/core/tests/api/test_calendar.py create mode 100644 src/frontend/src/features/api/gen/calendar/calendar.ts create mode 100644 src/frontend/src/features/api/gen/models/calendar_add_event_request_request.ts create mode 100644 src/frontend/src/features/api/gen/models/calendar_add_event_response.ts create mode 100644 src/frontend/src/features/api/gen/models/calendar_conflicts_request_request.ts create mode 100644 src/frontend/src/features/api/gen/models/calendar_conflicts_response.ts create mode 100644 src/frontend/src/features/api/gen/models/calendar_conflicts_response_conflicts_item.ts create mode 100644 src/frontend/src/features/api/gen/models/calendar_list_response.ts create mode 100644 src/frontend/src/features/api/gen/models/calendar_list_response_calendars_item.ts create mode 100644 src/frontend/src/features/api/gen/models/calendar_rsvp_request_request.ts create mode 100644 src/frontend/src/features/api/gen/models/calendar_rsvp_response.ts create mode 100644 src/frontend/src/features/api/gen/models/mailboxes_calendar_add_create400.ts create mode 100644 src/frontend/src/features/api/gen/models/mailboxes_calendar_add_create503.ts create mode 100644 src/frontend/src/features/api/gen/models/mailboxes_calendar_calendars_retrieve502.ts create mode 100644 src/frontend/src/features/api/gen/models/mailboxes_calendar_conflicts_create400.ts create mode 100644 src/frontend/src/features/api/gen/models/mailboxes_calendar_conflicts_create502.ts create mode 100644 src/frontend/src/features/api/gen/models/mailboxes_calendar_rsvp_create400.ts create mode 100644 src/frontend/src/features/api/gen/models/mailboxes_calendar_rsvp_create503.ts create mode 100644 src/frontend/src/features/api/gen/models/response_enum.ts create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/calendar-select.tsx create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/event-display.test.ts create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/event-display.ts rename src/frontend/src/hooks/{use-import-task.ts => use-task-status.ts} (69%) diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index 07966e60..302ea97b 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -2527,6 +2527,304 @@ } } }, + "/api/v1.0/mailboxes/{mailbox_id}/calendar/add/": { + "post": { + "operationId": "mailboxes_calendar_add_create", + "description": "Add an event to the mailbox's CalDAV calendar via a background task.", + "parameters": [ + { + "in": "path", + "name": "mailbox_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "calendar" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CalendarAddEventRequestRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CalendarAddEventRequestRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CalendarAddEventResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + } + } + } + }, + "description": "Missing ics_data." + }, + "503": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + } + } + } + }, + "description": "Task broker unavailable; the add-event could not be enqueued." + } + } + } + }, + "/api/v1.0/mailboxes/{mailbox_id}/calendar/calendars/": { + "get": { + "operationId": "mailboxes_calendar_calendars_retrieve", + "description": "Return the list of calendars available for the mailbox.", + "parameters": [ + { + "in": "path", + "name": "mailbox_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "calendar" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CalendarListResponse" + } + } + }, + "description": "" + }, + "502": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + } + } + } + }, + "description": "CalDAV server error while listing calendars." + } + } + } + }, + "/api/v1.0/mailboxes/{mailbox_id}/calendar/conflicts/": { + "post": { + "operationId": "mailboxes_calendar_conflicts_create", + "description": "Return a list of events overlapping the requested time range.", + "parameters": [ + { + "in": "path", + "name": "mailbox_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "calendar" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CalendarConflictsRequestRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CalendarConflictsRequestRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CalendarConflictsResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + } + } + } + }, + "description": "Missing or invalid start/end." + }, + "502": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + } + } + } + }, + "description": "CalDAV server error while checking conflicts." + } + } + } + }, + "/api/v1.0/mailboxes/{mailbox_id}/calendar/rsvp/": { + "post": { + "operationId": "mailboxes_calendar_rsvp_create", + "description": "Submit an RSVP response via a background CalDAV task.", + "parameters": [ + { + "in": "path", + "name": "mailbox_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "calendar" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CalendarRsvpRequestRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CalendarRsvpRequestRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CalendarRsvpResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + } + } + } + }, + "description": "Missing or invalid ics_data / response." + }, + "503": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + } + } + } + }, + "description": "Task broker unavailable; the RSVP could not be enqueued." + } + } + } + }, "/api/v1.0/mailboxes/{mailbox_id}/channels/": { "get": { "operationId": "mailboxes_channels_list", @@ -6639,6 +6937,146 @@ "type" ] }, + "CalendarAddEventRequestRequest": { + "type": "object", + "properties": { + "ics_data": { + "type": "string", + "minLength": 1, + "description": "Raw ICS content of the event" + }, + "calendar_id": { + "type": "string", + "nullable": true, + "minLength": 1, + "description": "Optional specific calendar URL" + } + }, + "required": [ + "ics_data" + ] + }, + "CalendarAddEventResponse": { + "type": "object", + "properties": { + "task_id": { + "type": "string" + } + }, + "required": [ + "task_id" + ] + }, + "CalendarConflictsRequestRequest": { + "type": "object", + "properties": { + "start": { + "type": "string", + "format": "date-time", + "description": "Start of the time range (ISO 8601)" + }, + "end": { + "type": "string", + "format": "date-time", + "description": "End of the time range (ISO 8601)" + }, + "exclude_uid": { + "type": "string", + "nullable": true, + "description": "Optional UID of an event to exclude from conflicts (avoids flagging prior imports of the same invite)." + } + }, + "required": [ + "end", + "start" + ] + }, + "CalendarConflictsResponse": { + "type": "object", + "properties": { + "conflicts": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } + }, + "existing_partstat": { + "type": "string", + "nullable": true, + "description": "PARTSTAT of the requesting mailbox on the prior copy of ``exclude_uid``, if such a copy exists. Lets the UI pre-select the user's prior RSVP." + } + }, + "required": [ + "conflicts", + "existing_partstat" + ] + }, + "CalendarListResponse": { + "type": "object", + "properties": { + "calendars": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } + }, + "web_url": { + "type": "string", + "nullable": true, + "description": "Public URL of the calendar web UI, if configured." + }, + "configured": { + "type": "boolean", + "description": "True when a CalDAV service is configured for this mailbox (per-mailbox channel or deployment default). False means the integration is disabled." + } + }, + "required": [ + "calendars", + "configured", + "web_url" + ] + }, + "CalendarRsvpRequestRequest": { + "type": "object", + "properties": { + "ics_data": { + "type": "string", + "minLength": 1, + "description": "Raw ICS content of the event" + }, + "response": { + "allOf": [ + { + "$ref": "#/components/schemas/ResponseEnum" + } + ], + "description": "RSVP response\n\n* `ACCEPTED` - ACCEPTED\n* `DECLINED` - DECLINED\n* `TENTATIVE` - TENTATIVE" + }, + "calendar_id": { + "type": "string", + "nullable": true, + "minLength": 1, + "description": "Optional specific calendar URL" + } + }, + "required": [ + "ics_data", + "response" + ] + }, + "CalendarRsvpResponse": { + "type": "object", + "properties": { + "task_id": { + "type": "string" + } + }, + "required": [ + "task_id" + ] + }, "ChangeFlagRequestRequest": { "type": "object", "properties": { @@ -8986,6 +9424,15 @@ "one_time_password" ] }, + "ResponseEnum": { + "enum": [ + "ACCEPTED", + "DECLINED", + "TENTATIVE" + ], + "type": "string", + "description": "* `ACCEPTED` - ACCEPTED\n* `DECLINED` - DECLINED\n* `TENTATIVE` - TENTATIVE" + }, "ScopeLevelEnum": { "enum": [ "global", diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index df47e4cb..28dd8576 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -691,3 +691,22 @@ class HasAccessToMailbox(IsAuthenticated): return models.MailboxAccess.objects.filter( user=request.user, mailbox=view.kwargs.get("mailbox_id") ).exists() + + +class HasWriteAccessToMailbox(IsAuthenticated): + """Allows access only to users with an editor-or-above role on the mailbox. + + Use for state-changing endpoints whose effect is observable beyond the + mailbox itself (e.g. writing to the mailbox's CalDAV calendar, which a + VIEWER access shouldn't be able to do). + """ + + def has_permission(self, request, view): + if not super().has_permission(request, view): + return False + + return models.MailboxAccess.objects.filter( + user=request.user, + mailbox=view.kwargs.get("mailbox_id"), + role__in=enums.MAILBOX_ROLES_CAN_EDIT, + ).exists() diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 2a33e0c9..9b72f2f6 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -1120,6 +1120,7 @@ class MessageSerializer(serializers.ModelSerializer): "size": attachment["size"], "type": attachment["type"], "cid": attachment.get("cid"), + "sha256": attachment.get("sha256"), } ) return stripped_attachments @@ -1965,6 +1966,10 @@ class ChannelSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer): # generators write directly to ``encrypted_settings`` instead. RESERVED_SETTINGS_KEYS = { enums.ChannelTypes.API_KEY: ["api_key_hashes"], + # CalDAV credentials must live in ``encrypted_settings``, never in + # the plaintext ``settings`` JSONField — a DB read would otherwise + # surface every user's CalDAV password. + enums.ChannelTypes.CALDAV: ["username", "password"], } def create(self, validated_data): diff --git a/src/backend/core/api/viewsets/calendar.py b/src/backend/core/api/viewsets/calendar.py new file mode 100644 index 00000000..b51f0e58 --- /dev/null +++ b/src/backend/core/api/viewsets/calendar.py @@ -0,0 +1,439 @@ +"""API ViewSet for calendar operations (RSVP, conflict detection, calendar listing).""" + +import logging +from datetime import datetime + +from django.conf import settings +from django.shortcuts import get_object_or_404 +from django.utils.functional import cached_property + +from drf_spectacular.utils import ( + OpenApiResponse, + extend_schema, + inline_serializer, +) +from rest_framework import serializers as drf_serializers +from rest_framework import status +from rest_framework.exceptions import NotFound +from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle +from rest_framework.views import APIView + +from core import enums, models +from core.api.permissions import HasAccessToMailbox, HasWriteAccessToMailbox +from core.api.viewsets.task import register_task_owner +from core.services.calendar.service import CalDAVError, CalDAVService +from core.services.calendar.tasks import calendar_add_event_task, calendar_rsvp_task + +logger = logging.getLogger(__name__) + +# Shared OpenAPI error-response schema for the calendar endpoints. Inline +# here so each endpoint's @extend_schema can reference it without dragging +# a one-off serializer class through drf_spectacular. +_ERROR_SCHEMA = { + "type": "object", + "properties": {"detail": {"type": "string"}}, +} + + +class CalDAVChannelMixin: + """Mixin to get the CalDAV channel or deployment-default config for a mailbox.""" + + @cached_property + def mailbox(self): + """The Mailbox referenced in the URL.""" + return get_object_or_404(models.Mailbox, id=self.kwargs["mailbox_id"]) + + @cached_property + def caldav_channel(self): + """The CalDAV channel for the mailbox, if any.""" + return models.Channel.objects.filter( + mailbox=self.mailbox, type=enums.ChannelTypes.CALDAV + ).first() + + def get_caldav_service(self): + """Get a CalDAVService for this mailbox. + + Priority: per-mailbox Channel (user-configured, pointing at any + CalDAV provider) > deployment-default config (``CALDAV_DEFAULT_*`` + env vars). For the default path, the mailbox email is sent as the + Basic Auth username so the CalDAV server can route to the user's + calendars via principal discovery — see + ``CalDAVService.from_instance_config`` for the trust model. + Returns None if neither is configured. + """ + return CalDAVService.from_channel_or_instance( + self.caldav_channel, str(self.mailbox) + ) + + def require_caldav_service(self): + """Get the CalDAVService or raise 404.""" + service = self.get_caldav_service() + if not service: + raise NotFound("No CalDAV calendar is configured for this mailbox.") + return service + + +@extend_schema(tags=["calendar"]) +class CalendarRsvpView(CalDAVChannelMixin, APIView): + """Submit an RSVP response to a calendar event.""" + + # Writing an RSVP on behalf of a mailbox is a CalDAV write that produces + # an outbound iTIP REPLY — VIEWER-only access must not be able to do it. + permission_classes = [HasWriteAccessToMailbox] + + @extend_schema( + request=inline_serializer( + name="CalendarRsvpRequest", + fields={ + "ics_data": drf_serializers.CharField( + help_text="Raw ICS content of the event" + ), + "response": drf_serializers.ChoiceField( + choices=["ACCEPTED", "DECLINED", "TENTATIVE"], + help_text="RSVP response", + ), + "calendar_id": drf_serializers.CharField( + required=False, + allow_null=True, + help_text="Optional specific calendar URL", + ), + }, + ), + responses={ + 200: inline_serializer( + name="CalendarRsvpResponse", + fields={ + "task_id": drf_serializers.CharField(), + }, + ), + 400: OpenApiResponse( + response=_ERROR_SCHEMA, + description="Missing or invalid ics_data / response.", + ), + 503: OpenApiResponse( + response=_ERROR_SCHEMA, + description="Task broker unavailable; the RSVP could not be enqueued.", + ), + }, + ) + def post(self, request, mailbox_id): # pylint: disable=unused-argument + """Submit an RSVP response via a background CalDAV task.""" + ics_data = request.data.get("ics_data") + response_type = request.data.get("response") + calendar_id = request.data.get("calendar_id") + + if not ics_data or not response_type: + return Response( + {"detail": "ics_data and response are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if response_type not in ("ACCEPTED", "DECLINED", "TENTATIVE"): + return Response( + {"detail": "response must be ACCEPTED, DECLINED, or TENTATIVE."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + self.require_caldav_service() + channel = self.caldav_channel + mailbox_email = str(self.mailbox) + + try: + task = calendar_rsvp_task.delay( + channel_id=str(channel.id) if channel else None, + mailbox_email=mailbox_email, + ics_data=ics_data, + response=response_type, + attendee_email=mailbox_email, + calendar_id=calendar_id, + ) + register_task_owner(task.id, request.user.id) + except Exception as e: # pylint: disable=broad-exception-caught + logger.exception("Failed to enqueue calendar_rsvp_task: %s", e) + return Response( + {"detail": "Could not schedule the RSVP task."}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + return Response({"task_id": task.id}, status=status.HTTP_200_OK) + + +@extend_schema(tags=["calendar"]) +class CalendarAddEventView(CalDAVChannelMixin, APIView): + """Add an event to a CalDAV calendar.""" + + # Writing an event into the mailbox's calendar must not be allowed for + # VIEWER-only access. + permission_classes = [HasWriteAccessToMailbox] + + @extend_schema( + request=inline_serializer( + name="CalendarAddEventRequest", + fields={ + "ics_data": drf_serializers.CharField( + help_text="Raw ICS content of the event" + ), + "calendar_id": drf_serializers.CharField( + required=False, + allow_null=True, + help_text="Optional specific calendar URL", + ), + }, + ), + responses={ + 200: inline_serializer( + name="CalendarAddEventResponse", + fields={ + "task_id": drf_serializers.CharField(), + }, + ), + 400: OpenApiResponse( + response=_ERROR_SCHEMA, + description="Missing ics_data.", + ), + 503: OpenApiResponse( + response=_ERROR_SCHEMA, + description="Task broker unavailable; the add-event could not be enqueued.", + ), + }, + ) + def post(self, request, mailbox_id): # pylint: disable=unused-argument + """Add an event to the mailbox's CalDAV calendar via a background task.""" + ics_data = request.data.get("ics_data") + calendar_id = request.data.get("calendar_id") + + if not ics_data: + return Response( + {"detail": "ics_data is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + self.require_caldav_service() + channel = self.caldav_channel + + try: + task = calendar_add_event_task.delay( + channel_id=str(channel.id) if channel else None, + mailbox_email=str(self.mailbox), + ics_data=ics_data, + calendar_id=calendar_id, + ) + register_task_owner(task.id, request.user.id) + except Exception as e: # pylint: disable=broad-exception-caught + logger.exception("Failed to enqueue calendar_add_event_task: %s", e) + return Response( + {"detail": "Could not schedule the add-event task."}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + return Response({"task_id": task.id}, status=status.HTTP_200_OK) + + +@extend_schema(tags=["calendar"]) +class CalendarConflictsView(CalDAVChannelMixin, APIView): + """Check for conflicting events in a given time range. + + Note: CalDAV calls are intentionally blocking (synchronous) here because + the user is waiting for the result before interacting with the UI. + + Throttled per user under the ``caldav_conflicts`` scope. Each call + PROPFINDs the home set and REPORTs every calendar in it, so a tight + polling loop both stresses the CalDAV server and ties up request + workers; a 30/min cap is generous for legitimate UI use (one call + per opened invite) and bounds the cost of a runaway script. + """ + + permission_classes = [HasAccessToMailbox] + throttle_classes = [ScopedRateThrottle] + throttle_scope = "caldav_conflicts" + + @extend_schema( + request=inline_serializer( + name="CalendarConflictsRequest", + fields={ + "start": drf_serializers.DateTimeField( + help_text="Start of the time range (ISO 8601)" + ), + "end": drf_serializers.DateTimeField( + help_text="End of the time range (ISO 8601)" + ), + "exclude_uid": drf_serializers.CharField( + required=False, + allow_null=True, + allow_blank=True, + help_text=( + "Optional UID of an event to exclude from conflicts " + "(avoids flagging prior imports of the same invite)." + ), + ), + }, + ), + responses={ + 200: inline_serializer( + name="CalendarConflictsResponse", + fields={ + "conflicts": drf_serializers.ListField( + child=drf_serializers.DictField() + ), + "existing_partstat": drf_serializers.CharField( + allow_null=True, + help_text=( + "PARTSTAT of the requesting mailbox on the prior " + "copy of ``exclude_uid``, if such a copy exists. " + "Lets the UI pre-select the user's prior RSVP." + ), + ), + }, + ), + 400: OpenApiResponse( + response=_ERROR_SCHEMA, + description="Missing or invalid start/end.", + ), + 502: OpenApiResponse( + response=_ERROR_SCHEMA, + description="CalDAV server error while checking conflicts.", + ), + }, + ) + def post(self, request, mailbox_id): # pylint: disable=unused-argument + """Return a list of events overlapping the requested time range.""" + start = request.data.get("start") + end = request.data.get("end") + exclude_uid = request.data.get("exclude_uid") or None + + if not start or not end: + return Response( + {"detail": "start and end are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + if isinstance(start, str): + start = datetime.fromisoformat(start) + if isinstance(end, str): + end = datetime.fromisoformat(end) + except (ValueError, TypeError): + return Response( + {"detail": "start and end must be valid ISO 8601 datetimes."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # The CalDAV time-range filter (`_format_utc`) expects aware + # datetimes — silently treating naive input as UTC would lie + # about the wall-clock value sent to the server. + if start.tzinfo is None or end.tzinfo is None: + return Response( + {"detail": "start and end must include timezone info."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if end <= start: + return Response( + {"detail": "end must be after start."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + service = self.require_caldav_service() + + try: + result = service.check_conflicts( + start=start, + end=end, + exclude_uid=exclude_uid, + attendee_email=str(self.mailbox), + ) + except CalDAVError as e: + # Upstream CalDAV failure (network, 4xx/5xx from server, + # SSRF guard tripped, etc.) — 502 is the right shape. + logger.warning("CalDAV upstream failed during conflicts: %s", e) + return Response( + {"detail": "CalDAV server returned an error while checking conflicts."}, + status=status.HTTP_502_BAD_GATEWAY, + ) + # Anything else is an unexpected programming error — let Django's + # default handler return 500 so it doesn't get mis-labelled as + # an upstream issue. + + return Response(result, status=status.HTTP_200_OK) + + +@extend_schema(tags=["calendar"]) +class CalendarListView(CalDAVChannelMixin, APIView): + """List available calendars on the CalDAV server. + + Note: CalDAV calls are intentionally blocking (synchronous) here because + the user is waiting for the result before interacting with the UI. + """ + + permission_classes = [HasAccessToMailbox] + + @extend_schema( + responses={ + 200: inline_serializer( + name="CalendarListResponse", + fields={ + "calendars": drf_serializers.ListField( + child=drf_serializers.DictField() + ), + "web_url": drf_serializers.CharField( + allow_null=True, + help_text=("Public URL of the calendar web UI, if configured."), + ), + "configured": drf_serializers.BooleanField( + help_text=( + "True when a CalDAV service is configured for this " + "mailbox (per-mailbox channel or deployment default). " + "False means the integration is disabled." + ), + ), + }, + ), + 502: OpenApiResponse( + response=_ERROR_SCHEMA, + description="CalDAV server error while listing calendars.", + ), + }, + ) + def get(self, request, mailbox_id): # pylint: disable=unused-argument + """Return the list of calendars available for the mailbox.""" + web_url = settings.CALDAV_DEFAULT_WEB_URL + service = self.get_caldav_service() + if not service: + return Response( + {"calendars": [], "web_url": web_url, "configured": False}, + status=status.HTTP_200_OK, + ) + + try: + # Only list calendars the user can write to — the UI uses this + # to pick a destination for RSVP/add-event, and read-only + # calendars would fail at PUT time. + calendars = service.list_calendars(writable_only=True) + except CalDAVError as e: + # 403 from the CalDAV proxy means "we authenticated the + # service credential but the mailbox email is not a known + # user upstream" — effectively this mailbox has no calendar + # account. Surface it like an unconfigured integration so + # the UI hides the footer rather than showing a misleading + # "service unavailable" message. + if e.status_code == 403: + logger.info( + "CalDAV reports mailbox %s has no calendar account (HTTP 403).", + mailbox_id, + ) + return Response( + {"calendars": [], "web_url": web_url, "configured": False}, + status=status.HTTP_200_OK, + ) + logger.warning("CalDAV upstream failed during list_calendars: %s", e) + return Response( + {"detail": "CalDAV server returned an error while listing calendars."}, + status=status.HTTP_502_BAD_GATEWAY, + ) + # Anything else is unexpected — let Django return 500. + + return Response( + {"calendars": calendars, "web_url": web_url, "configured": True}, + status=status.HTTP_200_OK, + ) diff --git a/src/backend/core/enums.py b/src/backend/core/enums.py index 5a0dd981..bc7ab461 100644 --- a/src/backend/core/enums.py +++ b/src/backend/core/enums.py @@ -218,6 +218,7 @@ class ChannelTypes(StrEnum): WIDGET = "widget" API_KEY = "api_key" WEBHOOK = "webhook" + CALDAV = "caldav" class WebhookEvents(StrEnum): diff --git a/src/backend/core/services/calendar/__init__.py b/src/backend/core/services/calendar/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/services/calendar/service.py b/src/backend/core/services/calendar/service.py new file mode 100644 index 00000000..f241e5ac --- /dev/null +++ b/src/backend/core/services/calendar/service.py @@ -0,0 +1,1000 @@ +"""Minimal CalDAV client for calendar invite management. + +Implements only the CalDAV operations we need (list calendars, search +events, add event, RSVP) directly over HTTP using ``requests`` and +``icalendar`` for parsing, rather than pulling in the full ``caldav`` +library's dependency surface. +""" + +import copy +import logging +import re +import uuid +from datetime import datetime, timezone +from urllib.parse import quote, urljoin, urlparse + +from django.conf import settings as django_settings + +import defusedxml.ElementTree as ET +import requests +from defusedxml.ElementTree import ParseError as DefusedParseError +from icalendar import Calendar as ICalendar +from icalendar import Event as ICalEvent + +from core.services.ssrf import ( + SSRFProtectedAdapter, + SSRFValidationError, + validate_hostname, +) + +logger = logging.getLogger(__name__) + +CALDAV_TIMEOUT = 20 + +DAV_NS = "DAV:" +CALDAV_NS = "urn:ietf:params:xml:ns:caldav" +APPLE_ICAL_NS = "http://apple.com/ns/ical/" + +# Accept only #RRGGBB / #RGB hex from the calendar server's color property — +# anything else is treated as no color (defensive against attacker-controlled +# values flowing into React inline styles). +_HEX_COLOR_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$") + + +# --- Storage-safe ICS rebuild --------------------------------------------- +# +# Inbound .ics is attacker-controlled (it arrives as an email attachment). +# Rather than try to enumerate every dangerous extension to strip — METHOD, +# VALARM with ACTION:EMAIL (Apple amplification), X-MS-OLK-*, X-ALT-DESC +# HTML, ATTACH with data: URIs, future iCal extensions we haven't heard +# of — we rebuild a fresh VCALENDAR from a tight allowlist of RFC 5545 +# properties before PUTing to the CalDAV server. + +# VCALENDAR-level properties to preserve. ``METHOD`` is deliberately +# excluded: RFC 4791 §4.1 forbids it in stored calendar objects. +_VCALENDAR_KEEP = frozenset({"VERSION", "PRODID", "CALSCALE"}) + +# VEVENT properties kept on the rebuilt event. Everything else is dropped: +# notable exclusions are ATTACH (size + scheme abuse), GEO, RESOURCES, +# RELATED-TO, REQUEST-STATUS, COMMENT, any X-* extension. +_VEVENT_KEEP = frozenset( + { + # Identity / iTIP versioning + "UID", + "DTSTAMP", + "SEQUENCE", + "CREATED", + "LAST-MODIFIED", + # Time / recurrence + "DTSTART", + "DTEND", + "DURATION", + "RRULE", + "RDATE", + "EXDATE", + "RECURRENCE-ID", + # Display + "SUMMARY", + "DESCRIPTION", + "LOCATION", + "URL", + "CATEGORIES", + # Semantics + "STATUS", + "TRANSP", + "CLASS", + "PRIORITY", + "ORGANIZER", + "ATTENDEE", + } +) + +# Per-property parameter allowlist. Anything else (X-*, unknown future +# params, attacker-crafted noise) is dropped. Properties not in this map +# get all parameters stripped. +_PARAM_KEEP = { + "DTSTART": frozenset({"TZID", "VALUE"}), + "DTEND": frozenset({"TZID", "VALUE"}), + "DURATION": frozenset(), + "RECURRENCE-ID": frozenset({"TZID", "VALUE", "RANGE"}), + "RDATE": frozenset({"TZID", "VALUE"}), + "EXDATE": frozenset({"TZID", "VALUE"}), + "ATTENDEE": frozenset( + { + "CN", + "PARTSTAT", + "ROLE", + "CUTYPE", + "RSVP", + "MEMBER", + "DELEGATED-TO", + "DELEGATED-FROM", + "SENT-BY", + "DIR", + "LANGUAGE", + "SCHEDULE-AGENT", + "SCHEDULE-STATUS", + } + ), + "ORGANIZER": frozenset( + { + "CN", + "DIR", + "SENT-BY", + "LANGUAGE", + "SCHEDULE-AGENT", + "SCHEDULE-STATUS", + } + ), + "SUMMARY": frozenset({"LANGUAGE"}), + "DESCRIPTION": frozenset({"LANGUAGE"}), + "LOCATION": frozenset({"LANGUAGE"}), + "CATEGORIES": frozenset({"LANGUAGE"}), +} + +# URL property must start with http:// or https://. We don't use +# ``urlparse`` here — browsers tolerate whitespace, control chars and +# weird Unicode that urlparse rejects, so a string we'd consider "safe" +# (because urlparse couldn't extract a dangerous scheme) might still +# resolve to ``javascript:`` in the browser. Be stricter than urlparse: +# the value must literally start with ``http://`` or ``https://`` +# (case-insensitive, no leading whitespace). +_SAFE_URL_RE = re.compile(r"^https?://", re.IGNORECASE) + +# RRULE frequencies that produce ruinous expansion if unbounded. +# Thunderbird hard-froze on a SECONDLY-frequency invite without +# COUNT/UNTIL (Mozilla bug 1770984). Reject these unless explicitly +# bounded. +_RRULE_FREQ_REQUIRES_BOUND = frozenset({"SECONDLY", "MINUTELY"}) + + +def _q(ns, tag): + return f"{{{ns}}}{tag}" + + +def _format_utc(dt): + # Reject naive datetimes — silently formatting them as UTC would lie + # about the wall-clock value sent to the CalDAV server. + if dt.tzinfo is None: + raise ValueError("Naive datetime; pass a timezone-aware datetime.") + return dt.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +class CalDAVError(Exception): + """CalDAV protocol or server error. + + ``status_code`` is the upstream HTTP status when the failure was an + HTTP response (4xx/5xx); None for network-level errors, validation + failures, or anything that didn't yield an HTTP status. + """ + + def __init__(self, message, status_code=None): + super().__init__(message) + self.status_code = status_code + + +class CalDAVService: # pylint: disable=too-many-instance-attributes + """Minimal CalDAV client (HTTP + icalendar, no full caldav lib).""" + + def __init__(self, url, username="", password="", headers=None, ssrf_protect=False): + self.url = url + self.username = username + self.password = password + self.extra_headers = headers or {} + self.ssrf_protect = ssrf_protect + self._session = None + self._home_set = None + # Resolved-and-pinned destination, populated lazily on session + # creation when ``ssrf_protect`` is True. + self._ssrf_adapter = None + + @property + def session(self): + """Lazily-created ``requests.Session`` with auth and headers applied. + + When ``ssrf_protect`` is True (per-mailbox channels — URL is + user-supplied and untrusted), an ``SSRFProtectedAdapter`` is + mounted so every request resolves the hostname through + ``validate_hostname`` (rejecting private/loopback IPs) and pins + the resulting IP to defeat DNS-rebinding. Untrusted-input + scheme/private-IP checks at config time (see ``from_channel``) + already filter the obvious cases; this is the per-request + guarantee. + """ + if self._session is None: + s = requests.Session() + if self.username or self.password: + s.auth = (self.username, self.password) + s.headers.update(self.extra_headers) + if self.ssrf_protect: + adapter = self._build_ssrf_adapter() + # Mount under the *scheme* prefix so every absolute URL + # we issue (PROPFIND/REPORT/PUT, possibly to the server's + # advertised href on the same origin) flows through the + # pinned adapter. + s.mount("https://", adapter) + s.mount("http://", adapter) + self._ssrf_adapter = adapter + self._session = s + return self._session + + def _build_ssrf_adapter(self): + """Resolve the configured URL once, pin the IP, return adapter. + + Called lazily on session creation. Raises ``CalDAVError`` if the + hostname resolves to a blocked range — propagating up turns this + into a 502 to the caller, surfaced as + "CalDAV server returned an error" in the UI. + """ + parsed = urlparse(self.url) + if parsed.scheme not in {"http", "https"}: + raise CalDAVError( + f"CalDAV URL scheme '{parsed.scheme}' is not allowed (http/https only)." + ) + if not parsed.hostname: + raise CalDAVError("CalDAV URL has no hostname.") + try: + ips = validate_hostname(parsed.hostname, allow_ip_literal=False) + except SSRFValidationError as exc: + raise CalDAVError(f"CalDAV URL host rejected: {exc}") from exc + port = parsed.port or (443 if parsed.scheme == "https" else 80) + return SSRFProtectedAdapter( + dest_ip=ips[0], + dest_port=port, + original_hostname=parsed.hostname, + original_scheme=parsed.scheme, + ) + + def _request(self, method, url, **kwargs): + # Belt-and-braces SSRF guard: the session carries Basic Auth that + # would otherwise leak to any host the server response steers us + # toward (PROPFIND hrefs are server-controlled and can be absolute, + # cross-origin URLs). Pin every outbound request to the configured + # CalDAV origin. + if not self._same_origin(url): + logger.warning( + "CalDAV SSRF guard tripped: refused %s to %s (configured: %s)", + method, + url, + self.url, + ) + raise CalDAVError( + f"Refusing {method} to {url}: not on configured CalDAV origin." + ) + kwargs.setdefault("timeout", CALDAV_TIMEOUT) + # Never follow redirects. The same-origin guard above only validates + # the *initial* URL; without this, a CalDAV server (especially a + # third-party one configured via a per-mailbox Channel) could 302 + # us to an attacker-controlled host, bypassing the guard. + kwargs.setdefault("allow_redirects", False) + try: + resp = self.session.request(method, url, **kwargs) + except requests.exceptions.RequestException as exc: + # Surface network-level failures (timeout, connection reset, DNS, + # SSL) as CalDAVError so callers can handle them uniformly with + # protocol errors instead of leaking ``requests`` exceptions. + raise CalDAVError(f"{method} {url} failed: {exc}") from exc + if resp.status_code >= 400: + raise CalDAVError( + f"{method} {url} failed: HTTP {resp.status_code}", + status_code=resp.status_code, + ) + return resp + + def _propfind(self, url, body, depth="0"): + return self._request( + "PROPFIND", + url, + data=body.encode("utf-8"), + headers={ + "Depth": str(depth), + "Content-Type": "application/xml; charset=utf-8", + }, + ) + + @property + def home_set(self): + """Calendar-home-set URL, resolved lazily via principal discovery. + + Falls back to the configured URL if discovery fails (for servers or + URLs that already point directly at the home set). + """ + if self._home_set is not None: + return self._home_set + try: + self._home_set = self._discover_home_set() or self.url + except (CalDAVError, DefusedParseError, AttributeError) as exc: + # CalDAVError: protocol/HTTP/network. DefusedParseError: malformed + # PROPFIND XML. AttributeError: a defusedxml node was None where + # we expected it (server returned partial body). + # Anything else (programmer error) should NOT be swallowed — + # let it bubble so a true bug surfaces instead of silently + # falling back to the configured URL. + logger.debug( + "home-set discovery failed for %s (%s), using URL directly", + self.url, + exc, + exc_info=True, + ) + self._home_set = self.url + return self._home_set + + def _discover_home_set(self): + body = ( + '' + '' + "" + "" + ) + root = ET.fromstring(self._propfind(self.url, body, depth="0").text) + principal_href = root.findtext( + f".//{_q(DAV_NS, 'current-user-principal')}/{_q(DAV_NS, 'href')}" + ) + principal_url = ( + urljoin(self.url, principal_href.strip()) if principal_href else self.url + ) + + body = ( + '' + '' + "" + "" + ) + root = ET.fromstring(self._propfind(principal_url, body, depth="0").text) + home_href = root.findtext( + f".//{_q(CALDAV_NS, 'calendar-home-set')}/{_q(DAV_NS, 'href')}" + ) + if not home_href: + return principal_url + return urljoin(self.url, home_href.strip()) + + def list_calendars(self, writable_only=False): + """List all calendars with a single PROPFIND depth=1 (no N+1). + + When ``writable_only`` is True, calendars the current user cannot + write to (read-only shares, subscribed calendars) are filtered out + based on the DAV ``current-user-privilege-set``. Servers that do + not advertise the privilege set are trusted (the calendar is kept) + to avoid hiding legitimate writable calendars on minimal CalDAV + implementations. + """ + body = ( + '' + '' + "" + "" + "" + "" + "" + ) + root = ET.fromstring(self._propfind(self.home_set, body, depth="1").text) + result = [] + for response in root.findall(_q(DAV_NS, "response")): + href = response.findtext(_q(DAV_NS, "href")) + if not href: + continue + href = href.strip() + rtype = response.find(f".//{_q(DAV_NS, 'resourcetype')}") + if rtype is None or rtype.find(_q(CALDAV_NS, "calendar")) is None: + continue + if writable_only and not self._response_is_writable(response): + continue + displayname = response.findtext(f".//{_q(DAV_NS, 'displayname')}") or href + color = self._parse_color( + response.findtext(f".//{_q(APPLE_ICAL_NS, 'calendar-color')}") + ) + result.append( + { + "id": urljoin(self.url, href), + "name": displayname.strip(), + "color": color, + } + ) + return result + + @staticmethod + def _parse_color(raw): + """Validate a CalDAV ``calendar-color`` value as a 3- or 6-digit hex. + + Some servers return 8-hex (#RRGGBBAA) — trim alpha for CSS. Anything + else (named colors, rgb(), garbage) is rejected to keep + attacker-controlled strings out of the frontend's inline ``style``. + """ + if not raw: + return None + value = raw.strip() + if len(value) == 9 and value.startswith("#"): + value = value[:7] + return value if _HEX_COLOR_RE.match(value) else None + + @staticmethod + def _response_is_writable(response): + """Whether the DAV response advertises a write privilege. + + Absence of ``current-user-privilege-set`` is treated as writable + (minimal CalDAV servers don't advertise ACLs); presence with no + write-family privilege is treated as read-only. + """ + priv_set = response.find(f".//{_q(DAV_NS, 'current-user-privilege-set')}") + if priv_set is None: + return True + write_tags = { + _q(DAV_NS, "write"), + _q(DAV_NS, "write-content"), + _q(DAV_NS, "all"), + } + for priv in priv_set.iter(_q(DAV_NS, "privilege")): + for child in priv: + if child.tag in write_tags: + return True + return False + + def check_conflicts(self, start, end, exclude_uid=None, attendee_email=None): + """Find conflicts and the existing PARTSTAT for the same UID. + + Returns ``{"conflicts": [...], "existing_partstat": str | None}``. + + Events whose UID matches ``exclude_uid`` are NOT returned as + conflicts (a prior import of the same invite should not flag the + event as conflicting with itself). When ``attendee_email`` is + also passed, the excluded event is inspected for that attendee's + PARTSTAT — surfaced as ``existing_partstat`` so the UI can + pre-select the user's prior RSVP choice (and avoid re-prompting + for one they already made). + """ + conflicts = [] + existing_partstat = None + attendee_lc = attendee_email.lower() if attendee_email else None + for cal in self.list_calendars(): + try: + events = self._calendar_query(cal["id"], start, end) + except CalDAVError: + logger.exception( + "Error searching for conflicts on calendar %s", cal["name"] + ) + continue + for ics_text in events: + summary = self._summarize_event(ics_text, cal["name"]) + if summary is None: + continue + if exclude_uid and summary.get("uid") == exclude_uid: + # Capture the user's PARTSTAT on the existing copy so + # the UI can reflect a prior RSVP. First match wins — + # if the user has multiple copies of the same UID + # across calendars, the first one we see is the one + # we surface. + if existing_partstat is None and attendee_lc: + existing_partstat = self._extract_partstat( + ics_text, attendee_lc + ) + continue + # UIDs are used only for the self-exclusion filter above — + # they can carry internal routing info (incident IDs, etc.) + # so don't leak them to the API client. + summary.pop("uid", None) + conflicts.append(summary) + return {"conflicts": conflicts, "existing_partstat": existing_partstat} + + @staticmethod + def _extract_partstat(ics_text, attendee_email_lc): + """Return PARTSTAT of ``attendee_email_lc`` (lowercased) in ``ics_text``. + + Returns ``None`` if the event is unparseable or the attendee + is absent — callers should treat ``None`` as "no prior RSVP". + """ + try: + cal = ICalendar.from_ical(ics_text) + except Exception: # pylint: disable=broad-exception-caught + return None + for comp in cal.walk("VEVENT"): + attendees = comp.get("ATTENDEE") + if attendees is None: + continue + if not isinstance(attendees, list): + attendees = [attendees] + for att in attendees: + addr = str(att).strip().lower() + if addr.startswith("mailto:"): + addr = addr[len("mailto:") :] + if addr == attendee_email_lc: + val = att.params.get("PARTSTAT") + return str(val) if val else None + return None + + def _calendar_query(self, calendar_url, start, end): + body = ( + '' + '' + "" + "" + '' + '' + f'' + "" + "" + "" + "" + ) + resp = self._request( + "REPORT", + calendar_url, + data=body.encode("utf-8"), + headers={ + "Depth": "1", + "Content-Type": "application/xml; charset=utf-8", + }, + ) + root = ET.fromstring(resp.text) + data_key = f".//{_q(CALDAV_NS, 'calendar-data')}" + return [ + data + for r in root.findall(_q(DAV_NS, "response")) + if (data := r.findtext(data_key)) + ] + + @staticmethod + def _summarize_event(ics_text, calendar_name): + try: + cal = ICalendar.from_ical(ics_text) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("Could not parse conflicting event", exc_info=True) + return None + for comp in cal.walk("VEVENT"): + dtstart = comp.get("DTSTART") + dtend = comp.get("DTEND") + uid = comp.get("UID") + # All-day events carry a ``date`` (not ``datetime``) — surface + # the distinction so the UI can format without TZ conversion. + # ``new Date("2026-06-01")`` in JS parses as midnight UTC, then + # converts to local time; for users west of UTC the date can + # display as the day *before*. The ``all_day`` flag lets the + # client opt into a date-only formatter for those. + all_day = bool( + dtstart + and hasattr(dtstart, "dt") + and not isinstance(dtstart.dt, datetime) + ) + return { + "uid": str(uid) if uid else None, + "summary": str(comp.get("SUMMARY") or "Untitled event"), + "start": dtstart.dt.isoformat() if dtstart else None, + "end": dtend.dt.isoformat() if dtend else None, + "all_day": all_day, + "calendar_name": calendar_name, + } + return None + + def add_event(self, ics_data, calendar_id=None): + """Store an event on the selected calendar (or the default one).""" + cal = ICalendar.from_ical(ics_data) + cal = self._rebuild_for_storage(cal) + # Suppress server-side iTIP REQUEST fan-out. sabre/dav's Schedule + # plugin (used by suitenumerique/calendars) auto-dispatches one + # iTIP REQUEST per ATTENDEE on any PUT where the calendar owner is + # the ORGANIZER — turning a single /add/ call into a mass-mailer. + # RFC 6638 §7.1 SCHEDULE-AGENT=CLIENT on ORGANIZER tells the server + # the client owns scheduling, so the server takes no action. + # /add/ is a personal-calendar copy, not an invitation send, so + # this is always the right value here. The RSVP path keeps + # SCHEDULE-AGENT=SERVER so the REPLY reaches the organizer. + self._set_schedule_agent_client(cal) + self._put_event( + self._pick_calendar_url(calendar_id), + cal.to_ical().decode("utf-8"), + ) + return True + + def respond_to_event(self, ics_data, response, attendee_email, calendar_id=None): + """Store an RSVP'd copy of the event on the user's calendar. + + Relies on the CalDAV server's scheduling extension (RFC 6638) to + dispatch the iTIP REPLY back to the organizer — ORGANIZER keeps + its default SCHEDULE-AGENT=SERVER, so the broker emits the REPLY + on PUT. This includes ``DECLINED``: until the organizer removes + the user from ATTENDEEs, they remain invited, so the canonical + place to record the decline is a stored copy with + ``PARTSTAT=DECLINED``. The user can re-accept later by changing + PARTSTAT on the same event. + + Raises ``CalDAVError`` if the responding mailbox is not in the + ATTENDEE list — without that, the iTIP REPLY would never reach + the organizer (the broker uses ATTENDEE matching to decide who + to notify) and the user would see a "Response saved" toast for + a no-op write. + """ + cal = ICalendar.from_ical(ics_data) + # Update PARTSTAT on the input first, then rebuild — the rebuild + # preserves ATTENDEE entries (with our updated PARTSTAT) and + # drops everything else. + if not self._update_partstat(cal, attendee_email, response): + raise CalDAVError( + "Mailbox is not an attendee of this event; " + "RSVP would not notify the organizer." + ) + + cal = self._rebuild_for_storage(cal) + + self._put_event( + self._pick_calendar_url(calendar_id), + cal.to_ical().decode("utf-8"), + ) + return True + + @classmethod + def _rebuild_for_storage(cls, cal): + """Return a fresh VCALENDAR containing only allowlisted properties. + + Default-deny posture: inbound .ics is attacker-controlled (it + arrives as an email attachment), so rather than try to enumerate + every dangerous extension to strip — METHOD, VALARM with + ACTION:EMAIL (Apple amplification), X-MS-OLK-*, X-ALT-DESC HTML, + ATTACH with data: URIs, future iCal extensions we haven't heard + of — we rebuild a fresh calendar from a small allowlist of + well-understood RFC 5545 properties. + + VTIMEZONE blocks referenced by a kept event's ``TZID`` parameter + are preserved so events authored in non-UTC zones still render + correctly. VTIMEZONEs not referenced by any kept event are + dropped. + + See _VEVENT_KEEP / _PARAM_KEEP for the exact allowlist. + """ + # Work on a deep copy throughout: ``_filter_params`` mutates the + # value objects' ``params`` dicts, and we add components by + # reference to ``fresh``. Without the copy the caller's parsed + # cal would be silently mutilated after this returns. + cal = copy.deepcopy(cal) + + fresh = ICalendar() + # Always stamp our own PRODID. Preserving the input's would echo + # attacker branding ("Created by Evil Corp") into the user's + # calendar and is informationally useless for storage. VERSION + # is fixed at 2.0 (RFC 5545); CALSCALE is preserved when present. + fresh.add("PRODID", "-//messages//CalDAV interop//EN") + fresh.add("VERSION", "2.0") + if cal.get("CALSCALE"): + fresh.add("CALSCALE", str(cal["CALSCALE"])) + + # Pass 1: rebuild every VEVENT and learn which TZIDs they + # actually reference. + rebuilt_events = [] + referenced_tzids = set() + for vevent in cal.walk("VEVENT"): + clean = cls._rebuild_event(vevent) + if clean is None: + continue + for prop in ("DTSTART", "DTEND", "RECURRENCE-ID", "RDATE", "EXDATE"): + val = clean.get(prop) + if val is None: + continue + for v in val if isinstance(val, list) else [val]: + tzid = getattr(v, "params", {}).get("TZID") + if tzid: + referenced_tzids.add(str(tzid)) + rebuilt_events.append(clean) + + # Pass 2: preserve only the VTIMEZONEs our kept events use. + for vtz in cal.walk("VTIMEZONE"): + if str(vtz.get("TZID") or "") in referenced_tzids: + fresh.add_component(vtz) + + for evt in rebuilt_events: + fresh.add_component(evt) + + return fresh + + @classmethod + def _rebuild_event(cls, src): + """Build a fresh VEVENT containing only allowlisted properties. + + Returns ``None`` if the source has no UID (malformed event per + RFC 5545 — skip rather than store). A missing DTSTAMP is + synthesized (see fallback chain below). + + DTSTAMP caveat: iTIP sequencing (RFC 5546 §3.2.6) compares + SEQUENCE first, then DTSTAMP, to decide whether an inbound + update supersedes what's already stored. The mainstream + generators (Google, Outlook, Apple, …) all emit DTSTAMP on + every iTIP message, so this fallback only fires for minimal / + hand-crafted invites. When it does fire and we synthesize + ``now``, a later legitimate UPDATE that carries an *older* + DTSTAMP (because the organizer authored it before our store + observed the original) can be misread as outdated and dropped + — the user appears to be on a stale copy of the event. The + LAST-MODIFIED → CREATED preference makes this less likely by + preserving the authoring order when those are present; ``now`` + is a true last-resort that we accept can cause sequence + inversion on follow-up updates. + """ + fresh = ICalEvent() + for key in list(src.keys()): + upper = key.upper() + if upper not in _VEVENT_KEEP: + continue + value = src[key] + for item in value if isinstance(value, list) else [value]: + cleaned = cls._clean_property_value(upper, item) + if cleaned is None: + continue + cls._filter_params(upper, cleaned) + # ``encode=0``: the value is already a typed icalendar + # object (vText / vDDDTypes / vCalAddress / vRecur); we + # don't want add() to re-encode and lose the params. + fresh.add(key, cleaned, encode=0) + + if "UID" not in fresh: + return None + if "DTSTAMP" not in fresh: + # Prefer iTIP versioning info already present on the event + # (LAST-MODIFIED → CREATED) over server-now. Setting DTSTAMP + # to "now" effectively claims this event was authored this + # second, which breaks iTIP sequencing if the organizer ever + # sends an update; the original timestamps preserve order. + fallback = fresh.get("LAST-MODIFIED") or fresh.get("CREATED") + if fallback is not None and hasattr(fallback, "dt"): + fresh.add("DTSTAMP", fallback.dt) + else: + fresh.add("DTSTAMP", datetime.now(tz=timezone.utc)) + return fresh + + @staticmethod + def _clean_property_value(prop, value): + """Per-property validation. Returns ``None`` to drop the value.""" + if prop == "URL": + if not _SAFE_URL_RE.match(str(value)): + return None + elif prop == "RRULE": + # icalendar represents RRULE as a vRecur (dict-like) where + # each entry is a list (``{"FREQ": ["SECONDLY"]}``). + try: + freq = (value.get("FREQ") or [""])[0] + except (AttributeError, TypeError): + return None + if str(freq).upper() in _RRULE_FREQ_REQUIRES_BOUND: + if not (value.get("COUNT") or value.get("UNTIL")): + return None + return value + + @staticmethod + def _filter_params(prop, value): + """Drop X-* / unknown params from a property value, in place. + + Properties not listed in ``_PARAM_KEEP`` get all their parameters + stripped — default-deny matches the rebuild posture. + """ + params = getattr(value, "params", None) + if not params: + return + allowed = _PARAM_KEEP.get(prop, frozenset()) + for k in list(params.keys()): + if k.upper() not in allowed: + del params[k] + + @staticmethod + def _set_schedule_agent_client(cal): + """Stamp ``SCHEDULE-AGENT=CLIENT`` on every ORGANIZER (RFC 6638 §7.1). + + Tells the CalDAV server's scheduling extension that the client is + handling iTIP delivery itself, so the server MUST NOT auto-dispatch + REQUEST/REPLY/CANCEL messages on this PUT. Used by ``add_event`` + to keep "save a personal copy of this invite" from turning into a + mass-mailer via the server's scheduling plugin. + + **Call only from import paths.** The RSVP path + (``respond_to_event``) *wants* the server to dispatch the iTIP + REPLY back to the organizer, so it must NOT call this helper — + flipping SCHEDULE-AGENT to CLIENT there would silently suppress + decline/accept notifications while leaving the PUT itself + successful (and the user's success toast unchanged). The + ``test_rsvp_stores_sanitized_copy`` regression test pins the + invariant; do not relax it without replacing the notification + channel. + """ + for comp in cal.walk("VEVENT"): + organizer = comp.get("ORGANIZER") + if organizer is not None: + organizer.params["SCHEDULE-AGENT"] = "CLIENT" + + @staticmethod + def _update_partstat(cal, attendee_email, new_partstat): + """Update PARTSTAT (and drop RSVP=TRUE) for the given attendee, in-place. + + Returns True if at least one matching ATTENDEE was updated, False + otherwise. The boolean lets callers distinguish "RSVP recorded" + from "no-op write" — for ``respond_to_event``, a False result + means the iTIP REPLY would never reach the organizer. + """ + email_lower = attendee_email.lower() + updated = False + for comp in cal.walk("VEVENT"): + attendees = comp.get("ATTENDEE") + if attendees is None: + continue + if not isinstance(attendees, list): + attendees = [attendees] + for att in attendees: + addr = str(att).strip().lower() + if addr.startswith("mailto:"): + addr = addr[len("mailto:") :] + if addr != email_lower: + continue + att.params["PARTSTAT"] = new_partstat + att.params.pop("RSVP", None) + updated = True + return updated + + def _pick_calendar_url(self, calendar_id): + if calendar_id and not self._same_origin(calendar_id): + raise CalDAVError( + "Calendar URL host does not match the configured CalDAV server." + ) + # Filter to writable calendars: PUT to a read-only share would fail + # at the server anyway, and accepting a read-only id here turned + # the writeability filter on the list endpoint into UX polish + # rather than an enforced precondition. Now the two paths agree. + calendars = self.list_calendars(writable_only=True) + if not calendars: + raise CalDAVError("No writable calendars available on this CalDAV server.") + if calendar_id: + valid_ids = {c["id"] for c in calendars} + if calendar_id not in valid_ids: + raise CalDAVError( + "Calendar is not in this user's writable calendar list." + ) + return calendar_id + return calendars[0]["id"] + + def _same_origin(self, candidate_url): + """Whether ``candidate_url`` shares scheme + host + port with ``self.url``. + + Normalizes default ports so ``https://host/`` and + ``https://host:443/`` compare equal — otherwise the SSRF guard + falsely rejects legit deployments where the configured URL and + server-returned hrefs disagree on whether to include the port. + """ + cand = urlparse(candidate_url) + base = urlparse(self.url) + if not cand.scheme or not cand.hostname: + return False + defaults = {"http": 80, "https": 443} + + def _norm(parsed): + scheme = parsed.scheme.lower() + try: + port = parsed.port + except ValueError: + # Malformed port (e.g. non-numeric) → treat as no match. + return None + return (scheme, parsed.hostname.lower(), port or defaults.get(scheme)) + + return _norm(cand) is not None and _norm(cand) == _norm(base) + + def _put_event(self, calendar_url, ics_data): + uid = "" + try: + cal = ICalendar.from_ical(ics_data) + for comp in cal.walk("VEVENT"): + uid = str(comp.get("UID") or "") + break + except Exception: # pylint: disable=broad-exception-caught + logger.debug("Could not extract UID from ICS, using random", exc_info=True) + # UID comes from attacker-controlled ICS data — reject path + # separators, traversal sequences and CRLF/NUL before percent-encoding + # so the event URL cannot escape the calendar collection. + if uid and (any(c in uid for c in "/\\\r\n\x00") or ".." in uid): + uid = "" + if not uid: + uid = str(uuid.uuid4()) + + event_url = calendar_url.rstrip("/") + "/" + quote(uid, safe="") + ".ics" + data = ics_data.encode("utf-8") if isinstance(ics_data, str) else ics_data + self._request( + "PUT", + event_url, + data=data, + headers={"Content-Type": "text/calendar; charset=utf-8"}, + ) + + @classmethod + def from_channel(cls, channel): + """Create a CalDAVService from a Channel model instance. + + TODO(caldav-per-channel): there is no DRF write path for this yet. + ``ChannelSerializer.RESERVED_SETTINGS_KEYS`` rejects + ``username``/``password`` in plaintext ``settings``, and + ``encrypted_settings`` is not a serializer field — so the only + way to provision per-mailbox CalDAV credentials today is via the + Django admin / management commands / test factories. The code + path is kept live so the test suite exercises it and so the + future write path is a small, well-scoped change rather than a + new feature. + + Reads non-secret config from ``channel.settings``: + - ``url`` — CalDAV server URL. + Reads secrets from ``channel.encrypted_settings``: + - ``username`` — Basic Auth user. + - ``password`` — Basic Auth password. + + Storing credentials in ``settings`` (the plaintext JSONField) is + rejected at the serializer layer; the secrets MUST live in + ``encrypted_settings`` so a DB read does not surface them. + """ + settings = channel.settings or {} + secrets = channel.encrypted_settings or {} + url = settings.get("url") + if not url: + raise ValueError("CalDAV channel is missing 'url' in settings.") + # Per-channel URLs are user-supplied (channel configured by a + # mailbox admin via Django admin). Opt into the SSRF-pinned + # session: ``validate_hostname`` at session-creation time rejects + # private/loopback/metadata IPs, and the ``SSRFProtectedAdapter`` + # pins the resolved IP per request to defeat DNS-rebinding — + # neither check applies to ``from_instance_config`` because + # operator-supplied env vars are trusted (they may legitimately + # point at a private CalDAV instance on the same network). + return cls( + url=url, + username=secrets.get("username") or "", + password=secrets.get("password") or "", + ssrf_protect=True, + ) + + @classmethod + def from_instance_config(cls, username): + """Create a CalDAVService from the deployment-default CalDAV config. + + Used when no per-mailbox Channel overrides the integration (users + can point a Channel at any CalDAV provider; see + ``from_channel_or_instance``). + + Authenticates with HTTP Basic Auth: ``username`` is the acting + mailbox email, passed per-request, so the CalDAV server can + resolve the user's calendars via principal discovery. The password + is the single ``CALDAV_DEFAULT_PASSWORD`` value — at the protocol + level it is just an HTTP Basic password, but the same value is + sent for every mailbox, so it effectively authenticates + messages-as-a-service rather than any individual user. + + Trust model: see the comment block on ``CALDAV_DEFAULT_PASSWORD`` + in ``messages/settings.py``. In short: the CalDAV server trusts + whichever email messages claims to act as, so the load-bearing + safety property is that mailbox creation cannot mint a Mailbox + whose email belongs to another user on the CalDAV side. + """ + url = django_settings.CALDAV_DEFAULT_URL + password = django_settings.CALDAV_DEFAULT_PASSWORD + if not url or not password: + raise ValueError( + "Instance-level CalDAV is not configured " + "(CALDAV_DEFAULT_URL and CALDAV_DEFAULT_PASSWORD are required)." + ) + return cls(url=url, username=username, password=password) + + @classmethod + def from_channel_or_instance(cls, channel, username): + """Prefer a per-mailbox Channel, falling back to the default config. + + The per-mailbox path lets users override the integration to point + at a CalDAV provider of their choice with credentials they own. + The default path (``CALDAV_DEFAULT_*`` env vars) is the + deployment-wide fallback — see ``from_instance_config`` for its + trust model. + + ``username`` is the acting mailbox email; it is only used by the + default path (per-channel credentials are self-contained). + Returns None if neither is available. + """ + # TODO(caldav-per-channel): no DRF write path exists for CalDAV + # channels yet (see ``from_channel``). In practice this branch is + # only reached via admin/management/factory-provisioned rows. + if channel: + return cls.from_channel(channel) + + if ( + django_settings.CALDAV_DEFAULT_URL + and django_settings.CALDAV_DEFAULT_PASSWORD + ): + return cls.from_instance_config(username) + + return None diff --git a/src/backend/core/services/calendar/tasks.py b/src/backend/core/services/calendar/tasks.py new file mode 100644 index 00000000..9d44510d --- /dev/null +++ b/src/backend/core/services/calendar/tasks.py @@ -0,0 +1,188 @@ +"""Celery tasks for CalDAV calendar operations.""" + +from typing import Any, Dict + +from celery.utils.log import get_task_logger +from sentry_sdk import capture_exception + +from core.enums import ChannelTypes +from core.models import Channel +from core.services.calendar.service import CalDAVError, CalDAVService + +from messages.celery_app import app as celery_app + +logger = get_task_logger(__name__) + +# Generic, user-facing error wording. The exception text is logged + sent to +# Sentry for diagnosis, but is never surfaced to the API client — exception +# strings can include the CalDAV server URL, internal hostnames, or other +# details we do not want to render in a toast. +_RSVP_FAILURE = "Failed to send the RSVP." +_ADD_FAILURE = "Failed to add the event to the calendar." + + +def _get_caldav_service(channel_id: str | None, mailbox_email: str): + """Build a CalDAVService from a channel ID or instance-level config. + + ``mailbox_email`` is the acting mailbox's email. It is the Basic Auth + username for the instance-level path; per-channel auth is + self-contained so the email is ignored there. + """ + if channel_id: + channel = Channel.objects.get(id=channel_id, type=ChannelTypes.CALDAV) + return CalDAVService.from_channel(channel) + + return CalDAVService.from_instance_config(mailbox_email) + + +@celery_app.task(bind=True) +def calendar_rsvp_task( + self, # pylint: disable=unused-argument + channel_id: str | None, + mailbox_email: str, + ics_data: str, + response: str, + attendee_email: str, + calendar_id: str | None = None, +) -> Dict[str, Any]: + """ + Respond to a calendar event via CalDAV (RSVP). + + Args: + channel_id: UUID of the CalDAV channel, or None for instance config + mailbox_email: Acting mailbox email (Basic Auth user for instance config) + ics_data: Raw ICS content + response: ACCEPTED, DECLINED, or TENTATIVE + attendee_email: Email of the responding attendee + calendar_id: Optional specific calendar URL to use + """ + try: + service = _get_caldav_service(channel_id, mailbox_email) + except Channel.DoesNotExist as e: + # Race: the row existed when the viewset enqueued the task, gone + # by the time the worker ran. Worth a Sentry breadcrumb so we + # can see if this happens at any volume. + capture_exception(e) + logger.warning( + "CalDAV channel %s vanished between enqueue and execute", channel_id + ) + return { + "status": "FAILURE", + "result": None, + "error": "CalDAV channel not found.", + } + except ValueError as e: + # Configuration error (URL/password missing). User-facing message + # is intentionally generic; full detail is on the worker logs. + logger.warning("CalDAV service unavailable: %s", e) + return { + "status": "FAILURE", + "result": None, + "error": "CalDAV service is not configured.", + } + + try: + service.respond_to_event( + ics_data=ics_data, + response=response, + attendee_email=attendee_email, + calendar_id=calendar_id, + ) + + return { + "status": "SUCCESS", + "result": {"response": response}, + "error": None, + } + except CalDAVError as e: + # CalDAVError is the protocol-shaped error the service raises for + # known failure modes (no attendee match, SSRF-blocked URL, 4xx/5xx + # from server). Its message is safe to surface — it is composed + # by us, not by ``requests`` or the upstream — and is informative + # to the user (e.g. "Mailbox is not an attendee of this event"). + logger.warning("RSVP failed: %s", e) + return { + "status": "FAILURE", + "result": None, + "error": str(e), + } + except Exception as e: # pylint: disable=broad-exception-caught + # Anything else is unexpected. Stack to Sentry, generic copy to + # the client — raw ``str(e)`` from ``requests``/``icalendar`` can + # leak the CalDAV URL or other internal details. + capture_exception(e) + logger.exception("Error responding to calendar event") + return { + "status": "FAILURE", + "result": None, + "error": _RSVP_FAILURE, + } + + +@celery_app.task(bind=True) +def calendar_add_event_task( + self, # pylint: disable=unused-argument + channel_id: str | None, + mailbox_email: str, + ics_data: str, + calendar_id: str | None = None, +) -> Dict[str, Any]: + """ + Add a calendar event to a CalDAV calendar. + + Args: + channel_id: UUID of the CalDAV channel, or None for instance config + mailbox_email: Acting mailbox email (Basic Auth user for instance config) + ics_data: Raw ICS content + calendar_id: Optional specific calendar URL to use + """ + try: + service = _get_caldav_service(channel_id, mailbox_email) + except Channel.DoesNotExist as e: + # Race: the row existed when the viewset enqueued the task, gone + # by the time the worker ran. Worth a Sentry breadcrumb so we + # can see if this happens at any volume. + capture_exception(e) + logger.warning( + "CalDAV channel %s vanished between enqueue and execute", channel_id + ) + return { + "status": "FAILURE", + "result": None, + "error": "CalDAV channel not found.", + } + except ValueError as e: + # Configuration error (URL/password missing). User-facing message + # is intentionally generic; full detail is on the worker logs. + logger.warning("CalDAV service unavailable: %s", e) + return { + "status": "FAILURE", + "result": None, + "error": "CalDAV service is not configured.", + } + + try: + service.add_event(ics_data=ics_data, calendar_id=calendar_id) + + return { + "status": "SUCCESS", + "result": {"added": True}, + "error": None, + } + except CalDAVError as e: + # See ``calendar_rsvp_task`` — CalDAVError messages are + # user-composed and safe to surface. + logger.warning("Add-event failed: %s", e) + return { + "status": "FAILURE", + "result": None, + "error": str(e), + } + except Exception as e: # pylint: disable=broad-exception-caught + capture_exception(e) + logger.exception("Error adding calendar event") + return { + "status": "FAILURE", + "result": None, + "error": _ADD_FAILURE, + } diff --git a/src/backend/core/tasks.py b/src/backend/core/tasks.py index 7c62030c..3b1d626c 100644 --- a/src/backend/core/tasks.py +++ b/src/backend/core/tasks.py @@ -4,6 +4,7 @@ from core.mda.inbound_tasks import * # noqa: F403 from core.mda.outbound_tasks import * # noqa: F403 from core.services.blob_gc import * # noqa: F403 +from core.services.calendar.tasks import * # noqa: F403 from core.services.dns.tasks import * # noqa: F403 from core.services.importer.eml_tasks import * # noqa: F403 from core.services.importer.imap_tasks import * # noqa: F403 diff --git a/src/backend/core/tests/api/test_calendar.py b/src/backend/core/tests/api/test_calendar.py new file mode 100644 index 00000000..b4194201 --- /dev/null +++ b/src/backend/core/tests/api/test_calendar.py @@ -0,0 +1,1975 @@ +"""Tests for calendar API views using a real in-process Radicale CalDAV server.""" +# pylint: disable=redefined-outer-name, unused-argument, protected-access, missing-function-docstring, too-many-lines + +import shutil +import tempfile +import threading +from datetime import datetime, timedelta, timezone +from unittest import mock +from wsgiref.simple_server import WSGIRequestHandler, make_server + +import pytest +import radicale.app +import radicale.config +import requests +import requests.adapters +from icalendar import Calendar as ICalendar + +from core import factories +from core.enums import ChannelTypes, MailboxRoleChoices +from core.services.calendar.service import CalDAVError, CalDAVService + + +class _SilentHandler(WSGIRequestHandler): + """Suppress Radicale request logs during tests.""" + + def log_message(self, format, *args): # pylint: disable=redefined-builtin + pass + + +@pytest.fixture() +def radicale_server(): + """Start a real Radicale CalDAV server in a background thread.""" + tmpdir = tempfile.mkdtemp() + configuration = radicale.config.load() + configuration.update( + { + "storage": { + "filesystem_folder": tmpdir, + "type": "multifilesystem_nolock", + }, + "auth": {"type": "none"}, + }, + "test", + ) + + app = radicale.app.Application(configuration) + server = make_server("localhost", 0, app, handler_class=_SilentHandler) + port = server.server_address[1] + + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + + yield f"http://localhost:{port}" + + server.shutdown() + thread.join(timeout=5) + shutil.rmtree(tmpdir, ignore_errors=True) + + +RADICALE_USER = "testuser" +RADICALE_PASSWORD = "testpass" + + +def _mkcalendar(url, display_name, auth): + """Create a calendar on a CalDAV server via MKCALENDAR.""" + body = ( + '' + '' + "" + f"{display_name}" + "" + "" + ) + resp = requests.request( + "MKCALENDAR", + url, + data=body.encode("utf-8"), + auth=auth, + headers={"Content-Type": "application/xml; charset=utf-8"}, + timeout=5, + ) + resp.raise_for_status() + + +def _put_event(calendar_url, uid, ics, auth): + resp = requests.put( + calendar_url.rstrip("/") + f"/{uid}.ics", + data=ics.encode("utf-8"), + auth=auth, + headers={"Content-Type": "text/calendar; charset=utf-8"}, + timeout=5, + ) + resp.raise_for_status() + + +@pytest.fixture() +def radicale_with_calendar(radicale_server): + """Create a default calendar on the Radicale server. + + Returns (calendar_url, put_event_callable). + """ + calendar_url = f"{radicale_server}/{RADICALE_USER}/test-cal/" + _mkcalendar( + calendar_url, + "Test Calendar", + auth=(RADICALE_USER, RADICALE_PASSWORD), + ) + + def put_event(uid, ics): + _put_event(calendar_url, uid, ics, auth=(RADICALE_USER, RADICALE_PASSWORD)) + + return calendar_url, put_event + + +@pytest.fixture() +def caldav_channel(radicale_server, mailbox): + """Create a Channel of type caldav pointing at the Radicale server. + + Credentials live in ``encrypted_settings`` (never in plain ``settings``) + so a DB dump cannot surface them. + """ + return factories.ChannelFactory( + mailbox=mailbox, + type=ChannelTypes.CALDAV, + settings={ + "url": f"{radicale_server}/{RADICALE_USER}/", + }, + encrypted_settings={ + "username": RADICALE_USER, + "password": RADICALE_PASSWORD, + }, + ) + + +@pytest.fixture(autouse=True) +def _bypass_caldav_ssrf(request): + """Bypass the per-channel SSRF guard for tests. + + Production ``from_channel`` calls ``_build_ssrf_adapter`` which + rejects loopback / private IPs — but the whole test suite targets a + localhost Radicale, which production correctly refuses. Replace the + adapter builder with a plain ``HTTPAdapter`` for the duration of + each test so the channel-flavored path can reach the test server. + + Opt out by adding the ``caldav_ssrf_real`` marker to a test that + needs to verify the production guard fires. + """ + if "caldav_ssrf_real" in request.keywords: + yield + return + with mock.patch.object( + CalDAVService, + "_build_ssrf_adapter", + lambda self: requests.adapters.HTTPAdapter(), + ): + yield + + +@pytest.fixture() +def user_with_mailbox(mailbox): + """Create a user with access to the mailbox.""" + user = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, + user=user, + role=MailboxRoleChoices.ADMIN, + ) + return user + + +SAMPLE_ICS = """\ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +METHOD:REQUEST +BEGIN:VEVENT +UID:test-event-001@example.com +DTSTART:{dtstart} +DTEND:{dtend} +SUMMARY:Team Meeting +ORGANIZER:mailto:organizer@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:{attendee} +END:VEVENT +END:VCALENDAR""" + + +def _make_ics(mailbox, dtstart=None, dtend=None): + now = datetime.now(tz=timezone.utc) + dtstart = dtstart or now + timedelta(hours=1) + dtend = dtend or dtstart + timedelta(hours=1) + return SAMPLE_ICS.format( + dtstart=dtstart.strftime("%Y%m%dT%H%M%SZ"), + dtend=dtend.strftime("%Y%m%dT%H%M%SZ"), + attendee=str(mailbox), + ) + + +# --------------------------------------------------------------------------- +# Permission tests +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db() +class TestCalendarPermissions: + """Verify that calendar endpoints enforce mailbox access.""" + + def test_anonymous_cannot_access(self, api_client, mailbox, caldav_channel): + """Anonymous users are rejected.""" + base = f"/api/v1.0/mailboxes/{mailbox.id}/calendar" + assert api_client.get(f"{base}/calendars/").status_code == 401 + assert api_client.post(f"{base}/conflicts/", {}).status_code == 401 + assert api_client.post(f"{base}/rsvp/", {}).status_code == 401 + assert api_client.post(f"{base}/add/", {}).status_code == 401 + + def test_user_without_access_is_forbidden( + self, api_client, mailbox, caldav_channel, other_user + ): + """Authenticated user without MailboxAccess is rejected.""" + api_client.force_authenticate(user=other_user) + base = f"/api/v1.0/mailboxes/{mailbox.id}/calendar" + assert api_client.get(f"{base}/calendars/").status_code == 403 + assert api_client.post(f"{base}/conflicts/", {}).status_code == 403 + assert api_client.post(f"{base}/rsvp/", {}).status_code == 403 + assert api_client.post(f"{base}/add/", {}).status_code == 403 + + def test_user_with_access_is_allowed( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + radicale_with_calendar, + ): + """Authenticated user with MailboxAccess can reach the endpoints.""" + api_client.force_authenticate(user=user_with_mailbox) + base = f"/api/v1.0/mailboxes/{mailbox.id}/calendar" + + # calendars list should succeed + resp = api_client.get(f"{base}/calendars/") + assert resp.status_code == 200 + + def test_viewer_cannot_write(self, api_client, mailbox, caldav_channel): + """VIEWER-only access can read but not RSVP or add events. + + Writing into the mailbox's CalDAV calendar (RSVP / Add) produces + outbound iTIP traffic on the user's behalf. A user granted + read-only access to the mailbox must not be able to do that. + """ + viewer = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, + user=viewer, + role=MailboxRoleChoices.VIEWER, + ) + api_client.force_authenticate(user=viewer) + base = f"/api/v1.0/mailboxes/{mailbox.id}/calendar" + + # Write endpoints must be blocked. + assert api_client.post(f"{base}/rsvp/", {}).status_code == 403 + assert api_client.post(f"{base}/add/", {}).status_code == 403 + + def test_viewer_can_read_calendars_and_conflicts( + self, + api_client, + mailbox, + caldav_channel, + radicale_with_calendar, + ): + """VIEWER access can hit the read endpoints — calendar + permissions are mirrored from mailbox permissions, so a viewer + of the mailbox is intended to be able to see the calendar.""" + viewer = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, + user=viewer, + role=MailboxRoleChoices.VIEWER, + ) + api_client.force_authenticate(user=viewer) + base = f"/api/v1.0/mailboxes/{mailbox.id}/calendar" + + assert api_client.get(f"{base}/calendars/").status_code == 200 + now = datetime.now(tz=timezone.utc) + resp = api_client.post( + f"{base}/conflicts/", + { + "start": now.isoformat(), + "end": (now + timedelta(hours=1)).isoformat(), + }, + ) + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# Calendar list +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db() +class TestCalendarListView: + """Tests for the calendar list endpoint.""" + + def test_list_calendars( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + radicale_with_calendar, + ): + api_client.force_authenticate(user=user_with_mailbox) + resp = api_client.get(f"/api/v1.0/mailboxes/{mailbox.id}/calendar/calendars/") + assert resp.status_code == 200 + calendars = resp.json()["calendars"] + assert len(calendars) >= 1 + assert any(c["name"] == "Test Calendar" for c in calendars) + + def test_list_calendars_no_channel( + self, api_client, mailbox, user_with_mailbox, settings + ): + """Without a caldav channel, returns an empty list.""" + settings.CALDAV_DEFAULT_URL = None + settings.CALDAV_DEFAULT_PASSWORD = None + api_client.force_authenticate(user=user_with_mailbox) + resp = api_client.get(f"/api/v1.0/mailboxes/{mailbox.id}/calendar/calendars/") + assert resp.status_code == 200 + assert resp.json()["calendars"] == [] + + def test_list_calendars_requests_writable_only( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + ): + """The endpoint must ask the service for writable calendars only, + so read-only shares never reach the UI dropdown.""" + api_client.force_authenticate(user=user_with_mailbox) + with mock.patch.object(CalDAVService, "list_calendars") as list_calendars: + list_calendars.return_value = [ + { + "id": "https://caldav.example.com/rw/", + "name": "Writable", + "color": None, + } + ] + resp = api_client.get( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/calendars/" + ) + + assert resp.status_code == 200 + list_calendars.assert_called_once_with(writable_only=True) + names = [c["name"] for c in resp.json()["calendars"]] + assert names == ["Writable"] + + +# --------------------------------------------------------------------------- +# Conflicts +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db() +class TestCalendarConflictsView: + """Tests for the calendar conflicts endpoint.""" + + def test_check_conflicts_empty( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + radicale_with_calendar, + ): + """No events => no conflicts.""" + api_client.force_authenticate(user=user_with_mailbox) + now = datetime.now(tz=timezone.utc) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/conflicts/", + { + "start": (now + timedelta(hours=1)).isoformat(), + "end": (now + timedelta(hours=2)).isoformat(), + }, + ) + assert resp.status_code == 200 + assert resp.json()["conflicts"] == [] + + def test_check_conflicts_with_event( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + radicale_with_calendar, + ): + """An event in the time range is returned as a conflict.""" + _, put_event = radicale_with_calendar + now = datetime.now(tz=timezone.utc) + event_start = now + timedelta(hours=1) + event_end = event_start + timedelta(hours=1) + + put_event( + "conflict-test", + f"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:conflict-test@example.com +DTSTART:{event_start.strftime("%Y%m%dT%H%M%SZ")} +DTEND:{event_end.strftime("%Y%m%dT%H%M%SZ")} +SUMMARY:Existing Meeting +END:VEVENT +END:VCALENDAR""", + ) + + api_client.force_authenticate(user=user_with_mailbox) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/conflicts/", + { + "start": event_start.isoformat(), + "end": event_end.isoformat(), + }, + ) + assert resp.status_code == 200 + conflicts = resp.json()["conflicts"] + assert len(conflicts) >= 1 + assert any("Existing Meeting" in c["summary"] for c in conflicts) + + def test_check_conflicts_excludes_uid( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + radicale_with_calendar, + ): + """exclude_uid filters out prior imports of the same event.""" + _, put_event = radicale_with_calendar + now = datetime.now(tz=timezone.utc) + event_start = now + timedelta(hours=3) + event_end = event_start + timedelta(hours=1) + + shared_uid = "same-invite@example.com" + put_event( + "prior-import", + f"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:{shared_uid} +DTSTART:{event_start.strftime("%Y%m%dT%H%M%SZ")} +DTEND:{event_end.strftime("%Y%m%dT%H%M%SZ")} +SUMMARY:JZ fête +END:VEVENT +END:VCALENDAR""", + ) + + api_client.force_authenticate(user=user_with_mailbox) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/conflicts/", + { + "start": event_start.isoformat(), + "end": event_end.isoformat(), + "exclude_uid": shared_uid, + }, + ) + assert resp.status_code == 200 + conflicts = resp.json()["conflicts"] + assert not any(c.get("uid") == shared_uid for c in conflicts) + + def test_check_conflicts_missing_fields( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + ): + api_client.force_authenticate(user=user_with_mailbox) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/conflicts/", + {}, + ) + assert resp.status_code == 400 + + def test_check_conflicts_naive_datetime_is_400_not_502( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + ): + """A naive ISO datetime is a client-input error (400) — not a + CalDAV upstream failure (502).""" + api_client.force_authenticate(user=user_with_mailbox) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/conflicts/", + { + "start": "2026-06-01T10:00:00", # no tz + "end": "2026-06-01T11:00:00", + }, + ) + assert resp.status_code == 400 + + def test_check_conflicts_inverted_range_is_400( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + ): + api_client.force_authenticate(user=user_with_mailbox) + now = datetime.now(tz=timezone.utc) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/conflicts/", + { + "start": (now + timedelta(hours=2)).isoformat(), + "end": now.isoformat(), + }, + ) + assert resp.status_code == 400 + + def test_check_conflicts_response_does_not_leak_uid( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + radicale_with_calendar, + ): + """The conflicts response must not echo UIDs back to the client + (they can carry internal routing info; we only need them + server-side for the exclude_uid filter).""" + _, put_event = radicale_with_calendar + now = datetime.now(tz=timezone.utc) + start = now + timedelta(hours=3) + end = start + timedelta(hours=1) + put_event( + "no-leak", + f"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:incident-12345-prod@example.com +DTSTART:{start.strftime("%Y%m%dT%H%M%SZ")} +DTEND:{end.strftime("%Y%m%dT%H%M%SZ")} +SUMMARY:secret +END:VEVENT +END:VCALENDAR""", + ) + api_client.force_authenticate(user=user_with_mailbox) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/conflicts/", + {"start": start.isoformat(), "end": end.isoformat()}, + ) + assert resp.status_code == 200 + for c in resp.json()["conflicts"]: + assert "uid" not in c + + def test_check_conflicts_returns_existing_partstat( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + radicale_with_calendar, + ): + """When ``exclude_uid`` matches a stored event, the response carries + the responding mailbox's PARTSTAT so the UI can pre-select their + prior RSVP. Without this, a user who already accepted would be + re-prompted on every page load.""" + _, put_event = radicale_with_calendar + now = datetime.now(tz=timezone.utc) + start = now + timedelta(hours=5) + end = start + timedelta(hours=1) + uid = "already-accepted@example.com" + put_event( + "already-accepted", + f"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:{uid} +DTSTART:{start.strftime("%Y%m%dT%H%M%SZ")} +DTEND:{end.strftime("%Y%m%dT%H%M%SZ")} +SUMMARY:Prior import +ATTENDEE;PARTSTAT=ACCEPTED:mailto:{mailbox} +END:VEVENT +END:VCALENDAR""", + ) + api_client.force_authenticate(user=user_with_mailbox) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/conflicts/", + { + "start": start.isoformat(), + "end": end.isoformat(), + "exclude_uid": uid, + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["existing_partstat"] == "ACCEPTED" + # The excluded UID must not also appear as a conflict. + assert body["conflicts"] == [] + + def test_check_conflicts_existing_partstat_null_when_no_match( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + radicale_with_calendar, + ): + """No prior copy → ``existing_partstat`` is null, not an error.""" + api_client.force_authenticate(user=user_with_mailbox) + now = datetime.now(tz=timezone.utc) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/conflicts/", + { + "start": now.isoformat(), + "end": (now + timedelta(hours=1)).isoformat(), + "exclude_uid": "never-stored@example.com", + }, + ) + assert resp.status_code == 200 + assert resp.json()["existing_partstat"] is None + + def test_check_conflicts_no_channel( + self, api_client, mailbox, user_with_mailbox, settings + ): + """Without a caldav channel, returns 404.""" + settings.CALDAV_DEFAULT_URL = None + settings.CALDAV_DEFAULT_PASSWORD = None + api_client.force_authenticate(user=user_with_mailbox) + now = datetime.now(tz=timezone.utc) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/conflicts/", + { + "start": now.isoformat(), + "end": (now + timedelta(hours=1)).isoformat(), + }, + ) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# RSVP (task-based – we call the task synchronously via .apply()) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db() +class TestCalendarRsvpView: + """Tests for the calendar RSVP endpoint.""" + + def test_rsvp_accepted( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + radicale_with_calendar, + ): + api_client.force_authenticate(user=user_with_mailbox) + ics_data = _make_ics(mailbox) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/rsvp/", + { + "ics_data": ics_data, + "response": "ACCEPTED", + }, + ) + assert resp.status_code == 200 + assert "task_id" in resp.json() + + def test_rsvp_declined_stores_with_partstat_declined( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + radicale_with_calendar, + ): + """DECLINED also PUTs a stored copy with PARTSTAT=DECLINED. + + Until the organizer removes the user from ATTENDEEs they are + still invited, so the canonical record of the decline lives on + the user's calendar. Sabre/dav's broker emits the iTIP REPLY to + the organizer on PUT regardless of PARTSTAT value, so the + decline notification is the side-effect of this same PUT. + """ + calendar_url, _ = radicale_with_calendar + api_client.force_authenticate(user=user_with_mailbox) + uid = "decline-stored@example.com" + ics_data = SAMPLE_ICS.format( + dtstart="20260601T100000Z", + dtend="20260601T110000Z", + attendee=str(mailbox), + ).replace("test-event-001@example.com", uid) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/rsvp/", + {"ics_data": ics_data, "response": "DECLINED"}, + ) + assert resp.status_code == 200 + + # Tasks run eagerly under DevelopmentMinimal; the declined event + # is now on Radicale. Fetch and inspect. + stored = requests.get( + calendar_url.rstrip("/") + f"/{uid}.ics", + auth=(RADICALE_USER, RADICALE_PASSWORD), + timeout=5, + ) + assert stored.status_code == 200, stored.text + stored_cal = ICalendar.from_ical(stored.text) + vevent = stored_cal.walk("VEVENT")[0] + attendees = vevent.get("ATTENDEE") + if not isinstance(attendees, list): + attendees = [attendees] + # The mailbox attendee is recorded as DECLINED. + decliner = next( + a for a in attendees if str(a).lower().endswith(str(mailbox).lower()) + ) + assert decliner.params.get("PARTSTAT") == "DECLINED" + + def test_rsvp_invalid_response( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + ): + api_client.force_authenticate(user=user_with_mailbox) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/rsvp/", + { + "ics_data": "BEGIN:VCALENDAR\nEND:VCALENDAR", + "response": "INVALID", + }, + ) + assert resp.status_code == 400 + + def test_rsvp_missing_fields( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + ): + api_client.force_authenticate(user=user_with_mailbox) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/rsvp/", + {}, + ) + assert resp.status_code == 400 + + def test_respond_to_event_refuses_when_mailbox_not_in_attendees( + self, + caldav_channel, + radicale_with_calendar, + mailbox, + ): + """RSVP must refuse when the mailbox is not on the ATTENDEE list. + + Without that, ``_update_partstat`` would silently no-op (PUT + happens, but no PARTSTAT changes for the mailbox), the iTIP + REPLY never reaches the organizer, and the user sees a + misleading "Response saved — the organizer will be notified" + toast. Exercised at the service level because eager Celery + does not persist task results to the result backend, so the + task-polling endpoint cannot observe the FAILURE here. + """ + service = CalDAVService.from_channel(caldav_channel) + ics_no_user = ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//Test//Test//EN\r\n" + "BEGIN:VEVENT\r\n" + "UID:not-an-attendee@example.com\r\n" + "DTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260601T100000Z\r\n" + "DTEND:20260601T110000Z\r\n" + "SUMMARY:Forwarded invite\r\n" + "ORGANIZER:mailto:org@example.com\r\n" + "ATTENDEE:mailto:someone-else@example.com\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + with pytest.raises(CalDAVError, match="not an attendee"): + service.respond_to_event( + ics_data=ics_no_user, + response="ACCEPTED", + attendee_email=str(mailbox), + ) + + def test_rsvp_no_channel(self, api_client, mailbox, user_with_mailbox): + """Without a caldav channel, returns 404 and does NOT schedule a task.""" + api_client.force_authenticate(user=user_with_mailbox) + with mock.patch("core.api.viewsets.calendar.calendar_rsvp_task.delay") as delay: + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/rsvp/", + { + "ics_data": _make_ics(mailbox), + "response": "ACCEPTED", + }, + ) + assert resp.status_code == 404 + delay.assert_not_called() + + +# --------------------------------------------------------------------------- +# Add event +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db() +class TestCalendarAddEventView: + """Tests for the calendar add-event endpoint.""" + + def test_add_event( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + radicale_with_calendar, + ): + api_client.force_authenticate(user=user_with_mailbox) + ics_data = _make_ics(mailbox) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/add/", + {"ics_data": ics_data}, + ) + assert resp.status_code == 200 + assert "task_id" in resp.json() + + def test_add_event_missing_ics( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + ): + api_client.force_authenticate(user=user_with_mailbox) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/add/", + {}, + ) + assert resp.status_code == 400 + + def test_add_event_stores_sanitized_copy( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + radicale_with_calendar, + ): + """E2E: POST hostile ICS → fetch back from Radicale → assert the + stored copy has no VALARM, no X-*, no javascript URL, and that + SCHEDULE-AGENT=CLIENT is stamped on ORGANIZER.""" + calendar_url, _ = radicale_with_calendar + api_client.force_authenticate(user=user_with_mailbox) + uid = "e2e-add-event@example.com" + hostile_ics = ( + f"BEGIN:VCALENDAR\r\n" + f"VERSION:2.0\r\n" + f"PRODID:-//Evil//EN\r\n" + f"METHOD:REQUEST\r\n" + f"BEGIN:VEVENT\r\n" + f"UID:{uid}\r\n" + f"DTSTAMP:20260101T000000Z\r\n" + f"DTSTART:20260601T100000Z\r\n" + f"DTEND:20260601T110000Z\r\n" + f"SUMMARY:hostile\r\n" + f"ORGANIZER;X-EVIL=1:mailto:org@example.com\r\n" + f"ATTENDEE;X-PWN=yes:mailto:victim@target.example\r\n" + f"URL:javascript:alert(1)\r\n" + f"X-MS-OLK-CONFTYPE:0\r\n" + f"BEGIN:VALARM\r\nACTION:EMAIL\r\nTRIGGER:-PT15M\r\n" + f"DESCRIPTION:x\r\nATTENDEE:mailto:spam@target.example\r\n" + f"END:VALARM\r\n" + f"END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/add/", + {"ics_data": hostile_ics}, + ) + assert resp.status_code == 200, resp.content + + # Tasks run eagerly under DevelopmentMinimal; the event is now + # on Radicale. Fetch it back and inspect. + stored = requests.get( + calendar_url.rstrip("/") + f"/{uid}.ics", + auth=(RADICALE_USER, RADICALE_PASSWORD), + timeout=5, + ) + assert stored.status_code == 200, stored.text + stored_cal = ICalendar.from_ical(stored.text) + + assert "METHOD" not in stored_cal + vevent = stored_cal.walk("VEVENT")[0] + assert "URL" not in vevent # javascript: scheme dropped + for key in vevent.keys(): + assert not key.upper().startswith("X-"), key + assert not any(s.name == "VALARM" for s in vevent.subcomponents) + # Attacker PRODID replaced with ours. + assert "messages" in str(stored_cal["PRODID"]) + # SCHEDULE-AGENT=CLIENT stamped on ORGANIZER — sabre/dav will + # NOT auto-dispatch iTIP REQUEST emails on this PUT. + organizer = vevent.get("ORGANIZER") + assert organizer.params.get("SCHEDULE-AGENT") == "CLIENT" + assert "X-EVIL" not in {k.upper() for k in organizer.params} + # Attendee still there but X-* params gone. + attendees = vevent.get("ATTENDEE") + if not isinstance(attendees, list): + attendees = [attendees] + assert len(attendees) == 1 + assert "X-PWN" not in {k.upper() for k in attendees[0].params} + + def test_rsvp_stores_sanitized_copy( + self, + api_client, + mailbox, + caldav_channel, + user_with_mailbox, + radicale_with_calendar, + ): + """E2E for /rsvp/: hostile ICS → sanitized copy stored. The + RSVP path keeps default SCHEDULE-AGENT (so REPLY routes to + organizer) but still rebuilds everything else.""" + calendar_url, _ = radicale_with_calendar + api_client.force_authenticate(user=user_with_mailbox) + uid = "e2e-rsvp@example.com" + hostile_ics = ( + f"BEGIN:VCALENDAR\r\n" + f"VERSION:2.0\r\n" + f"PRODID:-//Evil//EN\r\n" + f"BEGIN:VEVENT\r\n" + f"UID:{uid}\r\n" + f"DTSTAMP:20260101T000000Z\r\n" + f"DTSTART:20260601T100000Z\r\n" + f"DTEND:20260601T110000Z\r\n" + f"SUMMARY:hostile\r\n" + f"ORGANIZER:mailto:org@example.com\r\n" + f"ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:{mailbox}\r\n" + f"BEGIN:VALARM\r\nACTION:EMAIL\r\nTRIGGER:-PT15M\r\n" + f"END:VALARM\r\n" + f"END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/rsvp/", + {"ics_data": hostile_ics, "response": "ACCEPTED"}, + ) + assert resp.status_code == 200, resp.content + + stored = requests.get( + calendar_url.rstrip("/") + f"/{uid}.ics", + auth=(RADICALE_USER, RADICALE_PASSWORD), + timeout=5, + ) + assert stored.status_code == 200, stored.text + stored_cal = ICalendar.from_ical(stored.text) + vevent = stored_cal.walk("VEVENT")[0] + # VALARM stripped on RSVP too. + assert not any(s.name == "VALARM" for s in vevent.subcomponents) + # PARTSTAT was updated to ACCEPTED before rebuild — the updated + # value survives the rebuild via the ATTENDEE param allowlist. + attendees = vevent.get("ATTENDEE") + if not isinstance(attendees, list): + attendees = [attendees] + assert attendees[0].params.get("PARTSTAT") == "ACCEPTED" + # RSVP=TRUE was popped by _update_partstat and not re-added. + assert "RSVP" not in attendees[0].params + # ORGANIZER kept default SCHEDULE-AGENT (so the server WILL + # dispatch the REPLY iTIP to the organizer). + organizer = vevent.get("ORGANIZER") + assert organizer.params.get("SCHEDULE-AGENT") != "CLIENT" + + def test_add_event_no_channel(self, api_client, mailbox, user_with_mailbox): + """Without a caldav channel, returns 404 and does NOT schedule a task.""" + api_client.force_authenticate(user=user_with_mailbox) + with mock.patch( + "core.api.viewsets.calendar.calendar_add_event_task.delay" + ) as delay: + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/add/", + {"ics_data": _make_ics(mailbox)}, + ) + assert resp.status_code == 404 + delay.assert_not_called() + + +# --------------------------------------------------------------------------- +# Instance-level CalDAV config (no per-mailbox channel) +# --------------------------------------------------------------------------- + + +# Static shared secret sent as the Basic Auth password. Radicale with +# auth.type=none ignores the actual value; production servers verify it. +INSTANCE_CALDAV_PASSWORD = "stub-shared-secret" + + +@pytest.fixture() +def instance_caldav_config(radicale_server, settings): + """Configure instance-level CalDAV settings pointing at Radicale. + + CALDAV_DEFAULT_URL is the CalDAV server root — the service resolves the + per-user calendar-home-set via principal discovery, using the mailbox + email as the Basic Auth username. + """ + settings.CALDAV_DEFAULT_URL = f"{radicale_server}/" + settings.CALDAV_DEFAULT_PASSWORD = INSTANCE_CALDAV_PASSWORD + + +@pytest.fixture() +def instance_calendar(radicale_server, mailbox): + """Create a calendar under the mailbox's principal on Radicale. + + Radicale with ``auth.type=none`` treats the Basic Auth username as the + principal name and serves calendars under ``/{username}/``. Since the + service now uses the mailbox email as the Basic Auth username, the + calendar must live under that path. + """ + cal_url = f"{radicale_server}/{mailbox}/instance-cal/" + _mkcalendar(cal_url, "Instance Calendar", auth=(str(mailbox), "ignored")) + return radicale_server + + +@pytest.mark.django_db() +class TestCalendarInstanceConfig: + """Tests using instance-level CalDAV settings instead of a per-mailbox Channel.""" + + def test_list_calendars( + self, + api_client, + mailbox, + user_with_mailbox, + instance_caldav_config, + instance_calendar, + ): + api_client.force_authenticate(user=user_with_mailbox) + resp = api_client.get(f"/api/v1.0/mailboxes/{mailbox.id}/calendar/calendars/") + assert resp.status_code == 200 + assert len(resp.json()["calendars"]) >= 1 + + def test_conflicts( + self, + api_client, + mailbox, + user_with_mailbox, + instance_caldav_config, + instance_calendar, + ): + api_client.force_authenticate(user=user_with_mailbox) + now = datetime.now(tz=timezone.utc) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/conflicts/", + { + "start": (now + timedelta(hours=1)).isoformat(), + "end": (now + timedelta(hours=2)).isoformat(), + }, + ) + assert resp.status_code == 200 + + def test_rsvp( + self, + api_client, + mailbox, + user_with_mailbox, + instance_caldav_config, + instance_calendar, + ): + api_client.force_authenticate(user=user_with_mailbox) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/rsvp/", + {"ics_data": _make_ics(mailbox), "response": "ACCEPTED"}, + ) + assert resp.status_code == 200 + assert "task_id" in resp.json() + + def test_add_event( + self, + api_client, + mailbox, + user_with_mailbox, + instance_caldav_config, + instance_calendar, + ): + api_client.force_authenticate(user=user_with_mailbox) + resp = api_client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/calendar/add/", + {"ics_data": _make_ics(mailbox)}, + ) + assert resp.status_code == 200 + assert "task_id" in resp.json() + + def test_channel_overrides_instance_config( + self, + api_client, + mailbox, + user_with_mailbox, + caldav_channel, + instance_caldav_config, + radicale_with_calendar, + ): + """Per-mailbox channel takes precedence over instance config.""" + api_client.force_authenticate(user=user_with_mailbox) + resp = api_client.get(f"/api/v1.0/mailboxes/{mailbox.id}/calendar/calendars/") + assert resp.status_code == 200 + assert len(resp.json()["calendars"]) >= 1 + + def test_no_config_returns_empty_calendars( + self, api_client, mailbox, user_with_mailbox, settings + ): + """Without channel or instance config, calendar list returns [].""" + settings.CALDAV_DEFAULT_URL = None + settings.CALDAV_DEFAULT_PASSWORD = None + api_client.force_authenticate(user=user_with_mailbox) + resp = api_client.get(f"/api/v1.0/mailboxes/{mailbox.id}/calendar/calendars/") + assert resp.status_code == 200 + assert resp.json()["calendars"] == [] + + def test_no_config_returns_404_for_actions( + self, api_client, mailbox, user_with_mailbox, settings + ): + """Without channel or instance config, action endpoints return 404.""" + settings.CALDAV_DEFAULT_URL = None + settings.CALDAV_DEFAULT_PASSWORD = None + api_client.force_authenticate(user=user_with_mailbox) + base = f"/api/v1.0/mailboxes/{mailbox.id}/calendar" + assert ( + api_client.post( + f"{base}/rsvp/", + {"ics_data": _make_ics(mailbox), "response": "ACCEPTED"}, + ).status_code + == 404 + ) + assert ( + api_client.post( + f"{base}/add/", {"ics_data": _make_ics(mailbox)} + ).status_code + == 404 + ) + now = datetime.now(tz=timezone.utc) + assert ( + api_client.post( + f"{base}/conflicts/", + { + "start": now.isoformat(), + "end": (now + timedelta(hours=1)).isoformat(), + }, + ).status_code + == 404 + ) + + +# --------------------------------------------------------------------------- +# Unit tests for CalDAVService helpers (no network) +# --------------------------------------------------------------------------- + + +class TestUpdatePartstat: + """Direct tests for PARTSTAT rewriting, independent of any CalDAV server.""" + + def _build( + self, + attendee_line="ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:me@example.com", + ): + ics = ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//Test//Test//EN\r\n" + "BEGIN:VEVENT\r\n" + "UID:x@example.com\r\n" + "DTSTART:20260101T120000Z\r\n" + "DTEND:20260101T130000Z\r\n" + "SUMMARY:X\r\n" + f"{attendee_line}\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" + ) + return ICalendar.from_ical(ics) + + @staticmethod + def _attendee(cal): + vevent = cal.walk("VEVENT")[0] + att = vevent.get("ATTENDEE") + if isinstance(att, list): + att = att[0] + return att + + def test_updates_existing_partstat_and_drops_rsvp(self): + cal = self._build() + CalDAVService._update_partstat(cal, "me@example.com", "ACCEPTED") + att = self._attendee(cal) + assert att.params["PARTSTAT"] == "ACCEPTED" + assert "RSVP" not in att.params + + def test_adds_partstat_when_missing(self): + cal = self._build("ATTENDEE:mailto:me@example.com") + CalDAVService._update_partstat(cal, "me@example.com", "DECLINED") + att = self._attendee(cal) + assert att.params["PARTSTAT"] == "DECLINED" + + def test_case_insensitive_email_match(self): + cal = self._build("ATTENDEE:mailto:ME@Example.COM") + CalDAVService._update_partstat(cal, "me@example.com", "TENTATIVE") + att = self._attendee(cal) + assert att.params["PARTSTAT"] == "TENTATIVE" + + def test_leaves_other_attendees_untouched(self): + ics = ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//Test//Test//EN\r\n" + "BEGIN:VEVENT\r\n" + "UID:x@example.com\r\n" + "DTSTART:20260101T120000Z\r\n" + "DTEND:20260101T130000Z\r\n" + "SUMMARY:X\r\n" + "ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:other@example.com\r\n" + "ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:me@example.com\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" + ) + cal = ICalendar.from_ical(ics) + CalDAVService._update_partstat(cal, "me@example.com", "ACCEPTED") + + attendees = cal.walk("VEVENT")[0].get("ATTENDEE") + by_email = {str(a).lower(): a for a in attendees} + assert by_email["mailto:me@example.com"].params["PARTSTAT"] == "ACCEPTED" + assert by_email["mailto:other@example.com"].params["PARTSTAT"] == "NEEDS-ACTION" + + def test_returns_true_on_match(self): + """The boolean return value lets callers distinguish "RSVP recorded" + from "no-op write" — for ``respond_to_event`` the latter would + silently ship an RSVP that never reaches the organizer.""" + cal = self._build() + assert CalDAVService._update_partstat(cal, "me@example.com", "ACCEPTED") is True + + def test_returns_false_when_no_attendee_matches(self): + cal = self._build("ATTENDEE:mailto:other@example.com") + assert ( + CalDAVService._update_partstat(cal, "me@example.com", "ACCEPTED") is False + ) + + def test_substring_email_does_not_match(self): + """An attendee whose address contains the target as a substring must + not be updated; only an exact email match is.""" + ics = ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//Test//Test//EN\r\n" + "BEGIN:VEVENT\r\n" + "UID:x@example.com\r\n" + "DTSTART:20260101T120000Z\r\n" + "DTEND:20260101T130000Z\r\n" + "SUMMARY:X\r\n" + "ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:notme@example.com\r\n" + "ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:me@example.com\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" + ) + cal = ICalendar.from_ical(ics) + CalDAVService._update_partstat(cal, "me@example.com", "ACCEPTED") + + attendees = cal.walk("VEVENT")[0].get("ATTENDEE") + by_email = {str(a).lower(): a for a in attendees} + assert by_email["mailto:me@example.com"].params["PARTSTAT"] == "ACCEPTED" + assert by_email["mailto:notme@example.com"].params["PARTSTAT"] == "NEEDS-ACTION" + + +class TestRebuildForStorage: + """Default-deny ICS rebuild before storing on the CalDAV server. + + The rebuild keeps a fixed allowlist of RFC 5545 properties; everything + else (VALARM, ATTACH, X-*, future iCal extensions) is dropped. This + defends against: + + - Apple-style VALARM ACTION:EMAIL amplification (RFC 9074 §9). + - X-ALT-DESC;FMTTYPE=text/html and other X-* injection vectors. + - sabre/dav auto-dispatch of iTIP REQUEST on PUTs with + ORGANIZER+ATTENDEE — covered separately by SCHEDULE-AGENT=CLIENT + on the ``/add/`` path. + """ + + @staticmethod + def _hostile_ics(): + return ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//Evil Corp//EN\r\n" + "METHOD:REQUEST\r\n" + "BEGIN:VEVENT\r\n" + "UID:evt@example.com\r\n" + "DTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260601T100000Z\r\n" + "DTEND:20260601T110000Z\r\n" + "SUMMARY:test\r\n" + "DESCRIPTION:hi\r\n" + "ORGANIZER;CN=Attacker;X-EVIL=1:mailto:attacker@example.com\r\n" + "ATTENDEE;CN=v1;X-PWN=yes:mailto:victim1@target.example\r\n" + "ATTENDEE:mailto:victim2@target.example\r\n" + "ATTACH:data:text/html,\r\n" + "X-MS-OLK-CONFTYPE:0\r\n" + "X-ALT-DESC;FMTTYPE=text/html:evil\r\n" + "URL:javascript:alert(1)\r\n" + "BEGIN:VALARM\r\n" + "ACTION:EMAIL\r\n" + "TRIGGER:-PT15M\r\n" + "SUMMARY:Reminder\r\n" + "DESCRIPTION:You have a meeting\r\n" + "ATTENDEE:mailto:spam@target.example\r\n" + "END:VALARM\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" + ) + + def _rebuild(self, ics): + return CalDAVService._rebuild_for_storage(ICalendar.from_ical(ics)) + + def test_drops_method(self): + out = self._rebuild(self._hostile_ics()) + assert "METHOD" not in out + + def test_drops_all_valarms(self): + """Apple's well-known ACTION:EMAIL amplification turns the + calendar account into a mailer — VALARM must never survive.""" + out = self._rebuild(self._hostile_ics()) + for comp in out.walk("VEVENT"): + assert not any(s.name == "VALARM" for s in comp.subcomponents) + + def test_drops_attach(self): + out = self._rebuild(self._hostile_ics()) + for comp in out.walk("VEVENT"): + assert "ATTACH" not in comp + + def test_drops_x_properties(self): + out = self._rebuild(self._hostile_ics()) + for comp in out.walk("VEVENT"): + for key in comp.keys(): + assert not key.upper().startswith("X-"), key + + def test_drops_javascript_url(self): + """URL with unsafe schemes is dropped (defense against + ``javascript:``/``data:``/``file:``).""" + out = self._rebuild(self._hostile_ics()) + vevent = out.walk("VEVENT")[0] + assert "URL" not in vevent + + def test_keeps_http_url(self): + ics = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\n" + "BEGIN:VEVENT\r\nUID:u\r\nDTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260601T100000Z\r\nDTEND:20260601T110000Z\r\n" + "URL:https://meet.example.com/abc\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + out = self._rebuild(ics) + assert str(out.walk("VEVENT")[0]["URL"]) == "https://meet.example.com/abc" + + def test_strips_x_params_from_attendee(self): + out = self._rebuild(self._hostile_ics()) + for att in out.walk("VEVENT")[0].get("ATTENDEE"): + assert all(not k.upper().startswith("X-") for k in att.params) + + def test_strips_x_params_from_organizer(self): + out = self._rebuild(self._hostile_ics()) + organizer = out.walk("VEVENT")[0].get("ORGANIZER") + assert "X-EVIL" not in {k.upper() for k in organizer.params} + + def test_preserves_attendees(self): + """Attendees are kept (users want to see who else was invited). + iTIP suppression on /add/ is handled via SCHEDULE-AGENT, not by + stripping attendees.""" + out = self._rebuild(self._hostile_ics()) + attendees = out.walk("VEVENT")[0].get("ATTENDEE") + if not isinstance(attendees, list): + attendees = [attendees] + assert len(attendees) == 2 + + def test_drops_event_without_uid(self): + ics = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\n" + "BEGIN:VEVENT\r\nDTSTART:20260601T100000Z\r\n" + "DTEND:20260601T110000Z\r\nSUMMARY:no-uid\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + out = self._rebuild(ics) + assert len(out.walk("VEVENT")) == 0 + + def test_synthesizes_missing_dtstamp(self): + ics = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\n" + "BEGIN:VEVENT\r\nUID:u\r\n" + "DTSTART:20260601T100000Z\r\nDTEND:20260601T110000Z\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + out = self._rebuild(ics) + assert "DTSTAMP" in out.walk("VEVENT")[0] + + def test_rejects_unbounded_secondly_rrule(self): + """SECONDLY without COUNT/UNTIL froze Thunderbird (Mozilla + 1770984) — drop the RRULE so the event becomes a single + occurrence rather than infinite expansion.""" + ics = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\n" + "BEGIN:VEVENT\r\nUID:u\r\nDTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260601T100000Z\r\nDTEND:20260601T110000Z\r\n" + "RRULE:FREQ=SECONDLY\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + out = self._rebuild(ics) + assert "RRULE" not in out.walk("VEVENT")[0] + + def test_keeps_bounded_secondly_rrule(self): + ics = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\n" + "BEGIN:VEVENT\r\nUID:u\r\nDTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260601T100000Z\r\nDTEND:20260601T110000Z\r\n" + "RRULE:FREQ=SECONDLY;COUNT=10\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + out = self._rebuild(ics) + assert "RRULE" in out.walk("VEVENT")[0] + + def test_preserves_referenced_vtimezone(self): + """VTIMEZONE blocks whose TZID is referenced by a kept event's + DTSTART/DTEND must survive — otherwise non-UTC events render at + the wrong time.""" + ics = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\n" + "BEGIN:VTIMEZONE\r\nTZID:Europe/Paris\r\n" + "BEGIN:STANDARD\r\nDTSTART:19710101T000000\r\n" + "TZOFFSETFROM:+0100\r\nTZOFFSETTO:+0100\r\nTZNAME:CET\r\n" + "END:STANDARD\r\nEND:VTIMEZONE\r\n" + "BEGIN:VEVENT\r\nUID:u\r\nDTSTAMP:20260101T000000Z\r\n" + "DTSTART;TZID=Europe/Paris:20260601T100000\r\n" + "DTEND;TZID=Europe/Paris:20260601T110000\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + out = self._rebuild(ics) + tzids = {str(vtz.get("TZID")) for vtz in out.walk("VTIMEZONE")} + assert "Europe/Paris" in tzids + + def test_drops_unreferenced_vtimezone(self): + """A VTIMEZONE that no kept event references is dead weight.""" + ics = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\n" + "BEGIN:VTIMEZONE\r\nTZID:America/Phantom\r\n" + "BEGIN:STANDARD\r\nDTSTART:19710101T000000\r\n" + "TZOFFSETFROM:-0500\r\nTZOFFSETTO:-0500\r\nTZNAME:EST\r\n" + "END:STANDARD\r\nEND:VTIMEZONE\r\n" + "BEGIN:VEVENT\r\nUID:u\r\nDTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260601T100000Z\r\nDTEND:20260601T110000Z\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + out = self._rebuild(ics) + assert len(out.walk("VTIMEZONE")) == 0 + + def test_does_not_mutate_input(self): + """The rebuild must not modify the caller's parsed cal — the + old in-place ``_filter_params`` would corrupt it via shared + value objects.""" + ics = self._hostile_ics() + cal = ICalendar.from_ical(ics) + before = cal.to_ical() + _ = CalDAVService._rebuild_for_storage(cal) + # Input must be byte-identical after rebuild. + assert cal.to_ical() == before + + def test_always_uses_our_prodid(self): + """The input's PRODID is attacker-controlled branding. Always + stamp our own so the stored event isn't labeled "Created by + Evil Corp" in the user's calendar app.""" + ics = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Evil//EN\r\n" + "BEGIN:VEVENT\r\nUID:u\r\nDTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260601T100000Z\r\nDTEND:20260601T110000Z\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + out = self._rebuild(ics) + assert "messages" in str(out["PRODID"]) + assert "Evil" not in str(out["PRODID"]) + + def test_dtstamp_fallback_prefers_last_modified(self): + """Synthesizing DTSTAMP from server-now breaks iTIP versioning; + prefer LAST-MODIFIED → CREATED → now.""" + ics = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\n" + "BEGIN:VEVENT\r\nUID:u\r\n" + "DTSTART:20260601T100000Z\r\nDTEND:20260601T110000Z\r\n" + "LAST-MODIFIED:20260515T120000Z\r\n" + "CREATED:20260501T000000Z\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + out = self._rebuild(ics) + # LAST-MODIFIED wins over CREATED. + assert out.walk("VEVENT")[0]["DTSTAMP"].dt == datetime( + 2026, 5, 15, 12, 0, 0, tzinfo=timezone.utc + ) + + def test_url_rejects_leading_whitespace_javascript(self): + """Browsers strip leading whitespace before scheme — a value + like " javascript:..." can become a script URL on click. The + regex must NOT accept it as 'http(s)'.""" + ics = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\n" + "BEGIN:VEVENT\r\nUID:u\r\nDTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260601T100000Z\r\nDTEND:20260601T110000Z\r\n" + "URL: javascript:alert(1)\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + out = self._rebuild(ics) + assert "URL" not in out.walk("VEVENT")[0] + + def test_url_accepts_uppercase_https_scheme(self): + """Browsers lowercase schemes; we should too.""" + ics = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\n" + "BEGIN:VEVENT\r\nUID:u\r\nDTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260601T100000Z\r\nDTEND:20260601T110000Z\r\n" + "URL:HTTPS://example.com/foo\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + out = self._rebuild(ics) + assert "URL" in out.walk("VEVENT")[0] + + +class TestScheduleAgentClient: + """RFC 6638 §7.1 opt-out: SCHEDULE-AGENT=CLIENT on ORGANIZER tells the + CalDAV server not to auto-dispatch iTIP messages on PUT — the standard + way to prevent /add/ from becoming a mass-mailer.""" + + def test_sets_schedule_agent_client_on_organizer(self): + ics = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\n" + "BEGIN:VEVENT\r\nUID:u\r\nDTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260601T100000Z\r\nDTEND:20260601T110000Z\r\n" + "ORGANIZER:mailto:a@example.com\r\n" + "ATTENDEE:mailto:b@example.com\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + cal = ICalendar.from_ical(ics) + CalDAVService._set_schedule_agent_client(cal) + organizer = cal.walk("VEVENT")[0].get("ORGANIZER") + assert organizer.params.get("SCHEDULE-AGENT") == "CLIENT" + + def test_handles_missing_organizer(self): + ics = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\n" + "BEGIN:VEVENT\r\nUID:u\r\nDTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260601T100000Z\r\nDTEND:20260601T110000Z\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + cal = ICalendar.from_ical(ics) + # Must not raise. + CalDAVService._set_schedule_agent_client(cal) + + +class TestParseColor: + """Validate CalDAV ``calendar-color`` parsing — only hex passes.""" + + def test_six_hex(self): + assert CalDAVService._parse_color("#1a2b3c") == "#1a2b3c" + + def test_three_hex(self): + assert CalDAVService._parse_color("#abc") == "#abc" + + def test_eight_hex_trims_alpha(self): + assert CalDAVService._parse_color("#1a2b3cff") == "#1a2b3c" + + def test_rejects_named_color(self): + assert CalDAVService._parse_color("red") is None + + def test_rejects_rgb_function(self): + assert CalDAVService._parse_color("rgb(255,0,0)") is None + + def test_rejects_css_injection(self): + assert CalDAVService._parse_color("#fff; }body{display:none") is None + + def test_empty_returns_none(self): + assert CalDAVService._parse_color("") is None + assert CalDAVService._parse_color(None) is None + + +class TestRequestSameOriginGuard: + """``_request`` must refuse to talk to any host but the configured one.""" + + def test_request_refuses_cross_origin(self): + service = CalDAVService(url="https://caldav.example.com/home/") + with pytest.raises(CalDAVError): + service._request("GET", "https://attacker.example.org/leak") + + def test_request_refuses_relative_url(self): + """Relative URLs have no scheme/netloc — _same_origin returns + False; defense-in-depth against a malformed PROPFIND href.""" + service = CalDAVService(url="https://caldav.example.com/home/") + with pytest.raises(CalDAVError): + service._request("GET", "/leak") + + def test_request_refuses_userinfo_confusion(self): + """https://trusted.com@attacker.com/ has hostname=attacker.com + per RFC 3986 — must be rejected.""" + service = CalDAVService(url="https://caldav.example.com/") + with pytest.raises(CalDAVError): + service._request( + "GET", "https://caldav.example.com@attacker.example.org/leak" + ) + + def test_request_disables_redirects(self): + """The same-origin guard only validates the initial URL — without + ``allow_redirects=False`` a 302 from a (third-party) CalDAV server + would bypass it. Verify the kwarg is forced even if a caller did + not pass it.""" + service = CalDAVService(url="https://caldav.example.com/") + captured = {} + + class _StubSession: + def request(self, method, url, **kwargs): + captured["allow_redirects"] = kwargs.get("allow_redirects") + + class _Resp: + status_code = 200 + text = "" + + return _Resp() + + service._session = _StubSession() # type: ignore[assignment] + service._request("GET", "https://caldav.example.com/cal/") + assert captured["allow_redirects"] is False + + +class TestSameOriginPortNormalization: + """Default ports (80/443) must compare equal whether they're explicit + in the URL or implicit — otherwise legit setups break.""" + + def test_https_default_port_matches_implicit(self): + s = CalDAVService(url="https://caldav.example.com/") + assert s._same_origin("https://caldav.example.com:443/cal/") + + def test_https_implicit_matches_default_port(self): + s = CalDAVService(url="https://caldav.example.com:443/") + assert s._same_origin("https://caldav.example.com/cal/") + + def test_http_default_port_matches_implicit(self): + s = CalDAVService(url="http://caldav.example.com/") + assert s._same_origin("http://caldav.example.com:80/cal/") + + def test_explicit_nonstandard_port_must_match(self): + s = CalDAVService(url="https://caldav.example.com:8443/") + assert s._same_origin("https://caldav.example.com:8443/cal/") + assert not s._same_origin("https://caldav.example.com/cal/") + assert not s._same_origin("https://caldav.example.com:443/cal/") + + def test_different_scheme_not_same_origin(self): + s = CalDAVService(url="https://caldav.example.com/") + assert not s._same_origin("http://caldav.example.com/cal/") + + def test_case_insensitive_hostname(self): + s = CalDAVService(url="https://caldav.example.com/") + assert s._same_origin("https://Caldav.Example.COM/cal/") + + +@pytest.mark.django_db() +@pytest.mark.caldav_ssrf_real +class TestChannelSsrfGuard: + """Verify the production SSRF guard fires on the per-channel path. + + This class opts out of the autouse bypass (see ``_bypass_caldav_ssrf``) + so we test the real ``_build_ssrf_adapter`` — the rest of the suite + targets localhost which the production guard correctly rejects. + """ + + def test_loopback_is_rejected(self, mailbox): + + channel = factories.ChannelFactory( + mailbox=mailbox, + type=ChannelTypes.CALDAV, + settings={"url": "http://localhost:9999/"}, + encrypted_settings={"username": "u", "password": "p"}, + ) + service = CalDAVService.from_channel(channel) + # The adapter is built lazily on first request. Force a request + # so the guard fires. + with pytest.raises(CalDAVError, match="loopback|private"): + service._request("GET", "http://localhost:9999/") + + def test_ip_literal_is_rejected(self, mailbox): + """Real CalDAV providers use domain names. Per-channel URLs with a + raw IP literal (public OR private) are rejected outright so the + per-channel guard can rely on DNS for the private-IP check.""" + + channel = factories.ChannelFactory( + mailbox=mailbox, + type=ChannelTypes.CALDAV, + settings={"url": "http://192.168.0.1/"}, + encrypted_settings={"username": "u", "password": "p"}, + ) + service = CalDAVService.from_channel(channel) + with pytest.raises(CalDAVError, match="IP addresses are not allowed"): + service._request("GET", "http://192.168.0.1/") + + def test_unsupported_scheme_is_rejected(self, mailbox): + """``file://`` (and any non-http(s)) is rejected when the adapter + builds. Belt-and-braces with the ``_same_origin`` guard, which + already refuses URLs without a hostname.""" + + channel = factories.ChannelFactory( + mailbox=mailbox, + type=ChannelTypes.CALDAV, + settings={"url": "file:///etc/passwd"}, + encrypted_settings={"username": "u", "password": "p"}, + ) + service = CalDAVService.from_channel(channel) + # Force adapter creation explicitly so the scheme check fires + # (vs. the same-origin check, which would also reject this URL + # but via a different code path). + with pytest.raises(CalDAVError, match="scheme"): + service._build_ssrf_adapter() + + +class TestPickCalendarUrl: + """Direct tests for calendar URL selection / SSRF guard.""" + + def _service_with_calendars(self, calendar_ids): + service = CalDAVService(url="https://caldav.example.com/") + # ``_pick_calendar_url`` calls ``list_calendars(writable_only=True)``; + # accept (and ignore) the kwarg in the stub so this helper exercises + # the same code path as production. + service.list_calendars = lambda **_kw: [ # type: ignore[method-assign] + {"id": cid, "name": cid} for cid in calendar_ids + ] + return service + + def test_returns_first_when_no_id_given(self): + service = self._service_with_calendars( + ["https://caldav.example.com/u/cal1/", "https://caldav.example.com/u/cal2/"] + ) + assert service._pick_calendar_url(None) == "https://caldav.example.com/u/cal1/" + + def test_accepts_known_calendar_id(self): + service = self._service_with_calendars( + ["https://caldav.example.com/u/cal1/", "https://caldav.example.com/u/cal2/"] + ) + assert ( + service._pick_calendar_url("https://caldav.example.com/u/cal2/") + == "https://caldav.example.com/u/cal2/" + ) + + def test_rejects_arbitrary_url(self): + """An attacker-controlled URL must not be used as a calendar target.""" + service = self._service_with_calendars(["https://caldav.example.com/u/cal1/"]) + with pytest.raises(CalDAVError): + service._pick_calendar_url("https://attacker.example.org/evil/") + + def test_rejects_cross_origin_before_listing(self): + """The origin check must reject foreign hosts even if list_calendars() + somehow returned a matching entry — defense in depth.""" + service = CalDAVService(url="https://caldav.example.com/") + called = {"n": 0} + + def _spy(**_kw): + called["n"] += 1 + return [{"id": "https://attacker.example.org/evil/", "name": "evil"}] + + service.list_calendars = _spy # type: ignore[method-assign] + with pytest.raises(CalDAVError): + service._pick_calendar_url("https://attacker.example.org/evil/") + assert called["n"] == 0 + + def test_rejects_scheme_relative_or_malformed(self): + service = self._service_with_calendars(["https://caldav.example.com/u/cal1/"]) + with pytest.raises(CalDAVError): + service._pick_calendar_url("/u/cal1/") + + def test_rejects_unknown_calendar_on_same_host(self): + service = self._service_with_calendars(["https://caldav.example.com/u/cal1/"]) + with pytest.raises(CalDAVError): + service._pick_calendar_url("https://caldav.example.com/u/other/") + + def test_raises_when_no_calendars(self): + service = self._service_with_calendars([]) + with pytest.raises(CalDAVError): + service._pick_calendar_url(None) + + def test_requests_writable_only_calendars(self): + """``_pick_calendar_url`` must consult writable calendars only — + otherwise a read-only shared id passes selection and only fails + later at PUT time, with a misleading error.""" + service = CalDAVService(url="https://caldav.example.com/") + seen_kwargs = {} + + def _spy(**kw): + seen_kwargs.update(kw) + return [{"id": "https://caldav.example.com/u/cal1/", "name": "cal1"}] + + service.list_calendars = _spy # type: ignore[method-assign] + service._pick_calendar_url(None) + assert seen_kwargs == {"writable_only": True} + + +# --------------------------------------------------------------------------- +# UID sanitization in _put_event (defends against attacker-controlled ICS) +# --------------------------------------------------------------------------- + + +class TestPutEventUidSanitization: + """The UID extracted from an ICS file flows into the PUT URL; malicious + UIDs (path separators, traversal, CRLF) must never reach the server + verbatim — they must either be percent-encoded or replaced by a UUID.""" + + @staticmethod + def _ics_with_uid(uid): + return ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "BEGIN:VEVENT\r\n" + f"UID:{uid}\r\n" + "SUMMARY:test\r\n" + "DTSTART:20260516T100000Z\r\n" + "DTEND:20260516T110000Z\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" + ) + + def _capture_put_url(self, ics_data): + service = CalDAVService(url="https://caldav.example.com/home/") + seen = {} + + def _fake_request(method, url, **_kwargs): + seen["method"] = method + seen["url"] = url + + class _R: + status_code = 201 + text = "" + + return _R() + + service._request = _fake_request # type: ignore[method-assign] + service._put_event("https://caldav.example.com/home/cal/", ics_data) + return seen["url"] + + def test_path_separator_uid_does_not_escape_collection(self): + url = self._capture_put_url(self._ics_with_uid("../../etc/passwd")) + # Either the UID got percent-encoded (no raw slashes) or it was + # rejected and replaced by a generated UUID. Either way the PUT URL + # must stay inside the original calendar collection. + assert url.startswith("https://caldav.example.com/home/cal/") + tail = url[len("https://caldav.example.com/home/cal/") :] + assert "/" not in tail + assert ".." not in tail + assert tail.endswith(".ics") + + def test_crlf_uid_is_neutralized(self): + url = self._capture_put_url(self._ics_with_uid("evil\r\nX-Header: pwn")) + # No literal CR/LF may survive into the URL — they'd allow header + # injection on the wire. + assert "\r" not in url + assert "\n" not in url + + def test_backslash_uid_is_neutralized(self): + url = self._capture_put_url(self._ics_with_uid("a\\b")) + tail = url[len("https://caldav.example.com/home/cal/") :] + assert "\\" not in tail + + def test_safe_uid_is_preserved(self): + url = self._capture_put_url(self._ics_with_uid("safe-uid-123@example.com")) + assert url.endswith("/safe-uid-123%40example.com.ics") or url.endswith( + "/safe-uid-123@example.com.ics" + ) + + +# --------------------------------------------------------------------------- +# Writable-only filter on list_calendars +# --------------------------------------------------------------------------- + + +def _make_propfind_response(calendars): + """Build a multistatus body listing the given calendars. + + Each entry is (href, displayname, privileges) where privileges is a + list of DAV tag names (without namespace) to emit under + current-user-privilege-set, or None to omit the element entirely. + """ + parts = [ + '', + '', + ] + for href, name, privileges in calendars: + parts.append(f"{href}") + parts.append(f"{name}") + parts.append("") + if privileges is not None: + parts.append("") + for p in privileges: + parts.append(f"") + parts.append("") + parts.append("") + parts.append("") + return "".join(parts) + + +class TestListCalendarsWritableFilter: + """Unit tests for list_calendars(writable_only=True) parsing.""" + + def _service_with_body(self, body): + service = CalDAVService(url="https://caldav.example.com/home/") + service._home_set = "https://caldav.example.com/home/" + + class _FakeResp: + text = body + + service._propfind = lambda *a, **kw: _FakeResp() # type: ignore[method-assign] + return service + + def test_all_calendars_returned_when_not_filtering(self): + body = _make_propfind_response( + [ + ("/home/writable/", "Writable", ["read", "write"]), + ("/home/readonly/", "Read only", ["read"]), + ] + ) + service = self._service_with_body(body) + names = [c["name"] for c in service.list_calendars()] + assert names == ["Writable", "Read only"] + + def test_filter_excludes_read_only_calendars(self): + body = _make_propfind_response( + [ + ("/home/writable/", "Writable", ["read", "write"]), + ("/home/readonly/", "Read only", ["read"]), + ] + ) + service = self._service_with_body(body) + names = [c["name"] for c in service.list_calendars(writable_only=True)] + assert names == ["Writable"] + + def test_filter_accepts_write_content_as_sufficient(self): + """Granular servers may report write-content instead of write.""" + body = _make_propfind_response( + [("/home/cal/", "Cal", ["read", "write-content"])] + ) + service = self._service_with_body(body) + names = [c["name"] for c in service.list_calendars(writable_only=True)] + assert names == ["Cal"] + + def test_filter_trusts_servers_without_privilege_set(self): + """A server that omits current-user-privilege-set is trusted.""" + body = _make_propfind_response([("/home/cal/", "Cal", None)]) + service = self._service_with_body(body) + names = [c["name"] for c in service.list_calendars(writable_only=True)] + assert names == ["Cal"] + + def test_filter_excludes_when_privilege_set_lacks_write(self): + """Privilege set present but no write-family privilege → excluded.""" + body = _make_propfind_response([("/home/cal/", "Cal", ["read"])]) + service = self._service_with_body(body) + assert not service.list_calendars(writable_only=True) + + +# --------------------------------------------------------------------------- +# Credential contract: Basic Auth user = mailbox email, password = setting +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db() +def test_instance_config_sends_mailbox_email_as_basic_auth_user(settings, mailbox): + """Instance-level auth: Basic Auth user must be the mailbox email and + the password must be CALDAV_DEFAULT_PASSWORD verbatim.""" + settings.CALDAV_DEFAULT_URL = "https://caldav.example.com/" + settings.CALDAV_DEFAULT_PASSWORD = "shared-secret-xyz" + + service = CalDAVService.from_instance_config(str(mailbox)) + + assert service.username == str(mailbox) + assert service.password == "shared-secret-xyz" + assert service.session.auth == (str(mailbox), "shared-secret-xyz") diff --git a/src/backend/core/tests/api/test_messages_import.py b/src/backend/core/tests/api/test_messages_import.py index 1d6b047c..5e1211ae 100644 --- a/src/backend/core/tests/api/test_messages_import.py +++ b/src/backend/core/tests/api/test_messages_import.py @@ -22,7 +22,7 @@ pytestmark = pytest.mark.django_db @pytest.fixture(autouse=True) def _mock_ssrf_dns(): - """Short-circuit SSRF DNS validation for IMAP import tests. + """Short-circuit SSRF hostname validation for IMAP import tests. The IMAP endpoint validates the server hostname via ``core.services.ssrf.validate_hostname``; tests use unresolvable fixtures diff --git a/src/backend/core/tests/importer/conftest.py b/src/backend/core/tests/importer/conftest.py index b21aedf4..68af74b5 100644 --- a/src/backend/core/tests/importer/conftest.py +++ b/src/backend/core/tests/importer/conftest.py @@ -7,7 +7,7 @@ import pytest @pytest.fixture(autouse=True) def _mock_ssrf_dns(): - """Short-circuit SSRF DNS validation for IMAP tests. + """Short-circuit SSRF hostname validation for IMAP tests. The IMAP import path validates the server hostname via ``core.services.ssrf.validate_hostname``. Test fixtures use unresolvable diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 71b02d3c..c8df9518 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -6,6 +6,12 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter from core.api.viewsets.blob import BlobViewSet +from core.api.viewsets.calendar import ( + CalendarAddEventView, + CalendarConflictsView, + CalendarListView, + CalendarRsvpView, +) from core.api.viewsets.channel import ChannelViewSet, UserChannelViewSet from core.api.viewsets.config import ConfigView from core.api.viewsets.contacts import ContactViewSet @@ -183,6 +189,26 @@ urlpatterns = [ mailbox_channel_nested_router.urls ), # Includes /mailboxes/{id}/channels/ ), + path( + "mailboxes//calendar/rsvp/", + CalendarRsvpView.as_view(), + name="calendar-rsvp", + ), + path( + "mailboxes//calendar/add/", + CalendarAddEventView.as_view(), + name="calendar-add-event", + ), + path( + "mailboxes//calendar/conflicts/", + CalendarConflictsView.as_view(), + name="calendar-conflicts", + ), + path( + "mailboxes//calendar/calendars/", + CalendarListView.as_view(), + name="calendar-list", + ), path( "maildomains//", include(maildomain_nested_router.urls), diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index e99cc6e7..5371dd60 100644 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -364,6 +364,44 @@ class Base(Configuration): "my-shared-secret-mda", environ_name="MDA_API_SECRET", environ_prefix=None ) + # Default CalDAV server settings (optional). Enables calendar features + # for every mailbox that has not configured its own per-mailbox CalDAV + # Channel — users can override the integration by pointing a Channel at + # a CalDAV server of their choice. These ``DEFAULT`` values are the + # fallback applied when no such Channel exists. + # + # Trust model — IMPORTANT + # ----------------------- + # ``CALDAV_DEFAULT_URL`` is the CalDAV server root. + # ``CALDAV_DEFAULT_PASSWORD`` is a single secret sent as the HTTP Basic + # Auth *password* on every outbound request. The Basic Auth *username* + # is the acting mailbox's email, computed per request from the URL. + # + # That means the same secret authenticates messages-as-a-service for + # all mailboxes — the CalDAV server is then responsible for whatever + # per-user authorization it wants to layer on top. Concretely: any user + # who can choose what email lands on a Mailbox row can cause messages + # to authenticate to the CalDAV server *as* that email. The load-bearing + # safety property is therefore that mailbox creation does not let one + # user mint a Mailbox whose email matches another user on the CalDAV + # side. Operators wiring up this integration must verify that property + # against their domain/identity ownership rules. + CALDAV_DEFAULT_URL = values.Value( + None, environ_name="CALDAV_DEFAULT_URL", environ_prefix=None + ) + CALDAV_DEFAULT_PASSWORD = values.Value( + None, environ_name="CALDAV_DEFAULT_PASSWORD", environ_prefix=None + ) + # Public URL of the default calendar web UI (e.g. a hosted Calendars + # instance). Used by the mail UI to deep-link into the calendar app so + # the user can see the event after accepting, or create a calendar when + # they have none. Like the credentials above, this is the deployment- + # wide default — a per-mailbox Channel pointing at a different CalDAV + # provider is not expected to surface a web URL here. + CALDAV_DEFAULT_WEB_URL = values.Value( + None, environ_name="CALDAV_DEFAULT_WEB_URL", environ_prefix=None + ) + # Spam filtering settings # Default spam configuration for all mail domains, overrideable per mail @@ -735,6 +773,15 @@ class Base(Configuration): environ_name="API_USERS_LIST_THROTTLE_RATE_BURST", environ_prefix=None, ), + # /calendar/conflicts/ PROPFINDs the home set and REPORTs every + # calendar in it; legitimate UI use is one call per opened + # invite. 30/min/user is generous for users with many invites + # in a thread but caps the cost of a runaway script. + "caldav_conflicts": values.Value( + default="30/minute", + environ_name="API_CALDAV_CONFLICTS_THROTTLE_RATE", + environ_prefix=None, + ), }, } @@ -1363,8 +1410,11 @@ class DevelopmentMinimal(Development): CELERY_TASK_ALWAYS_EAGER = True OPENSEARCH_INDEX_THREADS = False + # LocMemCache (not DummyCache) for the default cache so that features + # that depend on real caching — notably task-owner tracking used by + # async-task polling — work in this no-Redis profile. CACHES = { - "default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}, + "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, "session": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, } diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index f45f0c4d..5db5789f 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -50,6 +50,7 @@ dependencies = [ "factory_boy==3.3.3", "flanker@git+https://github.com/sylvinus/flanker@a4248826794d446b07fb26719479c1c355411f5a", "gunicorn==25.1.0", + "icalendar==7.0.3", "jsonschema==4.26.0", "nested-multipart-parser==1.6.0", "openai==2.21.0", @@ -90,6 +91,7 @@ dev = [ "pytest-icdiff==0.9", "pytest-repeat==0.9.4", "pytest-xdist==3.8.0", + "radicale==3.6.1", "responses==0.26.0", "ruff==0.15.2" ] @@ -167,4 +169,5 @@ python_files = [ markers = [ "fuzz: marks tests as fuzz tests (run with: pytest -m fuzz)", "redis: marks tests that need a real Redis service (skip with: pytest -m 'not redis')", + "caldav_ssrf_real: opt out of the test-suite-wide SSRF bypass and exercise the real per-channel guard", ] diff --git a/src/backend/uv.lock b/src/backend/uv.lock index d4568cd4..1ee14a05 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -877,6 +877,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" }, ] +[[package]] +name = "icalendar" +version = "7.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/60/6b0356a2ed1c9689ae14bd8e44f22eac67c420a0ecca4df8306b70906600/icalendar-7.0.3.tar.gz", hash = "sha256:95027ece087ab87184d765f03761f25875821f74cdd18d3b57e9c868216d8fde", size = 443788, upload-time = "2026-03-03T12:00:10.952Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/c6/431fbf9063a6a4306d4cedae7823d69baf0979ba6ca57ab24a9d898cd0aa/icalendar-7.0.3-py3-none-any.whl", hash = "sha256:8c9fea6d3a89671bba8b6938d8565b4d0ec465c6a2796ef0f92790dcb9e627cd", size = 442406, upload-time = "2026-03-03T12:00:09.228Z" }, +] + [[package]] name = "icdiff" version = "2.0.10" @@ -1045,6 +1058,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/7e/e7394eeb49a41cc514b3eb49020223666cbf40d86f5721c2f07871e6d84a/legacy_cgi-2.6.4-py3-none-any.whl", hash = "sha256:7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd", size = 20035, upload-time = "2025-10-27T05:20:04.289Z" }, ] +[[package]] +name = "libpass" +version = "1.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/f6/210563eef148773ef835d21c0fa1e8a43c5b10fceb1f63d5d650635b81da/libpass-1.9.3.tar.gz", hash = "sha256:7830b9323d9ba96a841ad698a8dec1d43a2b0b7f1c855c76772e7972c1c6d959", size = 692901, upload-time = "2025-10-09T14:26:28.363Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/a6/ff00d926a9f4ece9e0732bcea1c3e8e9280390f48cec53f237e21f220082/libpass-1.9.3-py3-none-any.whl", hash = "sha256:3cbeb14d22086660e92c30d787ea981973d67394e81c8c870e1a83cfe1c84353", size = 307118, upload-time = "2025-10-09T14:26:25.551Z" }, +] + [[package]] name = "libpff-python" version = "20231205" @@ -1123,6 +1145,7 @@ dependencies = [ { name = "factory-boy" }, { name = "flanker" }, { name = "gunicorn" }, + { name = "icalendar" }, { name = "jsonschema" }, { name = "libpff-python" }, { name = "nested-multipart-parser" }, @@ -1158,6 +1181,7 @@ dev = [ { name = "pytest-icdiff" }, { name = "pytest-repeat" }, { name = "pytest-xdist" }, + { name = "radicale" }, { name = "responses" }, { name = "ruff" }, ] @@ -1193,6 +1217,7 @@ requires-dist = [ { name = "flower", marker = "extra == 'dev'", specifier = "==2.0.1" }, { name = "gunicorn", specifier = "==25.1.0" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = "==6.151.9" }, + { name = "icalendar", specifier = "==7.0.3" }, { name = "jsonschema", specifier = "==4.26.0" }, { name = "libpff-python", specifier = "==20231205" }, { name = "nested-multipart-parser", specifier = "==1.6.0" }, @@ -1215,6 +1240,7 @@ requires-dist = [ { name = "python-keycloak", specifier = "==5.5.1" }, { name = "python-magic", specifier = "==0.4.27" }, { name = "pyzstd", specifier = "==0.19.1" }, + { name = "radicale", marker = "extra == 'dev'", specifier = "==3.6.1" }, { name = "redis", specifier = "==6.4.0" }, { name = "requests", specifier = "==2.32.5" }, { name = "responses", marker = "extra == 'dev'", specifier = "==0.26.0" }, @@ -1325,6 +1351,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pika" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/db/d4102f356af18f316c67f2cead8ece307f731dd63140e2c71f170ddacf9b/pika-1.3.2.tar.gz", hash = "sha256:b2a327ddddf8570b4965b3576ac77091b850262d34ce8c1d8cb4e4146aa4145f", size = 145029, upload-time = "2023-05-05T14:25:43.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/f3/f412836ec714d36f0f4ab581b84c491e3f42c6b5b97a6c6ed1817f3c16d0/pika-1.3.2-py3-none-any.whl", hash = "sha256:0779a7c1fafd805672796085560d290213a465e4f6f76a6fb19e378d8041a14f", size = 155415, upload-time = "2023-05-05T14:25:41.484Z" }, +] + [[package]] name = "pip" version = "26.0.1" @@ -1811,6 +1846,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/62/f55d1363512d1b1cc122af6ca5951008e42605b63675c7d6781affc7c475/pyzstd-0.19.1-py3-none-any.whl", hash = "sha256:267e2eb0de0291dfcb7ccfebc4ffe75b2f9d84e7007ac38844f6ccafc6dce081", size = 23779, upload-time = "2025-12-13T08:15:31.979Z" }, ] +[[package]] +name = "radicale" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, + { name = "libpass" }, + { name = "packaging" }, + { name = "pika" }, + { name = "requests" }, + { name = "vobject" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/66/75606d83483271cdc3d422f4ccdbefffa9c2f959b3ab9965c2866ff18f8f/radicale-3.6.1.tar.gz", hash = "sha256:5b5529c093fc3a6e9eba6a3280d930ea6573b485a1bd8d25b7daa6ee70f5224b", size = 192417, upload-time = "2026-02-25T16:42:58.689Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/6c/38ff9bad1e60d0311d0564d20d7b6d802e89bc5075289cec456d73feae8e/radicale-3.6.1-py3-none-any.whl", hash = "sha256:4942704540691a4ae5d187f181c73fbc6fc8accf2b20c398e454dd1a3705405b", size = 222254, upload-time = "2026-02-25T16:42:56.784Z" }, +] + [[package]] name = "redis" version = "6.4.0" @@ -2221,6 +2273,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, ] +[[package]] +name = "vobject" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/8a/15c34b3d27c43fc81a467d0f66577afc5542326804c42f30557e31c3259e/vobject-0.9.9.tar.gz", hash = "sha256:ac44e5d7e2079d84c1d52c50a615b9bec4b1ba958608c4c7fe40cbf33247b38e", size = 1208905, upload-time = "2024-12-16T07:29:39.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/20/6bba813bbd498c28edbbcf8253a6398cf4266ecf7bfa6129835c0a2bfbb1/vobject-0.9.9-py2.py3-none-any.whl", hash = "sha256:0fbdb982065cf4d1843a5d5950c88510041c6de026bda49c3502721de1c6ac3d", size = 47526, upload-time = "2024-12-16T07:31:08.493Z" }, +] + [[package]] name = "wcwidth" version = "0.6.0" diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 67cbe932..6349bb77 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -19372,6 +19372,8 @@ }, "node_modules/typescript": { "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/src/frontend/package.json b/src/frontend/package.json index 4fccd456..153deb10 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -24,10 +24,10 @@ "@blocknote/core": "0.49.0", "@blocknote/mantine": "0.49.0", "@blocknote/react": "0.49.0", + "@gouvfr-lasuite/cunningham-react": "4.3.0", "@gouvfr-lasuite/drive-sdk": "0.0.1", "@gouvfr-lasuite/ui-kit": "0.20.0", "@hookform/resolvers": "5.2.2", - "@gouvfr-lasuite/cunningham-react": "4.3.0", "@react-email/components": "1.0.6", "@sentry/nextjs": "10.38.0", "@tanstack/react-query": "5.90.20", diff --git a/src/frontend/public/locales/common/en-US.json b/src/frontend/public/locales/common/en-US.json index 5e17ef46..db2980d4 100755 --- a/src/frontend/public/locales/common/en-US.json +++ b/src/frontend/public/locales/common/en-US.json @@ -1,147 +1,9 @@ { - "{{assignees}} was unassigned_one": "{{assignees}} was unassigned", - "{{assignees}} was unassigned_other": "{{assignees}} were unassigned", - "{{author}} assigned {{assignees}}_one": "{{author}} assigned {{assignees}}", - "{{author}} assigned {{assignees}}_other": "{{author}} assigned {{assignees}}", - "{{author}} assigned themself": "{{author}} assigned themself", - "{{author}} assigned themself and {{assignees}}_one": "{{author}} assigned themself and {{assignees}}", - "{{author}} assigned themself and {{assignees}}_other": "{{author}} assigned themself and {{assignees}}", - "{{author}} assigned you": "{{author}} assigned you", - "{{author}} assigned you and {{assignees}}_one": "{{author}} assigned you and {{assignees}}", - "{{author}} assigned you and {{assignees}}_other": "{{author}} assigned you and {{assignees}}", - "{{author}} assigned you and themself": "{{author}} assigned you and themself", - "{{author}} assigned you, themself and {{assignees}}_one": "{{author}} assigned you, themself and {{assignees}}", - "{{author}} assigned you, themself and {{assignees}}_other": "{{author}} assigned you, themself and {{assignees}}", - "{{author}} unassigned {{assignees}}_one": "{{author}} unassigned {{assignees}}", - "{{author}} unassigned {{assignees}}_other": "{{author}} unassigned {{assignees}}", - "{{author}} unassigned themself": "{{author}} unassigned themself", - "{{author}} unassigned themself and {{assignees}}_one": "{{author}} unassigned themself and {{assignees}}", - "{{author}} unassigned themself and {{assignees}}_other": "{{author}} unassigned themself and {{assignees}}", - "{{author}} unassigned you": "{{author}} unassigned you", - "{{author}} unassigned you and {{assignees}}_one": "{{author}} unassigned you and {{assignees}}", - "{{author}} unassigned you and {{assignees}}_other": "{{author}} unassigned you and {{assignees}}", - "{{author}} unassigned you and themself": "{{author}} unassigned you and themself", - "{{author}} unassigned you, themself and {{assignees}}_one": "{{author}} unassigned you, themself and {{assignees}}", - "{{author}} unassigned you, themself and {{assignees}}_other": "{{author}} unassigned you, themself and {{assignees}}", - "{{count}} assignment changes_one": "{{count}} assignment change", - "{{count}} assignment changes_other": "{{count}} assignment changes", - "{{count}} attachments_one": "{{count}} attachment", - "{{count}} attachments_other": "{{count}} attachments", - "{{count}} attendees_one": "{{count}} attendee", - "{{count}} attendees_other": "{{count}} attendees", - "{{count}} days ago_one": "{{count}} day ago", - "{{count}} days ago_other": "{{count}} days ago", - "{{count}} hours ago_one": "{{count}} hour ago", - "{{count}} hours ago_other": "{{count}} hours ago", - "{{count}} mailboxes_one": "{{count}} mailboxes", - "{{count}} mailboxes_other": "{{count}} mailboxes", - "{{count}} messages_one": "{{count}} message", - "{{count}} messages_other": "{{count}} messages", - "{{count}} messages are now starred._one": "The message is now starred.", - "{{count}} messages are now starred._other": "{{count}} messages are now starred.", - "{{count}} messages assigned to you_one": "{{count}} message assigned to you", - "{{count}} messages assigned to you_other": "{{count}} messages assigned to you", - "{{count}} messages have been archived._one": "The message has been archived.", - "{{count}} messages have been archived._other": "{{count}} messages have been archived.", - "{{count}} messages have been deleted._one": "The message has been deleted.", - "{{count}} messages have been deleted._other": "{{count}} messages have been deleted.", - "{{count}} messages have been reported as spam._one": "The message has been reported as spam.", - "{{count}} messages have been reported as spam._other": "{{count}} messages have been reported as spam.", - "{{count}} messages have been updated._one": "The message has been updated.", - "{{count}} messages have been updated._other": "{{count}} messages have been updated.", - "{{count}} messages imported_one": "{{count}} message imported", - "{{count}} messages imported_other": "{{count}} messages imported", - "{{count}} messages mentioning you_one": "{{count}} message mentioning you", - "{{count}} messages mentioning you_other": "{{count}} messages mentioning you", - "{{count}} messages of this thread have been deleted._one": "{{count}} message of this thread has been deleted.", - "{{count}} messages of this thread have been deleted._other": "{{count}} messages of this thread have been deleted.", - "{{count}} messages were imported before the error._one": "{{count}} message were imported before the error.", - "{{count}} messages were imported before the error._other": "{{count}} messages were imported before the error.", - "{{count}} minutes ago_one": "{{count}} minute ago", - "{{count}} minutes ago_other": "{{count}} minutes ago", - "{{count}} months ago_one": "{{count}} month ago", - "{{count}} months ago_other": "{{count}} months ago", - "{{count}} new message_one": "{{count}} new message", - "{{count}} new message_other": "{{count}} new messages", - "{{count}} occurrences_one": "{{count}} occurrence", - "{{count}} occurrences_other": "{{count}} occurrences", - "{{count}} of which are shared_one": ", {{count}} of which is shared", - "{{count}} of which are shared_other": ", {{count}} of which are shared", - "{{count}} out of {{total}} messages are now starred._one": "{{count}} out of {{total}} message is now starred.", - "{{count}} out of {{total}} messages are now starred._other": "{{count}} out of {{total}} messages are now starred.", - "{{count}} out of {{total}} messages have been archived._one": "{{count}} out of {{total}} message has been archived.", - "{{count}} out of {{total}} messages have been archived._other": "{{count}} out of {{total}} messages have been archived.", - "{{count}} out of {{total}} messages have been deleted._one": "{{count}} out of {{total}} message has been deleted.", - "{{count}} out of {{total}} messages have been deleted._other": "{{count}} out of {{total}} messages have been deleted.", - "{{count}} out of {{total}} messages have been reported as spam._one": "{{count}} out of {{total}} message has been reported as spam.", - "{{count}} out of {{total}} messages have been reported as spam._other": "{{count}} out of {{total}} messages have been reported as spam.", - "{{count}} out of {{total}} threads are now starred._one": "{{count}} out of {{total}} thread is now starred.", - "{{count}} out of {{total}} threads are now starred._other": "{{count}} out of {{total}} threads are now starred.", - "{{count}} out of {{total}} threads have been archived._one": "{{count}} out of {{total}} thread has been archived.", - "{{count}} out of {{total}} threads have been archived._other": "{{count}} out of {{total}} threads have been archived.", - "{{count}} out of {{total}} threads have been deleted._one": "{{count}} out of {{total}} thread has been deleted.", - "{{count}} out of {{total}} threads have been deleted._other": "{{count}} out of {{total}} threads have been deleted.", - "{{count}} out of {{total}} threads have been reported as spam._one": "{{count}} out of {{total}} thread has been reported as spam.", - "{{count}} out of {{total}} threads have been reported as spam._other": "{{count}} out of {{total}} threads have been reported as spam.", - "{{count}} results_one": "{{count}} result", - "{{count}} results_other": "{{count}} results", - "{{count}} results assigned to you_one": "{{count}} result assigned to you", - "{{count}} results assigned to you_other": "{{count}} results assigned to you", - "{{count}} results mentioning you_one": "{{count}} result mentioning you", - "{{count}} results mentioning you_other": "{{count}} results mentioning you", - "{{count}} selected threads_one": "{{count}} selected thread", - "{{count}} selected threads_other": "{{count}} selected threads", - "{{count}} starred messages_one": "{{count}} starred message", - "{{count}} starred messages_other": "{{count}} starred messages", - "{{count}} starred messages mentioning you_one": "{{count}} starred message mentioning you", - "{{count}} starred messages mentioning you_other": "{{count}} starred messages mentioning you", - "{{count}} starred results_one": "{{count}} starred result", - "{{count}} starred results_other": "{{count}} starred results", - "{{count}} starred results mentioning you_one": "{{count}} starred result mentioning you", - "{{count}} starred results mentioning you_other": "{{count}} starred results mentioning you", - "{{count}} threads are now starred._one": "The thread is now starred.", - "{{count}} threads are now starred._other": "{{count}} threads are now starred.", - "{{count}} threads have been archived._one": "The thread has been archived.", - "{{count}} threads have been archived._other": "{{count}} threads have been archived.", - "{{count}} threads have been deleted._one": "The thread has been deleted.", - "{{count}} threads have been deleted._other": "{{count}} threads have been deleted.", - "{{count}} threads have been reported as spam._one": "The thread has been reported as spam.", - "{{count}} threads have been reported as spam._other": "{{count}} threads have been reported as spam.", - "{{count}} threads have been restored._one": "The thread has been restored.", - "{{count}} threads have been restored._other": "{{count}} threads have been restored.", - "{{count}} threads have been unarchived._one": "The thread has been unarchived.", - "{{count}} threads have been unarchived._other": "{{count}} threads have been unarchived.", - "{{count}} threads have been updated._one": "The thread has been updated.", - "{{count}} threads have been updated._other": "{{count}} threads have been updated.", - "{{count}} unread messages_one": "{{count}} unread message", - "{{count}} unread messages_other": "{{count}} unread messages", - "{{count}} unread messages mentioning you_one": "{{count}} unread message mentioning you", - "{{count}} unread messages mentioning you_other": "{{count}} unread messages mentioning you", - "{{count}} unread results_one": "{{count}} unread result", - "{{count}} unread results_other": "{{count}} unread results", - "{{count}} unread results mentioning you_one": "{{count}} unread result mentioning you", - "{{count}} unread results mentioning you_other": "{{count}} unread results mentioning you", - "{{count}} unread starred messages_one": "{{count}} unread starred message", - "{{count}} unread starred messages_other": "{{count}} unread starred messages", - "{{count}} unread starred messages mentioning you_one": "{{count}} unread starred message mentioning you", - "{{count}} unread starred messages mentioning you_other": "{{count}} unread starred messages mentioning you", - "{{count}} unread starred results_one": "{{count}} unread starred result", - "{{count}} unread starred results_other": "{{count}} unread starred results", - "{{count}} unread starred results mentioning you_one": "{{count}} unread starred result mentioning you", - "{{count}} unread starred results mentioning you_other": "{{count}} unread starred results mentioning you", - "{{count}} weeks ago_one": "{{count}} week ago", - "{{count}} weeks ago_other": "{{count}} weeks ago", - "{{count}} years ago_one": "{{count}} year ago", - "{{count}} years ago_other": "{{count}} years ago", - "{{count}}/{{total}} mailboxes_one": "{{count}}/{{total}} mailboxes", - "{{count}}/{{total}} mailboxes_other": "{{count}}/{{total}} mailboxes", - "{{date}} at {{time}}": "{{date}} at {{time}}", - "{{name}} assigned to this thread": "{{name}} assigned to this thread", - "{{name}} unassigned from this thread": "{{name}} unassigned from this thread", - "{{progress}}% imported": "{{progress}}% imported", "2 columns": "2 columns", "2FA has been reset for {{mailbox}}.": "2FA has been reset for {{mailbox}}.", + "API Key": "API Key", "Abort upload": "Abort upload", + "Accept": "Accept", "Accepted": "Accepted", "Accesses": "Accesses", "Actions": "Actions", @@ -157,6 +19,7 @@ "Add labels": "Add labels", "Add tags": "Add tags", "Add this code snippet to your website to display the feedback widget.": "Add this code snippet to your website to display the feedback widget.", + "Add to calendar": "Add to calendar", "Address": "Address", "Addresses": "Addresses", "After creating the widget, you will receive the installation code to add to your website.": "After creating the widget, you will receive the installation code to add to your website.", @@ -174,9 +37,6 @@ "An error occurred while updating the address.": "An error occurred while updating the address.", "An error occurred while uploading the archive file.": "An error occurred while uploading the archive file.", "An unexpected error occurred.": "An unexpected error occurred.", - "and {{count}} other users_one": "and 1 other user", - "and {{count}} other users_other": "and {{count}} other users", - "API Key": "API Key", "Archive": "Archive", "Archives": "Archives", "Are you sure you want to close this dialog? Your upload will be aborted!": "Are you sure you want to close this dialog? Your upload will be aborted!", @@ -194,11 +54,11 @@ "Assign this label and archive": "Assign this label and archive", "Assign to...": "Assign to...", "Assign users to this thread": "Assign users to this thread", + "Assigned to me": "Assigned to me", + "Assigned to this thread": "Assigned to this thread", "Assigned to {{count}} people_one": "Assigned to {{count}} person", "Assigned to {{count}} people_other": "Assigned to {{count}} people", "Assigned to {{names}}": "Assigned to {{names}}", - "Assigned to me": "Assigned to me", - "Assigned to this thread": "Assigned to this thread", "At least one recipient is required.": "At least one recipient is required.", "Attachment failed to be saved into your {{driveAppName}}'s workspace.": "Attachment failed to be saved into your {{driveAppName}}'s workspace.", "Attachment saved into your {{driveAppName}}'s workspace.": "Attachment saved into your {{driveAppName}}'s workspace.", @@ -215,18 +75,21 @@ "Auto-reply updated!": "Auto-reply updated!", "Automatically create mailboxes according to OIDC emails": "Automatically create mailboxes according to OIDC emails", "Awaiting response": "Awaiting response", + "BCC: ": "BCC: ", "Back": "Back", "Back to your inbox": "Back to your inbox", - "BCC: ": "BCC: ", "Blind copy: ": "Blind copy: ", + "CC: ": "CC: ", "Calendar invite": "Calendar invite", + "Calendar service unavailable": "Calendar service unavailable", "Cancel": "Cancel", "Cancel those sendings": "Cancel those sendings", + "Cancelled": "Cancelled", "Cannot add attachment(s). Total size would be more than {{maxSize}}.": "Cannot add attachment(s). Total size would be more than {{maxSize}}.", "Cannot add image. File size exceeds the {{maxSize}} limit.": "Cannot add image. File size exceeds the {{maxSize}} limit.", - "CC: ": "CC: ", "Check DNS again": "Check DNS again", "Checking DNS records...": "Checking DNS records...", + "Choose calendar": "Choose calendar", "Choose the type of integration you want to create": "Choose the type of integration you want to create", "Clear filters": "Clear filters", "Clear selected items": "Clear selected items", @@ -237,11 +100,13 @@ "Close the menu": "Close the menu", "Close this thread": "Close this thread", "Collapse": "Collapse", - "Collapse {{name}}": "Collapse {{name}}", "Collapse all": "Collapse all", + "Collapse {{name}}": "Collapse {{name}}", "Color: ": "Color: ", "Coming soon": "Coming soon", + "Confirmed": "Confirmed", "Conflicting": "Conflicting", + "Connecting to calendar…": "Connecting to calendar…", "Contact the Support team": "Contact the Support team", "Contains the words": "Contains the words", "Content is required": "Content is required", @@ -255,8 +120,10 @@ "Copy to clipboard": "Copy to clipboard", "Copy: ": "Copy: ", "Correct": "Correct", + "Could not confirm the calendar update.": "Could not confirm the calendar update.", "Create": "Create", "Create a Label": "Create a Label", + "Create a Widget": "Create a Widget", "Create a new address @{{domain}}": "Create a new address @{{domain}}", "Create a new auto-reply": "Create a new auto-reply", "Create a new integration": "Create a new integration", @@ -267,18 +134,19 @@ "Create a new signature for {{domain}}": "Create a new signature for {{domain}}", "Create a new template": "Create a new template", "Create a simple redirect (Coming soon)": "Create a simple redirect (Coming soon)", - "Create a Widget": "Create a Widget", "Create integration": "Create integration", + "Create one": "Create one", "Create the label \"{{label}}\"": "Create the label \"{{label}}\"", - "create_mailbox_modal.success.credential_text": "Your Messages credentials are:\n- Email: {{id}}\n- Password: {{password}}\n\nIt will be asked to change your password at your first login.", "Created at": "Created at", "Creating...": "Creating...", "Credentials copied!": "Credentials copied!", "Current status": "Current status", + "DNS": "DNS", "Daily": "Daily", "Date range": "Date range", "Date:": "Date:", "Date: ": "Date: ", + "Decline": "Decline", "Declined": "Declined", "Default": "Default", "Default signature": "Default signature", @@ -302,7 +170,6 @@ "Did you forget an attachment?": "Did you forget an attachment?", "Disable thread selection": "Disable thread selection", "Display those images": "Display those images", - "DNS": "DNS", "Do you have any feedback?": "Do you have any feedback?", "Domain": "Domain", "Domain admin": "Domain admin", @@ -318,16 +185,15 @@ "Drag and drop an archive here": "Drag and drop an archive here", "Drop your attachments here": "Drop your attachments here", "Duplicate": "Duplicate", + "EML, MBOX or PST": "EML, MBOX or PST", "Edit": "Edit", - "Edit {{mailbox}} address": "Edit {{mailbox}} address", + "Edit Widget": "Edit Widget", "Edit auto-reply \"{{autoreply}}\"": "Edit auto-reply \"{{autoreply}}\"", "Edit signature \"{{signature}}\"": "Edit signature \"{{signature}}\"", "Edit template \"{{template}}\"": "Edit template \"{{template}}\"", - "Edit Widget": "Edit Widget", - "edited": "edited", + "Edit {{mailbox}} address": "Edit {{mailbox}} address", "Editing message": "Editing message", "Email address": "Email address", - "EML, MBOX or PST": "EML, MBOX or PST", "End date": "End date", "End date is required": "End date is required", "End day": "End day", @@ -341,6 +207,7 @@ "Error while loading integrations": "Error while loading integrations", "Error while loading signatures": "Error while loading signatures", "Error while loading templates": "Error while loading templates", + "Event added to calendar": "Event added to calendar", "Every {{count}} days_one": "Every {{count}} days", "Every {{count}} days_other": "Every {{count}} days", "Every {{count}} months_one": "Every {{count}} months", @@ -351,8 +218,8 @@ "Every {{count}} years_other": "Every {{count}} years", "Existing 2FA credentials will be removed. The user will be asked to re-enroll on next login.": "Existing 2FA credentials will be removed. The user will be asked to re-enroll on next login.", "Expand": "Expand", - "Expand {{name}}": "Expand {{name}}", "Expand all": "Expand all", + "Expand {{name}}": "Expand {{name}}", "Failed to delete auto-reply.": "Failed to delete auto-reply.", "Failed to delete integration.": "Failed to delete integration.", "Failed to delete signature.": "Failed to delete signature.", @@ -375,7 +242,6 @@ "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", "Fold message": "Fold message", - "folder.search": "Search", "For your security and privacy, external images are not displayed.": "For your security and privacy, external images are not displayed.", "Forced": "Forced", "Forced signature": "Forced signature", @@ -396,12 +262,12 @@ "How to allow IMAP connections from your account {{name}}?": "How to allow IMAP connections from your account {{name}}?", "I confirm that this address corresponds to the real identity of a colleague, and I commit to deactivating it when their position ends.": "I confirm that this address corresponds to the real identity of a colleague, and I commit to deactivating it when their position ends.", "I have an issue or a feature request": "I have an issue or a feature request", - "Identity": "Identity", - "Image + Text": "Image + Text", - "Image size limit exceeded": "Image size limit exceeded", "IMAP port": "IMAP port", "IMAP server": "IMAP server", "IMAP server is required.": "IMAP server is required.", + "Identity": "Identity", + "Image + Text": "Image + Text", + "Image size limit exceeded": "Image size limit exceeded", "Import": "Import", "Import complete": "Import complete", "Import failed": "Import failed", @@ -427,7 +293,6 @@ "Integration deleted!": "Integration deleted!", "Integration updated!": "Integration updated!", "Integrations": "Integrations", - "just now": "just now", "Label \"{{label}}\" assigned and {{count}} threads archived._one": "Label \"{{label}}\" assigned and thread archived.", "Label \"{{label}}\" assigned and {{count}} threads archived._other": "Label \"{{label}}\" assigned and {{count}} threads archived.", "Label \"{{label}}\" assigned to {{count}} threads._one": "Label \"{{label}}\" assigned to this thread.", @@ -446,7 +311,6 @@ "Layout": "Layout", "Leave this thread": "Leave this thread", "Leave this thread?": "Leave this thread?", - "less than a minute ago": "less than a minute ago", "Link copied to clipboard": "Link copied to clipboard", "Loading addresses...": "Loading addresses...", "Loading auto-replies...": "Loading auto-replies...", @@ -464,12 +328,12 @@ "Loading variables...": "Loading variables...", "Loading…": "Loading…", "Logout": "Logout", - "Mailbox {{mailbox}} has been deleted successfully.": "Mailbox {{mailbox}} has been deleted successfully.", "Mailbox count": "Mailbox count", "Mailbox is required.": "Mailbox is required.", + "Mailbox {{mailbox}} has been deleted successfully.": "Mailbox {{mailbox}} has been deleted successfully.", "Maildomains management": "Maildomains management", - "Manage {{entity}} accesses": "Manage {{entity}} accesses", "Manage accesses": "Manage accesses", + "Manage {{entity}} accesses": "Manage {{entity}} accesses", "Mandatory 2FA": "Mandatory 2FA", "Mandatory 2FA disabled for {{mailbox}}.": "Mandatory 2FA disabled for {{mailbox}}.", "Mandatory 2FA enabled for {{mailbox}}.": "Mandatory 2FA enabled for {{mailbox}}.", @@ -479,6 +343,7 @@ "Mark as read from here": "Mark as read from here", "Mark as unread": "Mark as unread", "Mark as unread from here": "Mark as unread from here", + "Maybe": "Maybe", "Mentioned": "Mentioned", "Message content": "Message content", "Message from {referer_domain}": "Message from {referer_domain}", @@ -509,12 +374,12 @@ "New message": "New message", "New signature": "New signature", "New template": "New template", + "No DNS records found": "No DNS records found", "No accesses": "No accesses", "No action available for this mailbox": "No action available for this mailbox", "No addresses found": "No addresses found", "No attachments": "No attachments", "No auto-replies found": "No auto-replies found", - "No DNS records found": "No DNS records found", "No event found in calendar invite": "No event found in calendar invite", "No integration found": "No integration found", "No mailbox": "No mailbox", @@ -539,11 +404,12 @@ "Older": "Older", "On going": "On going", "Only available for personal mailboxes in identity-synced domains.": "Only available for personal mailboxes in identity-synced domains.", - "Open {{driveAppName}} preview": "Open {{driveAppName}} preview", + "Open calendar": "Open calendar", "Open filters": "Open filters", "Open the menu": "Open the menu", + "Open {{driveAppName}} preview": "Open {{driveAppName}} preview", "Or": "Or", - "or drag and drop some files": "or drag and drop some files", + "Organizer": "Organizer", "Other services...": "Other services...", "Outbox": "Outbox", "Password": "Password", @@ -564,11 +430,11 @@ "Refresh": "Refresh", "Refresh summary": "Refresh summary", "Remove": "Remove", - "Remove {{displayName}}": "Remove {{displayName}}", "Remove access?": "Remove access?", "Remove report": "Remove report", "Remove spam report": "Remove spam report", "Remove tag": "Remove tag", + "Remove {{displayName}}": "Remove {{displayName}}", "Reply": "Reply", "Reply all": "Reply all", "Report as spam": "Report as spam", @@ -577,6 +443,7 @@ "Reset 2FA for {{mailbox}}": "Reset 2FA for {{mailbox}}", "Reset password": "Reset password", "Reset password of {{mailbox}}": "Reset password of {{mailbox}}", + "Response saved — the organizer will be notified": "Response saved — the organizer will be notified", "Retry": "Retry", "Saturday": "Saturday", "Save": "Save", @@ -604,13 +471,14 @@ "Select thread": "Select thread", "Select threads": "Select threads", "Send": "Send", + "Send Feedback": "Send Feedback", "Send and archive": "Send and archive", "Send and receive your messages in an instant.": "Send and receive your messages in an instant.", - "Send Feedback": "Send Feedback", "Sending message...": "Sending message...", "Sent": "Sent", "Sent by {{name}}": "Sent by {{name}}", "Settings": "Settings", + "Share access": "Share access", "Share and assign the thread": "Share and assign the thread", "Share the credentials of this mailbox with its user. You must transfer them securely, preferably physically.": "Share the credentials of this mailbox with its user. You must transfer them securely, preferably physically.", "Share the new credentials to the user.": "Share the new credentials to the user.", @@ -620,12 +488,12 @@ "Shared between {{count}} mailboxes_other": "Shared between {{count}} mailboxes", "Shared mailbox": "Shared mailbox", "Show": "Show", - "Show {{count}} more_one": "Show {{count}} more", - "Show {{count}} more_other": "Show {{count}} more", "Show embedded message": "Show embedded message", "Show less": "Show less", "Show logs": "Show logs", "Show more": "Show more", + "Show {{count}} more_one": "Show {{count}} more", + "Show {{count}} more_other": "Show {{count}} more", "Signature created!": "Signature created!", "Signature deleted!": "Signature deleted!", "Signature updated!": "Signature updated!", @@ -664,6 +532,7 @@ "Synchronize mailboxes with an identity provider": "Synchronize mailboxes with an identity provider", "Tags": "Tags", "Target": "Target", + "Target calendar": "Target calendar", "Target email": "Target email", "Template created!": "Template created!", "Template deleted!": "Template deleted!", @@ -671,36 +540,39 @@ "Temporary password": "Temporary password", "Tentative": "Tentative", "Thank you for your feedback!": "Thank you for your feedback!", + "The PST archive is unreadable: the file is corrupt or its internal structure is incomplete. Retrying will not help — please try to re-generate the archive.": "The PST archive is unreadable: the file is corrupt or its internal structure is incomplete. Retrying will not help — please try to re-generate the archive.", "The address has been updated!": "The address has been updated!", "The default signature will be automatically loaded when composing a new message.": "The default signature will be automatically loaded when composing a new message.", "The domain {{domain}} has been created successfully.": "The domain {{domain}} has been created successfully.", - "The email {{email}} is invalid.": "The email {{email}} is invalid.", "The email address is invalid.": "The email address is invalid.", + "The email {{email}} is invalid.": "The email {{email}} is invalid.", "The forced signature will be the only one usable for new messages.": "The forced signature will be the only one usable for new messages.", "The mailbox \"{{mailbox}}\" currently has read-only access on this thread. To assign {{user}} to it, edit permissions must be granted to this mailbox.": "The mailbox \"{{mailbox}}\" currently has read-only access on this thread. To assign {{user}} to it, edit permissions must be granted to this mailbox.", "The message could not be sent.": "The message could not be sent.", "The message could not be sent. Please try again later.": "The message could not be sent. Please try again later.", + "The organizer marked this event as tentative.": "The organizer marked this event as tentative.", "The personal mailbox {{mailboxAddress}} has been created successfully.": "The personal mailbox <1>{{mailboxAddress}} has been created successfully.", - "The PST archive is unreadable: the file is corrupt or its internal structure is incomplete. Retrying will not help — please try to re-generate the archive.": "The PST archive is unreadable: the file is corrupt or its internal structure is incomplete. Retrying will not help — please try to re-generate the archive.", "The redirect mailbox {{mailboxAddress}} has been created successfully.": "The redirect mailbox <1>{{mailboxAddress}} has been created successfully.", "The shared mailbox {{mailboxAddress}} has been created successfully.": "The shared mailbox <1>{{mailboxAddress}} has been created successfully.", "The upload failed. Please try again.": "The upload failed. Please try again.", "These DNS records must be configured on the domain {{domain}} for the mail system to work properly. Changes may take up to 24 hours to propagate. If you don't know how to update them, please contact your technical service provider or system administrator.": "These DNS records must be configured on the domain {{domain}} for the mail system to work properly. Changes may take up to 24 hours to propagate. If you don't know how to update them, please contact your technical service provider or system administrator.", + "These DNS records must be configured on the domain {{domain}} for the mail system to work properly. If you don't know how to update them, please contact your technical service provider or system administrator.": "These DNS records must be configured on the domain {{domain}} for the mail system to work properly. If you don't know how to update them, please contact your technical service provider or system administrator.", "These tags will be automatically applied to every incoming message from the widget.": "These tags will be automatically applied to every incoming message from the widget.", "This action cannot be undone and the user will need the new password to access its mailbox.": "This action cannot be undone and the user will need the new password to access its mailbox.", "This contact's identity could not be verified. Proceed with caution.": "This contact's identity could not be verified. Proceed with caution.", "This description will be used by the AI to automatically assign this label to your messages.": "This description will be used by the AI to automatically assign this label to your messages.", "This email prefix is not allowed for personal mailboxes. Please choose a different prefix.": "This email prefix is not allowed for personal mailboxes. Please choose a different prefix.", "This event has been cancelled": "This event has been cancelled", + "This event has been cancelled by the organizer.": "This event has been cancelled by the organizer.", "This is the only admin of this mailbox, you cannot therefore modify its access.": "This is the only admin of this mailbox, you cannot therefore modify its access.", "This message failed sender authentication and is likely a forgery. Do not trust it.": "This message failed sender authentication and is likely a forgery. Do not trust it.", - "This message has {{count}} attachments_one": "This message has one attachment", - "This message has {{count}} attachments_other": "This message has {{count}} attachments", "This message has a draft": "This message has a draft", "This message has been deleted.": "This message has been deleted.", "This message has not been delivered.": "This message has not been delivered.", "This message has not been delivered. You cancelled the delivery.": "This message has not been delivered. You cancelled the delivery.", "This message has not yet been delivered to all recipients.": "This message has not yet been delivered to all recipients.", + "This message has {{count}} attachments_one": "This message has one attachment", + "This message has {{count}} attachments_other": "This message has {{count}} attachments", "This message is being delivered.": "This message is being delivered.", "This name is for internal use only and will not be visible to users.": "This name is for internal use only and will not be visible to users.", "This signature is forced": "This signature is forced", @@ -724,6 +596,7 @@ "Tuesday": "Tuesday", "Tutorials and training": "Tutorials and training", "Type": "Type", + "Unable to check task status.": "Unable to check task status.", "Unable to copy credentials.": "Unable to copy credentials.", "Unable to copy to clipboard.": "Unable to copy to clipboard.", "Unarchive": "Unarchive", @@ -739,7 +612,6 @@ "Unsaved changes": "Unsaved changes", "Unstar": "Unstar", "Unstar this thread": "Unstar this thread", - "until {{date}}": "until {{date}}", "Up to date": "Up to date", "Update": "Update", "Update a Label": "Update a Label", @@ -748,8 +620,8 @@ "Uploading your archive": "Uploading your archive", "Uploading... {{progress}}%": "Uploading... {{progress}}%", "Use \"Send and archive\" by default": "Use \"Send and archive\" by default", - "Use {referer_domain} to include the website domain in the subject.": "Use {referer_domain} to include the website domain in the subject.", "Use SSL": "Use SSL", + "Use {referer_domain} to include the website domain in the subject.": "Use {referer_domain} to include the website domain in the subject.", "Value": "Value", "Variables": "Variables", "View full documentation": "View full documentation", @@ -763,33 +635,186 @@ "Yearly": "Yearly", "Yesterday": "Yesterday", "You": "You", + "You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.": "You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.", "You and {{assignees}} were unassigned_one": "You and {{assignees}} were unassigned", "You and {{assignees}} were unassigned_other": "You and {{assignees}} were unassigned", - "You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.": "You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.", "You are the last editor of this thread, you cannot therefore modify your access.": "You are the last editor of this thread, you cannot therefore modify your access.", - "You assigned {{assignees}}_one": "You assigned {{assignees}}", - "You assigned {{assignees}}_other": "You assigned {{assignees}}", + "You assigned yourself": "You assigned yourself", "You assigned {{assignees}} and yourself_one": "You assigned {{assignees}} and yourself", "You assigned {{assignees}} and yourself_other": "You assigned {{assignees}} and yourself", - "You assigned yourself": "You assigned yourself", + "You assigned {{assignees}}_one": "You assigned {{assignees}}", + "You assigned {{assignees}}_other": "You assigned {{assignees}}", "You can close this window and continue using the app.": "You can close this window and continue using the app.", "You can now inform the person that their mailbox is ready to be used and communicate the instructions for authentication.": "You can now inform the person that their mailbox is ready to be used and communicate the instructions for authentication.", "You can safely retry the import — messages already imported will not be duplicated.": "You can safely retry the import — messages already imported will not be duplicated.", "You cannot delete the last editor of this thread": "You cannot delete the last editor of this thread", "You cannot modify it.": "You cannot modify it.", - "You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._one": "You have {{count}} recipient, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients.", - "You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._other": "You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients.", + "You don't have a calendar yet.": "You don't have a calendar yet.", "You have aborted the upload.": "You have aborted the upload.", "You have unsaved changes. Are you sure you want to close?": "You have unsaved changes. Are you sure you want to close?", + "You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._one": "You have {{count}} recipient, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients.", + "You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._other": "You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients.", "You left the thread": "You left the thread", "You may not have sufficient permissions for all selected threads.": "You may not have sufficient permissions for all selected threads.", "You must confirm this statement.": "You must confirm this statement.", - "You unassigned {{assignees}}_one": "You unassigned {{assignees}}", - "You unassigned {{assignees}}_other": "You unassigned {{assignees}}", + "You unassigned yourself": "You unassigned yourself", "You unassigned {{assignees}} and yourself_one": "You unassigned {{assignees}} and yourself", "You unassigned {{assignees}} and yourself_other": "You unassigned {{assignees}} and yourself", - "You unassigned yourself": "You unassigned yourself", + "You unassigned {{assignees}}_one": "You unassigned {{assignees}}", + "You unassigned {{assignees}}_other": "You unassigned {{assignees}}", "You were unassigned": "You were unassigned", "Your email...": "Your email...", - "Your session has expired. Please log in again.": "Your session has expired. Please log in again." + "Your messages have been imported successfully!": "Your messages have been imported successfully!", + "Your session has expired. Please log in again.": "Your session has expired. Please log in again.", + "and {{count}} other users_one": "and 1 other user", + "and {{count}} other users_other": "and {{count}} other users", + "create_mailbox_modal.success.credential_text": "Your Messages credentials are:\n- Email: {{id}}\n- Password: {{password}}\n\nIt will be asked to change your password at your first login.", + "edited": "edited", + "folder.search": "Search", + "just now": "just now", + "less than a minute ago": "less than a minute ago", + "or drag and drop some files": "or drag and drop some files", + "until {{date}}": "until {{date}}", + "{{assignees}} was unassigned_one": "{{assignees}} was unassigned", + "{{assignees}} was unassigned_other": "{{assignees}} were unassigned", + "{{author}} assigned themself": "{{author}} assigned themself", + "{{author}} assigned themself and {{assignees}}_one": "{{author}} assigned themself and {{assignees}}", + "{{author}} assigned themself and {{assignees}}_other": "{{author}} assigned themself and {{assignees}}", + "{{author}} assigned you": "{{author}} assigned you", + "{{author}} assigned you and themself": "{{author}} assigned you and themself", + "{{author}} assigned you and {{assignees}}_one": "{{author}} assigned you and {{assignees}}", + "{{author}} assigned you and {{assignees}}_other": "{{author}} assigned you and {{assignees}}", + "{{author}} assigned you, themself and {{assignees}}_one": "{{author}} assigned you, themself and {{assignees}}", + "{{author}} assigned you, themself and {{assignees}}_other": "{{author}} assigned you, themself and {{assignees}}", + "{{author}} assigned {{assignees}}_one": "{{author}} assigned {{assignees}}", + "{{author}} assigned {{assignees}}_other": "{{author}} assigned {{assignees}}", + "{{author}} unassigned themself": "{{author}} unassigned themself", + "{{author}} unassigned themself and {{assignees}}_one": "{{author}} unassigned themself and {{assignees}}", + "{{author}} unassigned themself and {{assignees}}_other": "{{author}} unassigned themself and {{assignees}}", + "{{author}} unassigned you": "{{author}} unassigned you", + "{{author}} unassigned you and themself": "{{author}} unassigned you and themself", + "{{author}} unassigned you and {{assignees}}_one": "{{author}} unassigned you and {{assignees}}", + "{{author}} unassigned you and {{assignees}}_other": "{{author}} unassigned you and {{assignees}}", + "{{author}} unassigned you, themself and {{assignees}}_one": "{{author}} unassigned you, themself and {{assignees}}", + "{{author}} unassigned you, themself and {{assignees}}_other": "{{author}} unassigned you, themself and {{assignees}}", + "{{author}} unassigned {{assignees}}_one": "{{author}} unassigned {{assignees}}", + "{{author}} unassigned {{assignees}}_other": "{{author}} unassigned {{assignees}}", + "{{count}} assignment changes_one": "{{count}} assignment change", + "{{count}} assignment changes_other": "{{count}} assignment changes", + "{{count}} attachments_one": "{{count}} attachment", + "{{count}} attachments_other": "{{count}} attachments", + "{{count}} attendees_one": "{{count}} attendee", + "{{count}} attendees_other": "{{count}} attendees", + "{{count}} conflicting events_one": "{{count}} conflicting event", + "{{count}} conflicting events_other": "{{count}} conflicting events", + "{{count}} days ago_one": "{{count}} day ago", + "{{count}} days ago_other": "{{count}} days ago", + "{{count}} hours ago_one": "{{count}} hour ago", + "{{count}} hours ago_other": "{{count}} hours ago", + "{{count}} mailboxes_one": "{{count}} mailboxes", + "{{count}} mailboxes_other": "{{count}} mailboxes", + "{{count}} messages are now starred._one": "The message is now starred.", + "{{count}} messages are now starred._other": "{{count}} messages are now starred.", + "{{count}} messages assigned to you_one": "{{count}} message assigned to you", + "{{count}} messages assigned to you_other": "{{count}} messages assigned to you", + "{{count}} messages have been archived._one": "The message has been archived.", + "{{count}} messages have been archived._other": "{{count}} messages have been archived.", + "{{count}} messages have been deleted._one": "The message has been deleted.", + "{{count}} messages have been deleted._other": "{{count}} messages have been deleted.", + "{{count}} messages have been reported as spam._one": "The message has been reported as spam.", + "{{count}} messages have been reported as spam._other": "{{count}} messages have been reported as spam.", + "{{count}} messages have been updated._one": "The message has been updated.", + "{{count}} messages have been updated._other": "{{count}} messages have been updated.", + "{{count}} messages imported_one": "{{count}} message imported", + "{{count}} messages imported_other": "{{count}} messages imported", + "{{count}} messages mentioning you_one": "{{count}} message mentioning you", + "{{count}} messages mentioning you_other": "{{count}} messages mentioning you", + "{{count}} messages of this thread have been deleted._one": "{{count}} message of this thread has been deleted.", + "{{count}} messages of this thread have been deleted._other": "{{count}} messages of this thread have been deleted.", + "{{count}} messages were imported before the error._one": "{{count}} message was imported before the error.", + "{{count}} messages were imported before the error._other": "{{count}} messages were imported before the error.", + "{{count}} messages_one": "{{count}} message", + "{{count}} messages_other": "{{count}} messages", + "{{count}} minutes ago_one": "{{count}} minute ago", + "{{count}} minutes ago_other": "{{count}} minutes ago", + "{{count}} months ago_one": "{{count}} month ago", + "{{count}} months ago_other": "{{count}} months ago", + "{{count}} new message_one": "{{count}} new message", + "{{count}} new message_other": "{{count}} new messages", + "{{count}} occurrences_one": "{{count}} occurrence", + "{{count}} occurrences_other": "{{count}} occurrences", + "{{count}} of which are shared_one": ", {{count}} of which is shared", + "{{count}} of which are shared_other": ", {{count}} of which are shared", + "{{count}} out of {{total}} messages are now starred._one": "{{count}} out of {{total}} message is now starred.", + "{{count}} out of {{total}} messages are now starred._other": "{{count}} out of {{total}} messages are now starred.", + "{{count}} out of {{total}} messages have been archived._one": "{{count}} out of {{total}} message has been archived.", + "{{count}} out of {{total}} messages have been archived._other": "{{count}} out of {{total}} messages have been archived.", + "{{count}} out of {{total}} messages have been deleted._one": "{{count}} out of {{total}} message has been deleted.", + "{{count}} out of {{total}} messages have been deleted._other": "{{count}} out of {{total}} messages have been deleted.", + "{{count}} out of {{total}} messages have been reported as spam._one": "{{count}} out of {{total}} message has been reported as spam.", + "{{count}} out of {{total}} messages have been reported as spam._other": "{{count}} out of {{total}} messages have been reported as spam.", + "{{count}} out of {{total}} threads are now starred._one": "{{count}} out of {{total}} thread is now starred.", + "{{count}} out of {{total}} threads are now starred._other": "{{count}} out of {{total}} threads are now starred.", + "{{count}} out of {{total}} threads have been archived._one": "{{count}} out of {{total}} thread has been archived.", + "{{count}} out of {{total}} threads have been archived._other": "{{count}} out of {{total}} threads have been archived.", + "{{count}} out of {{total}} threads have been deleted._one": "{{count}} out of {{total}} thread has been deleted.", + "{{count}} out of {{total}} threads have been deleted._other": "{{count}} out of {{total}} threads have been deleted.", + "{{count}} out of {{total}} threads have been reported as spam._one": "{{count}} out of {{total}} thread has been reported as spam.", + "{{count}} out of {{total}} threads have been reported as spam._other": "{{count}} out of {{total}} threads have been reported as spam.", + "{{count}} results assigned to you_one": "{{count}} result assigned to you", + "{{count}} results assigned to you_other": "{{count}} results assigned to you", + "{{count}} results mentioning you_one": "{{count}} result mentioning you", + "{{count}} results mentioning you_other": "{{count}} results mentioning you", + "{{count}} results_one": "{{count}} result", + "{{count}} results_other": "{{count}} results", + "{{count}} selected threads_one": "{{count}} selected thread", + "{{count}} selected threads_other": "{{count}} selected threads", + "{{count}} starred messages mentioning you_one": "{{count}} starred message mentioning you", + "{{count}} starred messages mentioning you_other": "{{count}} starred messages mentioning you", + "{{count}} starred messages_one": "{{count}} starred message", + "{{count}} starred messages_other": "{{count}} starred messages", + "{{count}} starred results mentioning you_one": "{{count}} starred result mentioning you", + "{{count}} starred results mentioning you_other": "{{count}} starred results mentioning you", + "{{count}} starred results_one": "{{count}} starred result", + "{{count}} starred results_other": "{{count}} starred results", + "{{count}} threads are now starred._one": "The thread is now starred.", + "{{count}} threads are now starred._other": "{{count}} threads are now starred.", + "{{count}} threads have been archived._one": "The thread has been archived.", + "{{count}} threads have been archived._other": "{{count}} threads have been archived.", + "{{count}} threads have been deleted._one": "The thread has been deleted.", + "{{count}} threads have been deleted._other": "{{count}} threads have been deleted.", + "{{count}} threads have been reported as spam._one": "The thread has been reported as spam.", + "{{count}} threads have been reported as spam._other": "{{count}} threads have been reported as spam.", + "{{count}} threads have been restored._one": "The thread has been restored.", + "{{count}} threads have been restored._other": "{{count}} threads have been restored.", + "{{count}} threads have been unarchived._one": "The thread has been unarchived.", + "{{count}} threads have been unarchived._other": "{{count}} threads have been unarchived.", + "{{count}} threads have been updated._one": "The thread has been updated.", + "{{count}} threads have been updated._other": "{{count}} threads have been updated.", + "{{count}} unread messages mentioning you_one": "{{count}} unread message mentioning you", + "{{count}} unread messages mentioning you_other": "{{count}} unread messages mentioning you", + "{{count}} unread messages_one": "{{count}} unread message", + "{{count}} unread messages_other": "{{count}} unread messages", + "{{count}} unread results mentioning you_one": "{{count}} unread result mentioning you", + "{{count}} unread results mentioning you_other": "{{count}} unread results mentioning you", + "{{count}} unread results_one": "{{count}} unread result", + "{{count}} unread results_other": "{{count}} unread results", + "{{count}} unread starred messages mentioning you_one": "{{count}} unread starred message mentioning you", + "{{count}} unread starred messages mentioning you_other": "{{count}} unread starred messages mentioning you", + "{{count}} unread starred messages_one": "{{count}} unread starred message", + "{{count}} unread starred messages_other": "{{count}} unread starred messages", + "{{count}} unread starred results mentioning you_one": "{{count}} unread starred result mentioning you", + "{{count}} unread starred results mentioning you_other": "{{count}} unread starred results mentioning you", + "{{count}} unread starred results_one": "{{count}} unread starred result", + "{{count}} unread starred results_other": "{{count}} unread starred results", + "{{count}} weeks ago_one": "{{count}} week ago", + "{{count}} weeks ago_other": "{{count}} weeks ago", + "{{count}} years ago_one": "{{count}} year ago", + "{{count}} years ago_other": "{{count}} years ago", + "{{count}}/{{total}} mailboxes_one": "{{count}}/{{total}} mailboxes", + "{{count}}/{{total}} mailboxes_other": "{{count}}/{{total}} mailboxes", + "{{date}} at {{time}}": "{{date}} at {{time}}", + "{{name}} assigned to this thread": "{{name}} assigned to this thread", + "{{name}} unassigned from this thread": "{{name}} unassigned from this thread", + "{{progress}}% imported": "{{progress}}% imported" } diff --git a/src/frontend/public/locales/common/fr-FR.json b/src/frontend/public/locales/common/fr-FR.json index d0e0d62c..3c426827 100755 --- a/src/frontend/public/locales/common/fr-FR.json +++ b/src/frontend/public/locales/common/fr-FR.json @@ -1,212 +1,9 @@ { - "{{assignees}} was unassigned_one": "{{assignees}} a été désassigné", - "{{assignees}} was unassigned_many": "{{assignees}} ont été désassignés", - "{{assignees}} was unassigned_other": "{{assignees}} ont été désassignés", - "{{author}} assigned {{assignees}}_one": "{{author}} a assigné {{assignees}}", - "{{author}} assigned {{assignees}}_many": "{{author}} a assigné {{assignees}}", - "{{author}} assigned {{assignees}}_other": "{{author}} a assigné {{assignees}}", - "{{author}} assigned themself": "{{author}} s'est assigné·e", - "{{author}} assigned themself and {{assignees}}_one": "{{author}} s'est assigné·e ainsi que {{assignees}}", - "{{author}} assigned themself and {{assignees}}_many": "{{author}} s'est assigné·e ainsi que {{assignees}}", - "{{author}} assigned themself and {{assignees}}_other": "{{author}} s'est assigné·e ainsi que {{assignees}}", - "{{author}} assigned you": "{{author}} vous a assigné·e", - "{{author}} assigned you and {{assignees}}_one": "{{author}} vous a assigné·e ainsi que {{assignees}}", - "{{author}} assigned you and {{assignees}}_many": "{{author}} vous a assigné·e ainsi que {{assignees}}", - "{{author}} assigned you and {{assignees}}_other": "{{author}} vous a assigné·e ainsi que {{assignees}}", - "{{author}} assigned you and themself": "{{author}} vous a assigné·e ainsi que lui-même", - "{{author}} assigned you, themself and {{assignees}}_one": "{{author}} vous a assigné·e, lui-même ainsi que {{assignees}}", - "{{author}} assigned you, themself and {{assignees}}_many": "{{author}} vous a assigné·e, lui-même ainsi que {{assignees}}", - "{{author}} assigned you, themself and {{assignees}}_other": "{{author}} vous a assigné·e, lui-même ainsi que {{assignees}}", - "{{author}} unassigned {{assignees}}_one": "{{author}} a désassigné {{assignees}}", - "{{author}} unassigned {{assignees}}_many": "{{author}} a désassigné {{assignees}}", - "{{author}} unassigned {{assignees}}_other": "{{author}} a désassigné {{assignees}}", - "{{author}} unassigned themself": "{{author}} s'est désassigné·e", - "{{author}} unassigned themself and {{assignees}}_one": "{{author}} s'est désassigné·e ainsi que {{assignees}}", - "{{author}} unassigned themself and {{assignees}}_many": "{{author}} s'est désassigné·e ainsi que {{assignees}}", - "{{author}} unassigned themself and {{assignees}}_other": "{{author}} s'est désassigné·e ainsi que {{assignees}}", - "{{author}} unassigned you": "{{author}} vous a désassigné·e", - "{{author}} unassigned you and {{assignees}}_one": "{{author}} vous a désassigné·e ainsi que {{assignees}}", - "{{author}} unassigned you and {{assignees}}_many": "{{author}} vous a désassigné·e ainsi que {{assignees}}", - "{{author}} unassigned you and {{assignees}}_other": "{{author}} vous a désassigné·e ainsi que {{assignees}}", - "{{author}} unassigned you and themself": "{{author}} vous a désassigné·e ainsi que lui-même", - "{{author}} unassigned you, themself and {{assignees}}_one": "{{author}} vous a désassigné·e, lui-même ainsi que {{assignees}}", - "{{author}} unassigned you, themself and {{assignees}}_many": "{{author}} vous a désassigné·e, lui-même ainsi que {{assignees}}", - "{{author}} unassigned you, themself and {{assignees}}_other": "{{author}} vous a désassigné·e, lui-même ainsi que {{assignees}}", - "{{count}} assignment changes_one": "{{count}} changement d'assignation", - "{{count}} assignment changes_many": "{{count}} changements d'assignation", - "{{count}} assignment changes_other": "{{count}} changements d'assignation", - "{{count}} attachments_one": "{{count}} pièce jointe", - "{{count}} attachments_many": "{{count}} pièces jointes", - "{{count}} attachments_other": "{{count}} pièces jointes", - "{{count}} attendees_one": "{{count}} participant", - "{{count}} attendees_many": "{{count}} participants", - "{{count}} attendees_other": "{{count}} participants", - "{{count}} days ago_one": "il y a {{count}} jour", - "{{count}} days ago_many": "il y a {{count}} jours", - "{{count}} days ago_other": "il y a {{count}} jours", - "{{count}} hours ago_one": "il y a {{count}} heure", - "{{count}} hours ago_many": "il y a {{count}} heures", - "{{count}} hours ago_other": "il y a {{count}} heures", - "{{count}} mailboxes_one": "{{count}} boîte aux lettres", - "{{count}} mailboxes_many": "{{count}} boîtes aux lettres", - "{{count}} mailboxes_other": "{{count}} boîtes aux lettres", - "{{count}} messages_one": "{{count}} message", - "{{count}} messages_many": "{{count}} messages", - "{{count}} messages_other": "{{count}} messages", - "{{count}} messages are now starred._one": "{{count}} message est maintenant marqué pour suivi.", - "{{count}} messages are now starred._many": "{{count}} messages sont maintenant marqués pour suivi.", - "{{count}} messages are now starred._other": "{{count}} messages sont maintenant marqués pour suivi.", - "{{count}} messages assigned to you_one": "{{count}} message qui vous est assigné", - "{{count}} messages assigned to you_many": "{{count}} messages qui vous sont assignés", - "{{count}} messages assigned to you_other": "{{count}} messages qui vous sont assignés", - "{{count}} messages have been archived._one": "Le message a été archivé.", - "{{count}} messages have been archived._many": "{{count}} messages ont été archivés.", - "{{count}} messages have been archived._other": "{{count}} messages ont été archivés.", - "{{count}} messages have been deleted._one": "Le message a été supprimé.", - "{{count}} messages have been deleted._many": "{{count}} messages ont été supprimés.", - "{{count}} messages have been deleted._other": "{{count}} messages ont été supprimés.", - "{{count}} messages have been reported as spam._one": "Le message a été signalé comme spam.", - "{{count}} messages have been reported as spam._many": "{{count}} messages ont été signalés comme spam.", - "{{count}} messages have been reported as spam._other": "{{count}} messages ont été signalés comme spam.", - "{{count}} messages have been updated._one": "Le message a été mis à jour.", - "{{count}} messages have been updated._many": "{{count}} messages ont été mis à jour.", - "{{count}} messages have been updated._other": "{{count}} messages ont été mis à jour.", - "{{count}} messages imported_one": "{{count}} message importé", - "{{count}} messages imported_many": "{{count}} messages importés", - "{{count}} messages imported_other": "{{count}} messages importés", - "{{count}} messages mentioning you_one": "{{count}} message vous mentionnant", - "{{count}} messages mentioning you_many": "{{count}} messages vous mentionnant", - "{{count}} messages mentioning you_other": "{{count}} messages vous mentionnant", - "{{count}} messages of this thread have been deleted._one": "{{count}} message de cette conversation a été supprimé.", - "{{count}} messages of this thread have been deleted._many": "{{count}} messages de cette conversation ont été supprimés.", - "{{count}} messages of this thread have been deleted._other": "{{count}} messages de cette conversation ont été supprimés.", - "{{count}} messages were imported before the error._one": "{{count}} message a été importé avant l'erreur.", - "{{count}} messages were imported before the error._many": "{{count}} messages ont été importés avant l'erreur.", - "{{count}} messages were imported before the error._other": "{{count}} messages ont été importés avant l'erreur.", - "{{count}} minutes ago_one": "il y a {{count}} minute", - "{{count}} minutes ago_many": "il y a {{count}} minutes", - "{{count}} minutes ago_other": "il y a {{count}} minutes", - "{{count}} months ago_one": "il y a {{count}} mois", - "{{count}} months ago_many": "il y a {{count}} mois", - "{{count}} months ago_other": "il y a {{count}} mois", - "{{count}} new message_one": "{{count}} nouveau message", - "{{count}} new message_many": "{{count}} nouveaux messages", - "{{count}} new message_other": "{{count}} nouveaux messages", - "{{count}} occurrences_one": "{{count}} événement", - "{{count}} occurrences_many": "{{count}} événements", - "{{count}} occurrences_other": "{{count}} événements", - "{{count}} of which are shared_one": " dont {{count}} partagée", - "{{count}} of which are shared_many": " dont {{count}} partagées", - "{{count}} of which are shared_other": " dont {{count}} partagées", - "{{count}} out of {{total}} messages are now starred._one": "{{count}} message sur {{total}} est maintenant suivi.", - "{{count}} out of {{total}} messages are now starred._many": "{{count}} messages sur {{total}} sont maintenant suivis.", - "{{count}} out of {{total}} messages are now starred._other": "{{count}} messages sur {{total}} sont maintenant suivis.", - "{{count}} out of {{total}} messages have been archived._one": "{{count}} message sur {{total}} a été archivé.", - "{{count}} out of {{total}} messages have been archived._many": "{{count}} messages sur {{total}} ont été archivés.", - "{{count}} out of {{total}} messages have been archived._other": "{{count}} messages sur {{total}} ont été archivés.", - "{{count}} out of {{total}} messages have been deleted._one": "{{count}} message sur {{total}} a été supprimé.", - "{{count}} out of {{total}} messages have been deleted._many": "{{count}} messages sur {{total}} ont été supprimés.", - "{{count}} out of {{total}} messages have been deleted._other": "{{count}} messages sur {{total}} ont été supprimés.", - "{{count}} out of {{total}} messages have been reported as spam._one": "{{count}} message sur {{total}} a été signalé comme spam.", - "{{count}} out of {{total}} messages have been reported as spam._many": "{{count}} messages sur {{total}} ont été signalés comme spam.", - "{{count}} out of {{total}} messages have been reported as spam._other": "{{count}} messages sur {{total}} ont été signalés comme spam.", - "{{count}} out of {{total}} threads are now starred._one": "{{count}} conversation sur {{total}} est maintenant suivie.", - "{{count}} out of {{total}} threads are now starred._many": "{{count}} conversations sur {{total}} sont maintenant suivies.", - "{{count}} out of {{total}} threads are now starred._other": "{{count}} conversations sur {{total}} sont maintenant suivies.", - "{{count}} out of {{total}} threads have been archived._one": "{{count}} conversation sur {{total}} a été archivée.", - "{{count}} out of {{total}} threads have been archived._many": "{{count}} conversations sur {{total}} ont été archivées.", - "{{count}} out of {{total}} threads have been archived._other": "{{count}} conversations sur {{total}} ont été archivées.", - "{{count}} out of {{total}} threads have been deleted._one": "{{count}} conversation sur {{total}} a été supprimée.", - "{{count}} out of {{total}} threads have been deleted._many": "{{count}} conversations sur {{total}} ont été supprimées.", - "{{count}} out of {{total}} threads have been deleted._other": "{{count}} conversations sur {{total}} ont été supprimées.", - "{{count}} out of {{total}} threads have been reported as spam._one": "{{count}} conversation sur {{total}} a été signalée comme spam.", - "{{count}} out of {{total}} threads have been reported as spam._many": "{{count}} conversations sur {{total}} ont été signalées comme spam.", - "{{count}} out of {{total}} threads have been reported as spam._other": "{{count}} conversations sur {{total}} ont été signalées comme spam.", - "{{count}} results_one": "{{count}} résultat", - "{{count}} results_many": "{{count}} résultats", - "{{count}} results_other": "{{count}} résultats", - "{{count}} results assigned to you_one": "{{count}} résultat qui vous est assigné", - "{{count}} results assigned to you_many": "{{count}} résultats qui vous sont assignés", - "{{count}} results assigned to you_other": "{{count}} résultats qui vous sont assignés", - "{{count}} results mentioning you_one": "{{count}} résultat vous mentionnant", - "{{count}} results mentioning you_many": "{{count}} résultats vous mentionnant", - "{{count}} results mentioning you_other": "{{count}} résultats vous mentionnant", - "{{count}} selected threads_one": "{{count}} conversation sélectionnée", - "{{count}} selected threads_many": "{{count}} conversations sélectionnées", - "{{count}} selected threads_other": "{{count}} conversations sélectionnées", - "{{count}} starred messages_one": "{{count}} message suivi", - "{{count}} starred messages_many": "{{count}} messages suivis", - "{{count}} starred messages_other": "{{count}} messages suivis", - "{{count}} starred messages mentioning you_one": "{{count}} message suivi vous mentionnant", - "{{count}} starred messages mentioning you_many": "{{count}} messages suivis vous mentionnant", - "{{count}} starred messages mentioning you_other": "{{count}} messages suivis vous mentionnant", - "{{count}} starred results_one": "{{count}} résultat suivi", - "{{count}} starred results_many": "{{count}} résultats suivis", - "{{count}} starred results_other": "{{count}} résultats suivis", - "{{count}} starred results mentioning you_one": "{{count}} résultat suivi vous mentionnant", - "{{count}} starred results mentioning you_many": "{{count}} résultats suivis vous mentionnant", - "{{count}} starred results mentioning you_other": "{{count}} résultats suivis vous mentionnant", - "{{count}} threads are now starred._one": "{{count}} conversation est maintenant marquée pour suivi.", - "{{count}} threads are now starred._many": "{{count}} conversations sont maintenant marquées pour suivi.", - "{{count}} threads are now starred._other": "{{count}} conversations sont maintenant marquées pour suivi.", - "{{count}} threads have been archived._one": "La conversation a été archivée.", - "{{count}} threads have been archived._many": "{{count}} conversations ont été archivées.", - "{{count}} threads have been archived._other": "{{count}} conversations ont été archivées.", - "{{count}} threads have been deleted._one": "La conversation a été supprimée.", - "{{count}} threads have been deleted._many": "{{count}} conversations ont été supprimées.", - "{{count}} threads have been deleted._other": "{{count}} conversations ont été supprimées.", - "{{count}} threads have been reported as spam._one": "La conversation a été signalée comme spam.", - "{{count}} threads have been reported as spam._many": "{{count}} conversations ont été signalées comme spam.", - "{{count}} threads have been reported as spam._other": "{{count}} conversations ont été signalées comme spam.", - "{{count}} threads have been restored._one": "La conversation a été restaurée.", - "{{count}} threads have been restored._many": "{{count}} conversations ont été restaurées.", - "{{count}} threads have been restored._other": "{{count}} conversations ont été restaurées.", - "{{count}} threads have been unarchived._one": "La conversation a été désarchivée.", - "{{count}} threads have been unarchived._many": "{{count}} conversations ont été désarchivées.", - "{{count}} threads have been unarchived._other": "{{count}} conversations ont été désarchivées.", - "{{count}} threads have been updated._one": "La conversation a été mise à jour.", - "{{count}} threads have been updated._many": "{{count}} conversations ont été mises à jour.", - "{{count}} threads have been updated._other": "{{count}} conversations ont été mises à jour.", - "{{count}} unread messages_one": "{{count}} message non lu", - "{{count}} unread messages_many": "{{count}} messages non lus", - "{{count}} unread messages_other": "{{count}} messages non lus", - "{{count}} unread messages mentioning you_one": "{{count}} message non lu vous mentionnant", - "{{count}} unread messages mentioning you_many": "{{count}} messages non lus vous mentionnant", - "{{count}} unread messages mentioning you_other": "{{count}} messages non lus vous mentionnant", - "{{count}} unread results_one": "{{count}} résultat non lu", - "{{count}} unread results_many": "{{count}} résultats non lus", - "{{count}} unread results_other": "{{count}} résultats non lus", - "{{count}} unread results mentioning you_one": "{{count}} résultat non lu vous mentionnant", - "{{count}} unread results mentioning you_many": "{{count}} résultats non lus vous mentionnant", - "{{count}} unread results mentioning you_other": "{{count}} résultats non lus vous mentionnant", - "{{count}} unread starred messages_one": "{{count}} message suivi non lu", - "{{count}} unread starred messages_many": "{{count}} messages suivis non lus", - "{{count}} unread starred messages_other": "{{count}} messages suivis non lus", - "{{count}} unread starred messages mentioning you_one": "{{count}} message suivi non lu vous mentionnant", - "{{count}} unread starred messages mentioning you_many": "{{count}} messages suivis non lus vous mentionnant", - "{{count}} unread starred messages mentioning you_other": "{{count}} messages suivis non lus vous mentionnant", - "{{count}} unread starred results_one": "{{count}} résultat suivi non lu", - "{{count}} unread starred results_many": "{{count}} résultats suivis non lus", - "{{count}} unread starred results_other": "{{count}} résultats suivis non lus", - "{{count}} unread starred results mentioning you_one": "{{count}} résultat suivi non lu vous mentionnant", - "{{count}} unread starred results mentioning you_many": "{{count}} résultats suivis non lus vous mentionnant", - "{{count}} unread starred results mentioning you_other": "{{count}} résultats suivis non lus vous mentionnant", - "{{count}} weeks ago_one": "il y a {{count}} semaine", - "{{count}} weeks ago_many": "il y a {{count}} semaines", - "{{count}} weeks ago_other": "il y a {{count}} semaines", - "{{count}} years ago_one": "il y a {{count}} an", - "{{count}} years ago_many": "il y a {{count}} ans", - "{{count}} years ago_other": "il y a {{count}} ans", - "{{count}}/{{total}} mailboxes_one": "{{count}}/{{total}} boîte aux lettres", - "{{count}}/{{total}} mailboxes_many": "{{count}}/{{total}} boîtes aux lettres", - "{{count}}/{{total}} mailboxes_other": "{{count}}/{{total}} boîtes aux lettres", - "{{date}} at {{time}}": "{{date}} à {{time}}", - "{{name}} assigned to this thread": "{{name}} assigné à cette conversation", - "{{name}} unassigned from this thread": "{{name}} désassigné de cette conversation", - "{{progress}}% imported": "{{progress}}% importés", "2 columns": "2 colonnes", "2FA has been reset for {{mailbox}}.": "La 2FA a été réinitialisée pour {{mailbox}}.", + "API Key": "Clé API", "Abort upload": "Annuler le téléversement", + "Accept": "Accepter", "Accepted": "Accepté", "Accesses": "Accès", "Actions": "Actions", @@ -222,6 +19,7 @@ "Add labels": "Ajouter des libellés", "Add tags": "Ajouter des libellés", "Add this code snippet to your website to display the feedback widget.": "Ajoutez ce code à votre site web pour afficher le widget de contact.", + "Add to calendar": "Ajouter à l'agenda", "Address": "Adresse", "Addresses": "Adresses", "After creating the widget, you will receive the installation code to add to your website.": "Après avoir créé le widget, vous recevrez le code d'installation à ajouter à votre site web.", @@ -239,10 +37,6 @@ "An error occurred while updating the address.": "Une erreur est survenue lors de la mise à jour de l'adresse.", "An error occurred while uploading the archive file.": "Une erreur est survenue lors du téléversement de l'archive.", "An unexpected error occurred.": "Une erreur inattendue s’est produite.", - "and {{count}} other users_one": "et 1 autre utilisateur", - "and {{count}} other users_many": "et {{count}} autres utilisateurs", - "and {{count}} other users_other": "et {{count}} autres utilisateurs", - "API Key": "Clé API", "Archive": "Archiver", "Archives": "Archives", "Are you sure you want to close this dialog? Your upload will be aborted!": "Êtes-vous sûr de vouloir fermer cette boîte de dialogue ? Votre téléversement sera annulé !", @@ -260,12 +54,12 @@ "Assign this label and archive": "Assigner ce libellé et archiver", "Assign to...": "Assigner à…", "Assign users to this thread": "Assigner des utilisateurs à ce thread", - "Assigned to {{count}} people_one": "Assigné à {{count}} personne", - "Assigned to {{count}} people_many": "Assigné à {{count}} personnes", - "Assigned to {{count}} people_other": "Assigné à {{count}} personnes", - "Assigned to {{names}}": "Assigné à {{names}}", "Assigned to me": "Assigné à moi", "Assigned to this thread": "Assigné à cette conversation", + "Assigned to {{count}} people_many": "Assigné à {{count}} personnes", + "Assigned to {{count}} people_one": "Assigné à {{count}} personne", + "Assigned to {{count}} people_other": "Assigné à {{count}} personnes", + "Assigned to {{names}}": "Assigné à {{names}}", "At least one recipient is required.": "Il faut au moins un destinataire.", "Attachment failed to be saved into your {{driveAppName}}'s workspace.": "Impossible de sauvegarder la pièce jointe dans votre espace de travail {{driveAppName}}.", "Attachment saved into your {{driveAppName}}'s workspace.": "Pièce jointe sauvegardée dans votre espace de travail {{driveAppName}}.", @@ -282,18 +76,21 @@ "Auto-reply updated!": "Réponse automatique mise à jour !", "Automatically create mailboxes according to OIDC emails": "Créer les boîtes aux lettres automatiquement selon les emails OIDC", "Awaiting response": "En attente de réponse", + "BCC: ": "CCI : ", "Back": "Précédent", "Back to your inbox": "Retour à votre messagerie", - "BCC: ": "CCI : ", "Blind copy: ": "Copie cachée : ", + "CC: ": "Copie : ", "Calendar invite": "Invitation calendrier", + "Calendar service unavailable": "Service d'agenda indisponible", "Cancel": "Annuler", "Cancel those sendings": "Annuler ces envois", + "Cancelled": "Annulé", "Cannot add attachment(s). Total size would be more than {{maxSize}}.": "Impossible d'ajouter ces pièces jointes. La taille totale dépasserait la limite autorisée de {{maxSize}}.", "Cannot add image. File size exceeds the {{maxSize}} limit.": "Impossible d'ajouter l'image. La taille du fichier dépasse la limite de {{maxSize}}.", - "CC: ": "Copie : ", "Check DNS again": "Revérifier les DNS", "Checking DNS records...": "Vérification des enregistrements DNS...", + "Choose calendar": "Choisir l'agenda", "Choose the type of integration you want to create": "Choisissez le type d'intégration que vous souhaitez créer", "Clear filters": "Retirer les filtres", "Clear selected items": "Supprimer les éléments sélectionnés", @@ -304,11 +101,13 @@ "Close the menu": "Fermer le menu", "Close this thread": "Fermer cette conversation", "Collapse": "Réduire", - "Collapse {{name}}": "Réduire {{name}}", "Collapse all": "Tout réduire", + "Collapse {{name}}": "Réduire {{name}}", "Color: ": "Couleur : ", "Coming soon": "Bientôt disponible", + "Confirmed": "Confirmé", "Conflicting": "En conflit", + "Connecting to calendar…": "Connexion à l'agenda…", "Contact the Support team": "Écrire à l'équipe support", "Contains the words": "Contient les mots", "Content is required": "Un contenu est requis", @@ -322,8 +121,10 @@ "Copy to clipboard": "Copier dans le presse-papiers", "Copy: ": "Copie : ", "Correct": "Correct", + "Could not confirm the calendar update.": "Impossible de confirmer la mise à jour du calendrier.", "Create": "Créer", "Create a Label": "Créer un libellé", + "Create a Widget": "Créer un Widget", "Create a new address @{{domain}}": "Création d'une nouvelle adresse @{{domain}}", "Create a new auto-reply": "Créer une nouvelle réponse automatique", "Create a new integration": "Créer une nouvelle intégration", @@ -334,18 +135,19 @@ "Create a new signature for {{domain}}": "Création d'une nouvelle signature pour {{domain}}", "Create a new template": "Créer un nouveau modèle", "Create a simple redirect (Coming soon)": "Créer une simple redirection (Bientôt disponible)", - "Create a Widget": "Créer un Widget", "Create integration": "Créer l'intégration", + "Create one": "En créer un", "Create the label \"{{label}}\"": "Créer le libellé \"{{label}}\"", - "create_mailbox_modal.success.credential_text": "Vos identifiants Messages sont : \n- Courriel : {{id}}\n- Mot de passe : {{password}}\n\nIl vous sera demandé de changer votre mot de passe lors de votre première connexion.", "Created at": "Créé le", "Creating...": "Création en cours...", "Credentials copied!": "Identifiants copiés !", "Current status": "Statut actuel", + "DNS": "DNS", "Daily": "Quotidien", "Date range": "Plage de dates", "Date:": "Date :", "Date: ": "Date : ", + "Decline": "Refuser", "Declined": "Refusé", "Default": "Par défaut", "Default signature": "Signature par défaut", @@ -369,7 +171,6 @@ "Did you forget an attachment?": "N'avez-vous pas oublié une pièce jointe ?", "Disable thread selection": "Désactiver la sélection", "Display those images": "Afficher ces images", - "DNS": "DNS", "Do you have any feedback?": "Partager un retour ou une question", "Domain": "Domaine", "Domain admin": "Gestion des domaines", @@ -385,16 +186,15 @@ "Drag and drop an archive here": "Glissez-déposez une archive ici", "Drop your attachments here": "Déposez vos pièces jointes ici", "Duplicate": "Dupliqué", + "EML, MBOX or PST": "EML, MBOX ou PST", "Edit": "Modifier", - "Edit {{mailbox}} address": "Modifier l'adresse {{mailbox}}", + "Edit Widget": "Modifier le Widget", "Edit auto-reply \"{{autoreply}}\"": "Modifier la réponse automatique \"{{autoreply}}\"", "Edit signature \"{{signature}}\"": "Modifier la signature \"{{signature}}\"", "Edit template \"{{template}}\"": "Modifier le modèle \"{{template}}\"", - "Edit Widget": "Modifier le Widget", - "edited": "modifié", + "Edit {{mailbox}} address": "Modifier l'adresse {{mailbox}}", "Editing message": "Modification du message", "Email address": "Adresse mail", - "EML, MBOX or PST": "EML, MBOX ou PST", "End date": "Date de fin", "End date is required": "La date de fin est requise", "End day": "Jour de fin", @@ -408,22 +208,23 @@ "Error while loading integrations": "Erreur lors du chargement des intégrations", "Error while loading signatures": "Erreur lors du chargement des signatures", "Error while loading templates": "Erreur lors du chargement des modèles", - "Every {{count}} days_one": "Tous les jours", + "Event added to calendar": "Événement ajouté à l'agenda", "Every {{count}} days_many": "Tous les {{count}} jours", + "Every {{count}} days_one": "Tous les jours", "Every {{count}} days_other": "Tous les {{count}} jours", - "Every {{count}} months_one": "Tous les mois", "Every {{count}} months_many": "Tous les {{count}} mois", + "Every {{count}} months_one": "Tous les mois", "Every {{count}} months_other": "Tous les {{count}} mois", - "Every {{count}} weeks_one": "Toutes les semaines", "Every {{count}} weeks_many": "Toutes les {{count}} semaines", + "Every {{count}} weeks_one": "Toutes les semaines", "Every {{count}} weeks_other": "Toutes les {{count}} semaines", - "Every {{count}} years_one": "Tous les ans", "Every {{count}} years_many": "Tous les {{count}} ans", + "Every {{count}} years_one": "Tous les ans", "Every {{count}} years_other": "Tous les {{count}} ans", "Existing 2FA credentials will be removed. The user will be asked to re-enroll on next login.": "Les identifiants 2FA existants seront supprimés. L'utilisateur devra se ré-inscrire à la prochaine connexion.", "Expand": "Développer", - "Expand {{name}}": "Développer {{name}}", "Expand all": "Tout développer", + "Expand {{name}}": "Développer {{name}}", "Failed to delete auto-reply.": "Erreur lors de la suppression de la réponse automatique.", "Failed to delete integration.": "Erreur lors de la suppression de l'intégration.", "Failed to delete signature.": "Erreur lors de la suppression de la signature.", @@ -438,8 +239,8 @@ "Failed to save template. Please try again.": "Erreur lors de la sauvegarde du modèle. Veuillez réessayer.", "Failed to update auto-reply.": "Erreur lors de la mise à jour de la réponse automatique.", "Failed to update signature.": "Erreur lors de la mise à jour de la signature.", - "Failed: {{count}} messages_one": "Échoué : {{count}} message", "Failed: {{count}} messages_many": "Échoué : {{count}} messages", + "Failed: {{count}} messages_one": "Échoué : {{count}} message", "Failed: {{count}} messages_other": "Échoué : {{count}} messages", "Filter by: {{filters}}": "Filtrer par : {{filters}}", "Filter threads": "Filtrer les conversations", @@ -447,7 +248,6 @@ "First name is required.": "Le prénom est requis.", "First, we need some information about your old mailbox": "Tout d'abord, nous avons besoin de quelques informations sur votre ancienne boîte aux lettres", "Fold message": "Réduire le message", - "folder.search": "Recherche", "For your security and privacy, external images are not displayed.": "Pour votre sécurité et votre vie privée, les images externes ne sont pas affichées.", "Forced": "Forcée", "Forced signature": "Signature forcée", @@ -468,20 +268,20 @@ "How to allow IMAP connections from your account {{name}}?": "Comment autoriser les connexions IMAP depuis votre compte {{name}} ?", "I confirm that this address corresponds to the real identity of a colleague, and I commit to deactivating it when their position ends.": "Je confirme que cette adresse correspond à l'identité d'une personne physique travaillant avec moi, et m'engage à la désactiver quand son poste prendra fin.", "I have an issue or a feature request": "J'ai un problème ou une demande d'amélioration", - "Identity": "Identifiant", - "Image + Text": "Image + Texte", - "Image size limit exceeded": "Taille de l'image trop grande", "IMAP port": "Port IMAP", "IMAP server": "Serveur IMAP", "IMAP server is required.": "Le serveur IMAP est requis.", + "Identity": "Identifiant", + "Image + Text": "Image + Texte", + "Image size limit exceeded": "Taille de l'image trop grande", "Import": "Importer", "Import complete": "Importation terminée", "Import failed": "Importation échouée", "Import messages": "Importer des messages", "Import your old messages in {{mailbox}}": "Importer vos anciens messages dans {{mailbox}}", "Imported messages": "Messages importés", - "Imported: {{count}} of {{total}} messages_one": "Importé : {{count}} message sur {{total}}", "Imported: {{count}} of {{total}} messages_many": "Importé : {{count}} messages sur {{total}}", + "Imported: {{count}} of {{total}} messages_one": "Importé : {{count}} message sur {{total}}", "Imported: {{count}} of {{total}} messages_other": "Importé : {{count}} messages sur {{total}}", "Importing messages...": "Importation des messages...", "Importing...": "Importation en cours...", @@ -500,16 +300,15 @@ "Integration deleted!": "Intégration supprimée !", "Integration updated!": "Intégration mise à jour !", "Integrations": "Intégrations", - "just now": "à l'instant", - "Label \"{{label}}\" assigned and {{count}} threads archived._one": "Libellé \"{{label}}\" assigné et conversation archivée.", "Label \"{{label}}\" assigned and {{count}} threads archived._many": "Libellé \"{{label}}\" assigné et {{count}} conversations archivées.", + "Label \"{{label}}\" assigned and {{count}} threads archived._one": "Libellé \"{{label}}\" assigné et conversation archivée.", "Label \"{{label}}\" assigned and {{count}} threads archived._other": "Libellé \"{{label}}\" assigné et {{count}} conversations archivées.", - "Label \"{{label}}\" assigned to {{count}} threads._one": "Libellé \"{{label}}\" assigné à la conversation.", "Label \"{{label}}\" assigned to {{count}} threads._many": "Libellé \"{{label}}\" assigné à {{count}} conversations.", + "Label \"{{label}}\" assigned to {{count}} threads._one": "Libellé \"{{label}}\" assigné à la conversation.", "Label \"{{label}}\" assigned to {{count}} threads._other": "Libellé \"{{label}}\" assigné à {{count}} conversations.", "Label \"{{label}}\" assigned, but no threads could be archived.": "Libellé \"{{label}}\" assigné, mais aucune conversation n'a pu être archivée.", - "Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._one": "Libellé \"{{label}}\" assigné. {{count}} conversation sur {{total}} archivée.", "Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._many": "Libellé \"{{label}}\" assigné. {{count}} conversations sur {{total}} archivées.", + "Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._one": "Libellé \"{{label}}\" assigné. {{count}} conversation sur {{total}} archivée.", "Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._other": "Libellé \"{{label}}\" assigné. {{count}} conversations sur {{total}} archivées.", "Label \"{{label}}\" removed from this conversation.": "Libellé \"{{label}}\" retiré de cette conversation.", "Label name": "Nom du libellé", @@ -522,7 +321,6 @@ "Layout": "Mise en page", "Leave this thread": "Quitter cette conversation", "Leave this thread?": "Quitter cette conversation ?", - "less than a minute ago": "il y a moins d'une minute", "Link copied to clipboard": "Lien copié dans le presse-papiers", "Loading addresses...": "Chargement des adresses...", "Loading auto-replies...": "Chargement des réponses automatiques...", @@ -540,12 +338,12 @@ "Loading variables...": "Chargement des variables...", "Loading…": "Chargement…", "Logout": "Déconnexion", - "Mailbox {{mailbox}} has been deleted successfully.": "La boîte aux lettres {{mailbox}} a été supprimée avec succès.", "Mailbox count": "Nombre de BAL", "Mailbox is required.": "Vous devez choisir une boîte d'envoi.", + "Mailbox {{mailbox}} has been deleted successfully.": "La boîte aux lettres {{mailbox}} a été supprimée avec succès.", "Maildomains management": "Gestion des domaines", - "Manage {{entity}} accesses": "Gérer les accès à {{entity}}", "Manage accesses": "Gérer les accès", + "Manage {{entity}} accesses": "Gérer les accès à {{entity}}", "Mandatory 2FA": "2FA obligatoire", "Mandatory 2FA disabled for {{mailbox}}.": "2FA obligatoire désactivée pour {{mailbox}}.", "Mandatory 2FA enabled for {{mailbox}}.": "2FA obligatoire activée pour {{mailbox}}.", @@ -555,6 +353,7 @@ "Mark as read from here": "Marquer comme lu à partir d'ici", "Mark as unread": "Marquer comme non lu", "Mark as unread from here": "Marquer comme non lu à partir d'ici", + "Maybe": "Peut-être", "Mentioned": "Mentionné", "Message content": "Contenu du message", "Message from {referer_domain}": "Message de {referer_domain}", @@ -569,8 +368,8 @@ "More": "Plus", "More options": "Plus d'options", "More options (none available for this mailbox)": "Plus d'options (aucune disponible pour cette boîte aux lettres)", - "Move {{count}} threads_one": "Déplacer {{count}} conversation", "Move {{count}} threads_many": "Déplacer {{count}} conversations", + "Move {{count}} threads_one": "Déplacer {{count}} conversation", "Move {{count}} threads_other": "Déplacer {{count}} conversations", "My auto-replies": "Mes réponses automatiques", "My message templates": "Mes modèles de message", @@ -586,12 +385,12 @@ "New message": "Nouveau message", "New signature": "Nouvelle signature", "New template": "Nouveau modèle", + "No DNS records found": "Aucun enregistrement DNS trouvé", "No accesses": "Aucun accès", "No action available for this mailbox": "Aucune action disponible pour cette boîte aux lettres", "No addresses found": "Aucune adresse trouvée", "No attachments": "Aucune pièce jointe", "No auto-replies found": "Aucune réponse automatique trouvée", - "No DNS records found": "Aucun enregistrement DNS trouvé", "No event found in calendar invite": "Aucun événement trouvé dans l'invitation calendrier", "No integration found": "Aucune intégration trouvée", "No mailbox": "Aucune boîte aux lettres", @@ -616,11 +415,12 @@ "Older": "Plus ancien", "On going": "En cours", "Only available for personal mailboxes in identity-synced domains.": "Disponible uniquement pour les boîtes personnelles dans les domaines synchronisés avec l'annuaire.", - "Open {{driveAppName}} preview": "Ouvrir l'aperçu dans {{driveAppName}}", + "Open calendar": "Ouvrir l'agenda", "Open filters": "Ouvrir les filtres", "Open the menu": "Ouvrir le menu", + "Open {{driveAppName}} preview": "Ouvrir l'aperçu dans {{driveAppName}}", "Or": "Ou", - "or drag and drop some files": "ou glissez-déposez des fichiers", + "Organizer": "Organisateur", "Other services...": "Autres services...", "Outbox": "Boîte d'envoi", "Password": "Mot de passe", @@ -641,11 +441,11 @@ "Refresh": "Actualiser", "Refresh summary": "Actualiser le résumé", "Remove": "Supprimer", - "Remove {{displayName}}": "Supprimer {{displayName}}", "Remove access?": "Retirer l'accès ?", "Remove report": "Annuler le signalement", "Remove spam report": "Annuler le signalement spam", "Remove tag": "Supprimer le tag", + "Remove {{displayName}}": "Supprimer {{displayName}}", "Reply": "Répondre", "Reply all": "Répondre à tous", "Report as spam": "Signaler comme spam", @@ -654,6 +454,7 @@ "Reset 2FA for {{mailbox}}": "Réinitialiser la 2FA pour {{mailbox}}", "Reset password": "Réinitialiser le mot de passe", "Reset password of {{mailbox}}": "Réinitialiser le mot de passe de {{mailbox}}", + "Response saved — the organizer will be notified": "Réponse enregistrée — l'organisateur sera notifié", "Retry": "Réessayer", "Saturday": "Samedi", "Save": "Enregistrer", @@ -673,8 +474,8 @@ "Search in messages...": "Rechercher dans vos messages...", "Search results": "Résultats de la recherche", "Search users": "Rechercher des utilisateurs", - "See members of this thread ({{count}} members)_one": "Voir les membres de cette conversation ({{count}} membre)", "See members of this thread ({{count}} members)_many": "Voir les membres de cette conversation ({{count}} membres)", + "See members of this thread ({{count}} members)_one": "Voir les membres de cette conversation ({{count}} membre)", "See members of this thread ({{count}} members)_other": "Voir les membres de cette conversation ({{count}} membres)", "Select a parent label": "Sélectionnez un libellé parent", "Select a thread": "Sélectionner une conversation", @@ -682,30 +483,31 @@ "Select thread": "Sélectionner une conversation", "Select threads": "Sélectionner des conversations", "Send": "Envoyer", + "Send Feedback": "Envoyer le message", "Send and archive": "Envoyer et archiver", "Send and receive your messages in an instant.": "Envoyez et recevez vos messages en un instant.", - "Send Feedback": "Envoyer le message", "Sending message...": "Envoi du message en cours...", "Sent": "Envoyés", "Sent by {{name}}": "Envoyé par {{name}}", "Settings": "Paramètres", + "Share access": "Partager l'accès", "Share and assign the thread": "Partager et assigner la conversation", "Share the credentials of this mailbox with its user. You must transfer them securely, preferably physically.": "Transmettez les identifiants de cette boîte mail à son utilisateur de façon sécurisée, de préférence physiquement.", "Share the new credentials to the user.": "Transmettez les nouveaux identifiants à l'utilisateur.", "Share the thread": "Partager la conversation", "Share your feedback here...": "Saisir votre message...", - "Shared between {{count}} mailboxes_one": "Partagé entre {{count}} boîte aux lettres", "Shared between {{count}} mailboxes_many": "Partagé entre {{count}} boîtes aux lettres", + "Shared between {{count}} mailboxes_one": "Partagé entre {{count}} boîte aux lettres", "Shared between {{count}} mailboxes_other": "Partagé entre {{count}} boîtes aux lettres", "Shared mailbox": "Boîte partagée", "Show": "Afficher", - "Show {{count}} more_one": "Afficher {{count}} de plus", - "Show {{count}} more_many": "Afficher {{count}} de plus", - "Show {{count}} more_other": "Afficher {{count}} de plus", "Show embedded message": "Afficher le message inclus", "Show less": "Afficher moins", "Show logs": "En savoir plus", "Show more": "Afficher plus", + "Show {{count}} more_many": "Afficher {{count}} de plus", + "Show {{count}} more_one": "Afficher {{count}} de plus", + "Show {{count}} more_other": "Afficher {{count}} de plus", "Signature created!": "Signature créée !", "Signature deleted!": "Signature supprimée !", "Signature updated!": "Signature mise à jour !", @@ -717,8 +519,8 @@ "Some messages have not been delivered to all recipients.": "Certains messages n'ont pas pu être délivrés à tous les destinataires.", "Some recipients have not received this message!": "Certains destinataires n'ont pas reçu ce message !", "Spam": "Pourriel", - "Spam report removed from {{count}} threads._one": "Le signalement spam a été annulé.", "Spam report removed from {{count}} threads._many": "{{count}} signalements spam ont été annulés.", + "Spam report removed from {{count}} threads._one": "Le signalement spam a été annulé.", "Spam report removed from {{count}} threads._other": "{{count}} signalements spam ont été annulés.", "Split thread": "Séparer la conversation", "Split thread from here": "Séparer la conversation à partir d'ici", @@ -745,6 +547,7 @@ "Synchronize mailboxes with an identity provider": "Synchroniser les boîtes aux lettres avec un fournisseur d'identité", "Tags": "Libellés", "Target": "Cible", + "Target calendar": "Agenda cible", "Target email": "Adresse de destination", "Template created!": "Modèle créé !", "Template deleted!": "Modèle supprimé !", @@ -752,37 +555,40 @@ "Temporary password": "Mot de passe temporaire", "Tentative": "Provisoire", "Thank you for your feedback!": "Merci pour votre message !", + "The PST archive is unreadable: the file is corrupt or its internal structure is incomplete. Retrying will not help — please try to re-generate the archive.": "L'archive PST est illisible : le fichier est corrompu ou sa structure interne est incomplète. Réessayer ne servira à rien — veuillez essayer de régénérer l'archive.", "The address has been updated!": "L'adresse a été mise à jour !", "The default signature will be automatically loaded when composing a new message.": "La signature par défaut sera automatiquement chargée lors de la composition d'un nouveau message.", "The domain {{domain}} has been created successfully.": "Le domaine {{domain}} a été créé avec succès.", - "The email {{email}} is invalid.": "Le courriel {{email}} est invalide.", "The email address is invalid.": "L'adresse email est invalide.", + "The email {{email}} is invalid.": "Le courriel {{email}} est invalide.", "The forced signature will be the only one usable for new messages.": "La signature forcée sera la seule utilisable pour les nouveaux messages.", "The mailbox \"{{mailbox}}\" currently has read-only access on this thread. To assign {{user}} to it, edit permissions must be granted to this mailbox.": "La boîte « {{mailbox}} » n'a actuellement que les droits en lecture sur cette conversation. Pour y assigner {{user}}, les droits en édition doivent être accordés à cette boîte.", "The message could not be sent.": "Le message n'a pas pu être envoyé.", "The message could not be sent. Please try again later.": "Le message n'a pas pu être envoyé. Veuillez réessayer plus tard.", + "The organizer marked this event as tentative.": "L'organisateur a marqué cet événement comme provisoire.", "The personal mailbox {{mailboxAddress}} has been created successfully.": "L'adresse personnelle {{mailboxAddress}} a été créée avec succès.", - "The PST archive is unreadable: the file is corrupt or its internal structure is incomplete. Retrying will not help — please try to re-generate the archive.": "L'archive PST est illisible : le fichier est corrompu ou sa structure interne est incomplète. Réessayer ne servira à rien — veuillez essayer de régénérer l'archive.", "The redirect mailbox {{mailboxAddress}} has been created successfully.": "L'adresse de redirection {{mailboxAddress}} a été créée avec succès.", "The shared mailbox {{mailboxAddress}} has been created successfully.": "L'adresse partagée {{mailboxAddress}} a été créée avec succès.", "The upload failed. Please try again.": "Le téléversement a échoué. Veuillez réessayer.", "These DNS records must be configured on the domain {{domain}} for the mail system to work properly. Changes may take up to 24 hours to propagate. If you don't know how to update them, please contact your technical service provider or system administrator.": "Ces enregistrements DNS doivent être configurés sur le domaine {{domain}} pour que le système de messagerie fonctionne correctement. Les changements peuvent prendre jusqu'à 24 heures pour être propagés. Si vous ne savez pas comment les mettre à jour, contactez votre prestataire technique ou votre administrateur système.", + "These DNS records must be configured on the domain {{domain}} for the mail system to work properly. If you don't know how to update them, please contact your technical service provider or system administrator.": "Ces enregistrements DNS doivent être configurés sur le domaine {{domain}} pour que le système de messagerie fonctionne correctement. Si vous ne savez pas comment les mettre à jour, contactez votre prestataire technique ou votre administrateur système.", "These tags will be automatically applied to every incoming message from the widget.": "Ces libellés seront automatiquement appliqués à chaque message entrant provenant du widget.", "This action cannot be undone and the user will need the new password to access its mailbox.": "Cette action est irréversible et l'utilisateur aura besoin du nouveau mot de passe pour accéder à sa boîte aux lettres.", "This contact's identity could not be verified. Proceed with caution.": "L'identité de ce contact n'a pas pu être vérifiée. Faites attention.", "This description will be used by the AI to automatically assign this label to your messages.": "Cette description sera utilisée par l'IA pour assigner automatiquement cette étiquette à vos messages.", "This email prefix is not allowed for personal mailboxes. Please choose a different prefix.": "Ce préfixe d'adresse n'est pas autorisé pour les boîtes aux lettres personnelles. Veuillez choisir un autre préfixe.", "This event has been cancelled": "Cet événement a été annulé", + "This event has been cancelled by the organizer.": "Cet événement a été annulé par l'organisateur.", "This is the only admin of this mailbox, you cannot therefore modify its access.": "C'est le seul administrateur de cette boîte aux lettres, vous ne pouvez donc pas modifier son accès.", "This message failed sender authentication and is likely a forgery. Do not trust it.": "L'authentification de l'expéditeur a échoué pour ce message, qui est probablement frauduleux. Ne lui faites pas confiance.", - "This message has {{count}} attachments_one": "Ce message a une pièce jointe", - "This message has {{count}} attachments_many": "Ce message a {{count}} pièces jointes", - "This message has {{count}} attachments_other": "Ce message a {{count}} pièces jointes", "This message has a draft": "Ce message a un brouillon", "This message has been deleted.": "Ce message a été supprimé.", "This message has not been delivered.": "Ce message n'a pas pu être délivré.", "This message has not been delivered. You cancelled the delivery.": "Ce message n'a pas pu être délivré. Vous avez annulé l'envoi.", "This message has not yet been delivered to all recipients.": "Ce message n'a pas encore été délivré à tous les destinataires.", + "This message has {{count}} attachments_many": "Ce message a {{count}} pièces jointes", + "This message has {{count}} attachments_one": "Ce message a une pièce jointe", + "This message has {{count}} attachments_other": "Ce message a {{count}} pièces jointes", "This message is being delivered.": "Ce message est en cours d'envoi.", "This name is for internal use only and will not be visible to users.": "Ce nom est réservé à un usage interne et ne sera pas visible par les utilisateurs.", "This signature is forced": "Cette signature est forcée", @@ -806,6 +612,7 @@ "Tuesday": "Mardi", "Tutorials and training": "Tutoriels et formations", "Type": "Type", + "Unable to check task status.": "Impossible de vérifier le statut de la tâche.", "Unable to copy credentials.": "Impossible de copier les identifiants.", "Unable to copy to clipboard.": "Impossible de copier dans le presse-papiers.", "Unarchive": "Désarchiver", @@ -821,7 +628,6 @@ "Unsaved changes": "Modifications non enregistrées", "Unstar": "Désactiver le suivi", "Unstar this thread": "Ne plus suivre cette conversation", - "until {{date}}": "jusqu'au {{date}}", "Up to date": "À jour", "Update": "Mettre à jour", "Update a Label": "Modifier un libellé", @@ -830,8 +636,8 @@ "Uploading your archive": "Téléversement de votre archive", "Uploading... {{progress}}%": "Téléversement en cours... {{progress}}%", "Use \"Send and archive\" by default": "Utiliser \"Envoyer et archiver\" par défaut", - "Use {referer_domain} to include the website domain in the subject.": "Utilisez {referer_domain} pour inclure le domaine du site web dans l'objet.", "Use SSL": "Utiliser SSL", + "Use {referer_domain} to include the website domain in the subject.": "Utilisez {referer_domain} pour inclure le domaine du site web dans l'objet.", "Value": "Valeur", "Variables": "Variables", "View full documentation": "Voir la documentation complète", @@ -845,39 +651,259 @@ "Yearly": "Annuel", "Yesterday": "Hier", "You": "Vous", - "You and {{assignees}} were unassigned_one": "Vous et {{assignees}} avez été désassigné·e·s", - "You and {{assignees}} were unassigned_many": "Vous et {{assignees}} avez été désassigné·e·s", - "You and {{assignees}} were unassigned_other": "Vous et {{assignees}} avez été désassigné·e·s", "You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.": "Vous et tous les utilisateurs avec un accès à la boîte « {{mailboxName}} » ne pourront plus voir cette conversation.", + "You and {{assignees}} were unassigned_many": "Vous et {{assignees}} avez été désassigné·e·s", + "You and {{assignees}} were unassigned_one": "Vous et {{assignees}} avez été désassigné·e·s", + "You and {{assignees}} were unassigned_other": "Vous et {{assignees}} avez été désassigné·e·s", "You are the last editor of this thread, you cannot therefore modify your access.": "Vous êtes le dernier éditeur de cette conversation, vous ne pouvez donc pas modifier votre accès.", - "You assigned {{assignees}}_one": "Vous avez assigné {{assignees}}", - "You assigned {{assignees}}_many": "Vous avez assigné {{assignees}}", - "You assigned {{assignees}}_other": "Vous avez assigné {{assignees}}", - "You assigned {{assignees}} and yourself_one": "Vous avez assigné {{assignees}} et vous-même", - "You assigned {{assignees}} and yourself_many": "Vous avez assigné {{assignees}} et vous-même", - "You assigned {{assignees}} and yourself_other": "Vous avez assigné {{assignees}} et vous-même", "You assigned yourself": "Vous vous êtes assigné·e", + "You assigned {{assignees}} and yourself_many": "Vous avez assigné {{assignees}} et vous-même", + "You assigned {{assignees}} and yourself_one": "Vous avez assigné {{assignees}} et vous-même", + "You assigned {{assignees}} and yourself_other": "Vous avez assigné {{assignees}} et vous-même", + "You assigned {{assignees}}_many": "Vous avez assigné {{assignees}}", + "You assigned {{assignees}}_one": "Vous avez assigné {{assignees}}", + "You assigned {{assignees}}_other": "Vous avez assigné {{assignees}}", "You can close this window and continue using the app.": "Vous pouvez fermer cette fenêtre et continuer à utiliser l'application.", "You can now inform the person that their mailbox is ready to be used and communicate the instructions for authentication.": "Vous pouvez désormais prévenir la personne que sa boîte aux lettres est prête à être utilisée et lui communiquer les instructions pour s'authentifier.", "You can safely retry the import — messages already imported will not be duplicated.": "Vous pouvez relancer l'import en toute sécurité — les messages déjà importés ne seront pas dupliqués.", "You cannot delete the last editor of this thread": "Vous ne pouvez pas supprimer le dernier éditeur de cette conversation", "You cannot modify it.": "Vous ne pouvez pas la modifier.", - "You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._one": "Vous avez {{count}} destinataire, ce qui dépasse le maximum de {{max}} destinataires autorisés par message. Le message ne peut pas être envoyé tant que vous n'avez pas réduit le nombre de destinataires.", - "You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._many": "Vous avez {{count}} destinataires, ce qui dépasse le maximum de {{max}} destinataires autorisés par message. Le message ne peut pas être envoyé tant que vous n'avez pas réduit le nombre de destinataires.", - "You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._other": "Vous avez {{count}} destinataires, ce qui dépasse le maximum de {{max}} destinataires autorisés par message. Le message ne peut pas être envoyé tant que vous n'avez pas réduit le nombre de destinataires.", + "You don't have a calendar yet.": "Vous n'avez pas encore d'agenda.", "You have aborted the upload.": "Vous avez annulé le téléversement.", "You have unsaved changes. Are you sure you want to close?": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir fermer ?", + "You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._many": "Vous avez {{count}} destinataires, ce qui dépasse le maximum de {{max}} destinataires autorisés par message. Le message ne peut pas être envoyé tant que vous n'avez pas réduit le nombre de destinataires.", + "You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._one": "Vous avez {{count}} destinataire, ce qui dépasse le maximum de {{max}} destinataires autorisés par message. Le message ne peut pas être envoyé tant que vous n'avez pas réduit le nombre de destinataires.", + "You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._other": "Vous avez {{count}} destinataires, ce qui dépasse le maximum de {{max}} destinataires autorisés par message. Le message ne peut pas être envoyé tant que vous n'avez pas réduit le nombre de destinataires.", "You left the thread": "Vous avez quitté la conversation", "You may not have sufficient permissions for all selected threads.": "Vous n'avez peut-être pas les droits suffisants sur toutes les conversations sélectionnées.", "You must confirm this statement.": "Vous devez confirmer cette déclaration.", - "You unassigned {{assignees}}_one": "Vous avez désassigné {{assignees}}", - "You unassigned {{assignees}}_many": "Vous avez désassigné {{assignees}}", - "You unassigned {{assignees}}_other": "Vous avez désassigné {{assignees}}", - "You unassigned {{assignees}} and yourself_one": "Vous avez désassigné {{assignees}} et vous-même", - "You unassigned {{assignees}} and yourself_many": "Vous avez désassigné {{assignees}} et vous-même", - "You unassigned {{assignees}} and yourself_other": "Vous avez désassigné {{assignees}} et vous-même", "You unassigned yourself": "Vous vous êtes désassigné·e", + "You unassigned {{assignees}} and yourself_many": "Vous avez désassigné {{assignees}} et vous-même", + "You unassigned {{assignees}} and yourself_one": "Vous avez désassigné {{assignees}} et vous-même", + "You unassigned {{assignees}} and yourself_other": "Vous avez désassigné {{assignees}} et vous-même", + "You unassigned {{assignees}}_many": "Vous avez désassigné {{assignees}}", + "You unassigned {{assignees}}_one": "Vous avez désassigné {{assignees}}", + "You unassigned {{assignees}}_other": "Vous avez désassigné {{assignees}}", "You were unassigned": "Vous avez été désassigné·e", "Your email...": "Renseigner votre email...", - "Your session has expired. Please log in again.": "Votre session a expiré. Veuillez vous reconnecter." + "Your messages have been imported successfully!": "Vos messages ont été importés avec succès !", + "Your session has expired. Please log in again.": "Votre session a expiré. Veuillez vous reconnecter.", + "and {{count}} other users_many": "et {{count}} autres utilisateurs", + "and {{count}} other users_one": "et 1 autre utilisateur", + "and {{count}} other users_other": "et {{count}} autres utilisateurs", + "create_mailbox_modal.success.credential_text": "Vos identifiants Messages sont : \n- Courriel : {{id}}\n- Mot de passe : {{password}}\n\nIl vous sera demandé de changer votre mot de passe lors de votre première connexion.", + "edited": "modifié", + "folder.search": "Recherche", + "just now": "à l'instant", + "less than a minute ago": "il y a moins d'une minute", + "or drag and drop some files": "ou glissez-déposez des fichiers", + "until {{date}}": "jusqu'au {{date}}", + "{{assignees}} was unassigned_many": "{{assignees}} ont été désassignés", + "{{assignees}} was unassigned_one": "{{assignees}} a été désassigné", + "{{assignees}} was unassigned_other": "{{assignees}} ont été désassignés", + "{{author}} assigned themself": "{{author}} s'est assigné·e", + "{{author}} assigned themself and {{assignees}}_many": "{{author}} s'est assigné·e ainsi que {{assignees}}", + "{{author}} assigned themself and {{assignees}}_one": "{{author}} s'est assigné·e ainsi que {{assignees}}", + "{{author}} assigned themself and {{assignees}}_other": "{{author}} s'est assigné·e ainsi que {{assignees}}", + "{{author}} assigned you": "{{author}} vous a assigné·e", + "{{author}} assigned you and themself": "{{author}} vous a assigné·e ainsi que lui-même", + "{{author}} assigned you and {{assignees}}_many": "{{author}} vous a assigné·e ainsi que {{assignees}}", + "{{author}} assigned you and {{assignees}}_one": "{{author}} vous a assigné·e ainsi que {{assignees}}", + "{{author}} assigned you and {{assignees}}_other": "{{author}} vous a assigné·e ainsi que {{assignees}}", + "{{author}} assigned you, themself and {{assignees}}_many": "{{author}} vous a assigné·e, lui-même ainsi que {{assignees}}", + "{{author}} assigned you, themself and {{assignees}}_one": "{{author}} vous a assigné·e, lui-même ainsi que {{assignees}}", + "{{author}} assigned you, themself and {{assignees}}_other": "{{author}} vous a assigné·e, lui-même ainsi que {{assignees}}", + "{{author}} assigned {{assignees}}_many": "{{author}} a assigné {{assignees}}", + "{{author}} assigned {{assignees}}_one": "{{author}} a assigné {{assignees}}", + "{{author}} assigned {{assignees}}_other": "{{author}} a assigné {{assignees}}", + "{{author}} unassigned themself": "{{author}} s'est désassigné·e", + "{{author}} unassigned themself and {{assignees}}_many": "{{author}} s'est désassigné·e ainsi que {{assignees}}", + "{{author}} unassigned themself and {{assignees}}_one": "{{author}} s'est désassigné·e ainsi que {{assignees}}", + "{{author}} unassigned themself and {{assignees}}_other": "{{author}} s'est désassigné·e ainsi que {{assignees}}", + "{{author}} unassigned you": "{{author}} vous a désassigné·e", + "{{author}} unassigned you and themself": "{{author}} vous a désassigné·e ainsi que lui-même", + "{{author}} unassigned you and {{assignees}}_many": "{{author}} vous a désassigné·e ainsi que {{assignees}}", + "{{author}} unassigned you and {{assignees}}_one": "{{author}} vous a désassigné·e ainsi que {{assignees}}", + "{{author}} unassigned you and {{assignees}}_other": "{{author}} vous a désassigné·e ainsi que {{assignees}}", + "{{author}} unassigned you, themself and {{assignees}}_many": "{{author}} vous a désassigné·e, lui-même ainsi que {{assignees}}", + "{{author}} unassigned you, themself and {{assignees}}_one": "{{author}} vous a désassigné·e, lui-même ainsi que {{assignees}}", + "{{author}} unassigned you, themself and {{assignees}}_other": "{{author}} vous a désassigné·e, lui-même ainsi que {{assignees}}", + "{{author}} unassigned {{assignees}}_many": "{{author}} a désassigné {{assignees}}", + "{{author}} unassigned {{assignees}}_one": "{{author}} a désassigné {{assignees}}", + "{{author}} unassigned {{assignees}}_other": "{{author}} a désassigné {{assignees}}", + "{{count}} assignment changes_many": "{{count}} changements d'assignation", + "{{count}} assignment changes_one": "{{count}} changement d'assignation", + "{{count}} assignment changes_other": "{{count}} changements d'assignation", + "{{count}} attachments_many": "{{count}} pièces jointes", + "{{count}} attachments_one": "{{count}} pièce jointe", + "{{count}} attachments_other": "{{count}} pièces jointes", + "{{count}} attendees_many": "{{count}} participants", + "{{count}} attendees_one": "{{count}} participant", + "{{count}} attendees_other": "{{count}} participants", + "{{count}} conflicting events_many": "{{count}} événements en conflit", + "{{count}} conflicting events_one": "{{count}} événement en conflit", + "{{count}} conflicting events_other": "{{count}} événements en conflit", + "{{count}} days ago_many": "il y a {{count}} jours", + "{{count}} days ago_one": "il y a {{count}} jour", + "{{count}} days ago_other": "il y a {{count}} jours", + "{{count}} hours ago_many": "il y a {{count}} heures", + "{{count}} hours ago_one": "il y a {{count}} heure", + "{{count}} hours ago_other": "il y a {{count}} heures", + "{{count}} mailboxes_many": "{{count}} boîtes aux lettres", + "{{count}} mailboxes_one": "{{count}} boîte aux lettres", + "{{count}} mailboxes_other": "{{count}} boîtes aux lettres", + "{{count}} messages are now starred._many": "{{count}} messages sont maintenant marqués pour suivi.", + "{{count}} messages are now starred._one": "{{count}} message est maintenant marqué pour suivi.", + "{{count}} messages are now starred._other": "{{count}} messages sont maintenant marqués pour suivi.", + "{{count}} messages assigned to you_many": "{{count}} messages qui vous sont assignés", + "{{count}} messages assigned to you_one": "{{count}} message qui vous est assigné", + "{{count}} messages assigned to you_other": "{{count}} messages qui vous sont assignés", + "{{count}} messages have been archived._many": "{{count}} messages ont été archivés.", + "{{count}} messages have been archived._one": "Le message a été archivé.", + "{{count}} messages have been archived._other": "{{count}} messages ont été archivés.", + "{{count}} messages have been deleted._many": "{{count}} messages ont été supprimés.", + "{{count}} messages have been deleted._one": "Le message a été supprimé.", + "{{count}} messages have been deleted._other": "{{count}} messages ont été supprimés.", + "{{count}} messages have been reported as spam._many": "{{count}} messages ont été signalés comme spam.", + "{{count}} messages have been reported as spam._one": "Le message a été signalé comme spam.", + "{{count}} messages have been reported as spam._other": "{{count}} messages ont été signalés comme spam.", + "{{count}} messages have been updated._many": "{{count}} messages ont été mis à jour.", + "{{count}} messages have been updated._one": "Le message a été mis à jour.", + "{{count}} messages have been updated._other": "{{count}} messages ont été mis à jour.", + "{{count}} messages imported_many": "{{count}} messages importés", + "{{count}} messages imported_one": "{{count}} message importé", + "{{count}} messages imported_other": "{{count}} messages importés", + "{{count}} messages mentioning you_many": "{{count}} messages vous mentionnant", + "{{count}} messages mentioning you_one": "{{count}} message vous mentionnant", + "{{count}} messages mentioning you_other": "{{count}} messages vous mentionnant", + "{{count}} messages of this thread have been deleted._many": "{{count}} messages de cette conversation ont été supprimés.", + "{{count}} messages of this thread have been deleted._one": "{{count}} message de cette conversation a été supprimé.", + "{{count}} messages of this thread have been deleted._other": "{{count}} messages de cette conversation ont été supprimés.", + "{{count}} messages were imported before the error._many": "{{count}} messages ont été importés avant l'erreur.", + "{{count}} messages were imported before the error._one": "{{count}} message a été importé avant l'erreur.", + "{{count}} messages were imported before the error._other": "{{count}} messages ont été importés avant l'erreur.", + "{{count}} messages_many": "{{count}} messages", + "{{count}} messages_one": "{{count}} message", + "{{count}} messages_other": "{{count}} messages", + "{{count}} minutes ago_many": "il y a {{count}} minutes", + "{{count}} minutes ago_one": "il y a {{count}} minute", + "{{count}} minutes ago_other": "il y a {{count}} minutes", + "{{count}} months ago_many": "il y a {{count}} mois", + "{{count}} months ago_one": "il y a {{count}} mois", + "{{count}} months ago_other": "il y a {{count}} mois", + "{{count}} new message_many": "{{count}} nouveaux messages", + "{{count}} new message_one": "{{count}} nouveau message", + "{{count}} new message_other": "{{count}} nouveaux messages", + "{{count}} occurrences_many": "{{count}} événements", + "{{count}} occurrences_one": "{{count}} événement", + "{{count}} occurrences_other": "{{count}} événements", + "{{count}} of which are shared_many": " dont {{count}} partagées", + "{{count}} of which are shared_one": " dont {{count}} partagée", + "{{count}} of which are shared_other": " dont {{count}} partagées", + "{{count}} out of {{total}} messages are now starred._many": "{{count}} messages sur {{total}} sont maintenant suivis.", + "{{count}} out of {{total}} messages are now starred._one": "{{count}} message sur {{total}} est maintenant suivi.", + "{{count}} out of {{total}} messages are now starred._other": "{{count}} messages sur {{total}} sont maintenant suivis.", + "{{count}} out of {{total}} messages have been archived._many": "{{count}} messages sur {{total}} ont été archivés.", + "{{count}} out of {{total}} messages have been archived._one": "{{count}} message sur {{total}} a été archivé.", + "{{count}} out of {{total}} messages have been archived._other": "{{count}} messages sur {{total}} ont été archivés.", + "{{count}} out of {{total}} messages have been deleted._many": "{{count}} messages sur {{total}} ont été supprimés.", + "{{count}} out of {{total}} messages have been deleted._one": "{{count}} message sur {{total}} a été supprimé.", + "{{count}} out of {{total}} messages have been deleted._other": "{{count}} messages sur {{total}} ont été supprimés.", + "{{count}} out of {{total}} messages have been reported as spam._many": "{{count}} messages sur {{total}} ont été signalés comme spam.", + "{{count}} out of {{total}} messages have been reported as spam._one": "{{count}} message sur {{total}} a été signalé comme spam.", + "{{count}} out of {{total}} messages have been reported as spam._other": "{{count}} messages sur {{total}} ont été signalés comme spam.", + "{{count}} out of {{total}} threads are now starred._many": "{{count}} conversations sur {{total}} sont maintenant suivies.", + "{{count}} out of {{total}} threads are now starred._one": "{{count}} conversation sur {{total}} est maintenant suivie.", + "{{count}} out of {{total}} threads are now starred._other": "{{count}} conversations sur {{total}} sont maintenant suivies.", + "{{count}} out of {{total}} threads have been archived._many": "{{count}} conversations sur {{total}} ont été archivées.", + "{{count}} out of {{total}} threads have been archived._one": "{{count}} conversation sur {{total}} a été archivée.", + "{{count}} out of {{total}} threads have been archived._other": "{{count}} conversations sur {{total}} ont été archivées.", + "{{count}} out of {{total}} threads have been deleted._many": "{{count}} conversations sur {{total}} ont été supprimées.", + "{{count}} out of {{total}} threads have been deleted._one": "{{count}} conversation sur {{total}} a été supprimée.", + "{{count}} out of {{total}} threads have been deleted._other": "{{count}} conversations sur {{total}} ont été supprimées.", + "{{count}} out of {{total}} threads have been reported as spam._many": "{{count}} conversations sur {{total}} ont été signalées comme spam.", + "{{count}} out of {{total}} threads have been reported as spam._one": "{{count}} conversation sur {{total}} a été signalée comme spam.", + "{{count}} out of {{total}} threads have been reported as spam._other": "{{count}} conversations sur {{total}} ont été signalées comme spam.", + "{{count}} results assigned to you_many": "{{count}} résultats qui vous sont assignés", + "{{count}} results assigned to you_one": "{{count}} résultat qui vous est assigné", + "{{count}} results assigned to you_other": "{{count}} résultats qui vous sont assignés", + "{{count}} results mentioning you_many": "{{count}} résultats vous mentionnant", + "{{count}} results mentioning you_one": "{{count}} résultat vous mentionnant", + "{{count}} results mentioning you_other": "{{count}} résultats vous mentionnant", + "{{count}} results_many": "{{count}} résultats", + "{{count}} results_one": "{{count}} résultat", + "{{count}} results_other": "{{count}} résultats", + "{{count}} selected threads_many": "{{count}} conversations sélectionnées", + "{{count}} selected threads_one": "{{count}} conversation sélectionnée", + "{{count}} selected threads_other": "{{count}} conversations sélectionnées", + "{{count}} starred messages mentioning you_many": "{{count}} messages suivis vous mentionnant", + "{{count}} starred messages mentioning you_one": "{{count}} message suivi vous mentionnant", + "{{count}} starred messages mentioning you_other": "{{count}} messages suivis vous mentionnant", + "{{count}} starred messages_many": "{{count}} messages suivis", + "{{count}} starred messages_one": "{{count}} message suivi", + "{{count}} starred messages_other": "{{count}} messages suivis", + "{{count}} starred results mentioning you_many": "{{count}} résultats suivis vous mentionnant", + "{{count}} starred results mentioning you_one": "{{count}} résultat suivi vous mentionnant", + "{{count}} starred results mentioning you_other": "{{count}} résultats suivis vous mentionnant", + "{{count}} starred results_many": "{{count}} résultats suivis", + "{{count}} starred results_one": "{{count}} résultat suivi", + "{{count}} starred results_other": "{{count}} résultats suivis", + "{{count}} threads are now starred._many": "{{count}} conversations sont maintenant marquées pour suivi.", + "{{count}} threads are now starred._one": "{{count}} conversation est maintenant marquée pour suivi.", + "{{count}} threads are now starred._other": "{{count}} conversations sont maintenant marquées pour suivi.", + "{{count}} threads have been archived._many": "{{count}} conversations ont été archivées.", + "{{count}} threads have been archived._one": "La conversation a été archivée.", + "{{count}} threads have been archived._other": "{{count}} conversations ont été archivées.", + "{{count}} threads have been deleted._many": "{{count}} conversations ont été supprimées.", + "{{count}} threads have been deleted._one": "La conversation a été supprimée.", + "{{count}} threads have been deleted._other": "{{count}} conversations ont été supprimées.", + "{{count}} threads have been reported as spam._many": "{{count}} conversations ont été signalées comme spam.", + "{{count}} threads have been reported as spam._one": "La conversation a été signalée comme spam.", + "{{count}} threads have been reported as spam._other": "{{count}} conversations ont été signalées comme spam.", + "{{count}} threads have been restored._many": "{{count}} conversations ont été restaurées.", + "{{count}} threads have been restored._one": "La conversation a été restaurée.", + "{{count}} threads have been restored._other": "{{count}} conversations ont été restaurées.", + "{{count}} threads have been unarchived._many": "{{count}} conversations ont été désarchivées.", + "{{count}} threads have been unarchived._one": "La conversation a été désarchivée.", + "{{count}} threads have been unarchived._other": "{{count}} conversations ont été désarchivées.", + "{{count}} threads have been updated._many": "{{count}} conversations ont été mises à jour.", + "{{count}} threads have been updated._one": "La conversation a été mise à jour.", + "{{count}} threads have been updated._other": "{{count}} conversations ont été mises à jour.", + "{{count}} unread messages mentioning you_many": "{{count}} messages non lus vous mentionnant", + "{{count}} unread messages mentioning you_one": "{{count}} message non lu vous mentionnant", + "{{count}} unread messages mentioning you_other": "{{count}} messages non lus vous mentionnant", + "{{count}} unread messages_many": "{{count}} messages non lus", + "{{count}} unread messages_one": "{{count}} message non lu", + "{{count}} unread messages_other": "{{count}} messages non lus", + "{{count}} unread results mentioning you_many": "{{count}} résultats non lus vous mentionnant", + "{{count}} unread results mentioning you_one": "{{count}} résultat non lu vous mentionnant", + "{{count}} unread results mentioning you_other": "{{count}} résultats non lus vous mentionnant", + "{{count}} unread results_many": "{{count}} résultats non lus", + "{{count}} unread results_one": "{{count}} résultat non lu", + "{{count}} unread results_other": "{{count}} résultats non lus", + "{{count}} unread starred messages mentioning you_many": "{{count}} messages suivis non lus vous mentionnant", + "{{count}} unread starred messages mentioning you_one": "{{count}} message suivi non lu vous mentionnant", + "{{count}} unread starred messages mentioning you_other": "{{count}} messages suivis non lus vous mentionnant", + "{{count}} unread starred messages_many": "{{count}} messages suivis non lus", + "{{count}} unread starred messages_one": "{{count}} message suivi non lu", + "{{count}} unread starred messages_other": "{{count}} messages suivis non lus", + "{{count}} unread starred results mentioning you_many": "{{count}} résultats suivis non lus vous mentionnant", + "{{count}} unread starred results mentioning you_one": "{{count}} résultat suivi non lu vous mentionnant", + "{{count}} unread starred results mentioning you_other": "{{count}} résultats suivis non lus vous mentionnant", + "{{count}} unread starred results_many": "{{count}} résultats suivis non lus", + "{{count}} unread starred results_one": "{{count}} résultat suivi non lu", + "{{count}} unread starred results_other": "{{count}} résultats suivis non lus", + "{{count}} weeks ago_many": "il y a {{count}} semaines", + "{{count}} weeks ago_one": "il y a {{count}} semaine", + "{{count}} weeks ago_other": "il y a {{count}} semaines", + "{{count}} years ago_many": "il y a {{count}} ans", + "{{count}} years ago_one": "il y a {{count}} an", + "{{count}} years ago_other": "il y a {{count}} ans", + "{{count}}/{{total}} mailboxes_many": "{{count}}/{{total}} boîtes aux lettres", + "{{count}}/{{total}} mailboxes_one": "{{count}}/{{total}} boîte aux lettres", + "{{count}}/{{total}} mailboxes_other": "{{count}}/{{total}} boîtes aux lettres", + "{{date}} at {{time}}": "{{date}} à {{time}}", + "{{name}} assigned to this thread": "{{name}} assigné à cette conversation", + "{{name}} unassigned from this thread": "{{name}} désassigné de cette conversation", + "{{progress}}% imported": "{{progress}}% importés" } diff --git a/src/frontend/src/features/api/gen/calendar/calendar.ts b/src/frontend/src/features/api/gen/calendar/calendar.ts new file mode 100644 index 00000000..2fe11a35 --- /dev/null +++ b/src/frontend/src/features/api/gen/calendar/calendar.ts @@ -0,0 +1,636 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import type { + CalendarAddEventRequestRequest, + CalendarAddEventResponse, + CalendarConflictsRequestRequest, + CalendarConflictsResponse, + CalendarListResponse, + CalendarRsvpRequestRequest, + CalendarRsvpResponse, + MailboxesCalendarAddCreate400, + MailboxesCalendarAddCreate503, + MailboxesCalendarCalendarsRetrieve502, + MailboxesCalendarConflictsCreate400, + MailboxesCalendarConflictsCreate502, + MailboxesCalendarRsvpCreate400, + MailboxesCalendarRsvpCreate503, +} from ".././models"; + +import { fetchAPI } from "../../fetch-api"; +import type { ErrorType } from "../../fetch-api"; + +type SecondParameter unknown> = Parameters[1]; + +/** + * Add an event to the mailbox's CalDAV calendar via a background task. + */ +export type mailboxesCalendarAddCreateResponse200 = { + data: CalendarAddEventResponse; + status: 200; +}; + +export type mailboxesCalendarAddCreateResponse400 = { + data: MailboxesCalendarAddCreate400; + status: 400; +}; + +export type mailboxesCalendarAddCreateResponse503 = { + data: MailboxesCalendarAddCreate503; + status: 503; +}; + +export type mailboxesCalendarAddCreateResponseSuccess = + mailboxesCalendarAddCreateResponse200 & { + headers: Headers; + }; +export type mailboxesCalendarAddCreateResponseError = ( + | mailboxesCalendarAddCreateResponse400 + | mailboxesCalendarAddCreateResponse503 +) & { + headers: Headers; +}; + +export type mailboxesCalendarAddCreateResponse = + | mailboxesCalendarAddCreateResponseSuccess + | mailboxesCalendarAddCreateResponseError; + +export const getMailboxesCalendarAddCreateUrl = (mailboxId: string) => { + return `/api/v1.0/mailboxes/${mailboxId}/calendar/add/`; +}; + +export const mailboxesCalendarAddCreate = async ( + mailboxId: string, + calendarAddEventRequestRequest: CalendarAddEventRequestRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMailboxesCalendarAddCreateUrl(mailboxId), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(calendarAddEventRequestRequest), + }, + ); +}; + +export const getMailboxesCalendarAddCreateMutationOptions = < + TError = ErrorType< + MailboxesCalendarAddCreate400 | MailboxesCalendarAddCreate503 + >, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; data: CalendarAddEventRequestRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; data: CalendarAddEventRequestRequest }, + TContext +> => { + const mutationKey = ["mailboxesCalendarAddCreate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { mailboxId: string; data: CalendarAddEventRequestRequest } + > = (props) => { + const { mailboxId, data } = props ?? {}; + + return mailboxesCalendarAddCreate(mailboxId, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type MailboxesCalendarAddCreateMutationResult = NonNullable< + Awaited> +>; +export type MailboxesCalendarAddCreateMutationBody = + CalendarAddEventRequestRequest; +export type MailboxesCalendarAddCreateMutationError = ErrorType< + MailboxesCalendarAddCreate400 | MailboxesCalendarAddCreate503 +>; + +export const useMailboxesCalendarAddCreate = < + TError = ErrorType< + MailboxesCalendarAddCreate400 | MailboxesCalendarAddCreate503 + >, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; data: CalendarAddEventRequestRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { mailboxId: string; data: CalendarAddEventRequestRequest }, + TContext +> => { + const mutationOptions = getMailboxesCalendarAddCreateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * Return the list of calendars available for the mailbox. + */ +export type mailboxesCalendarCalendarsRetrieveResponse200 = { + data: CalendarListResponse; + status: 200; +}; + +export type mailboxesCalendarCalendarsRetrieveResponse502 = { + data: MailboxesCalendarCalendarsRetrieve502; + status: 502; +}; + +export type mailboxesCalendarCalendarsRetrieveResponseSuccess = + mailboxesCalendarCalendarsRetrieveResponse200 & { + headers: Headers; + }; +export type mailboxesCalendarCalendarsRetrieveResponseError = + mailboxesCalendarCalendarsRetrieveResponse502 & { + headers: Headers; + }; + +export type mailboxesCalendarCalendarsRetrieveResponse = + | mailboxesCalendarCalendarsRetrieveResponseSuccess + | mailboxesCalendarCalendarsRetrieveResponseError; + +export const getMailboxesCalendarCalendarsRetrieveUrl = (mailboxId: string) => { + return `/api/v1.0/mailboxes/${mailboxId}/calendar/calendars/`; +}; + +export const mailboxesCalendarCalendarsRetrieve = async ( + mailboxId: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMailboxesCalendarCalendarsRetrieveUrl(mailboxId), + { + ...options, + method: "GET", + }, + ); +}; + +export const getMailboxesCalendarCalendarsRetrieveQueryKey = ( + mailboxId?: string, +) => { + return [`/api/v1.0/mailboxes/${mailboxId}/calendar/calendars/`] as const; +}; + +export const getMailboxesCalendarCalendarsRetrieveQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + mailboxId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getMailboxesCalendarCalendarsRetrieveQueryKey(mailboxId); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + mailboxesCalendarCalendarsRetrieve(mailboxId, { + signal, + ...requestOptions, + }); + + return { + queryKey, + queryFn, + enabled: !!mailboxId, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type MailboxesCalendarCalendarsRetrieveQueryResult = NonNullable< + Awaited> +>; +export type MailboxesCalendarCalendarsRetrieveQueryError = + ErrorType; + +export function useMailboxesCalendarCalendarsRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + mailboxId: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useMailboxesCalendarCalendarsRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + mailboxId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useMailboxesCalendarCalendarsRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + mailboxId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useMailboxesCalendarCalendarsRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + mailboxId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getMailboxesCalendarCalendarsRetrieveQueryOptions( + mailboxId, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * Return a list of events overlapping the requested time range. + */ +export type mailboxesCalendarConflictsCreateResponse200 = { + data: CalendarConflictsResponse; + status: 200; +}; + +export type mailboxesCalendarConflictsCreateResponse400 = { + data: MailboxesCalendarConflictsCreate400; + status: 400; +}; + +export type mailboxesCalendarConflictsCreateResponse502 = { + data: MailboxesCalendarConflictsCreate502; + status: 502; +}; + +export type mailboxesCalendarConflictsCreateResponseSuccess = + mailboxesCalendarConflictsCreateResponse200 & { + headers: Headers; + }; +export type mailboxesCalendarConflictsCreateResponseError = ( + | mailboxesCalendarConflictsCreateResponse400 + | mailboxesCalendarConflictsCreateResponse502 +) & { + headers: Headers; +}; + +export type mailboxesCalendarConflictsCreateResponse = + | mailboxesCalendarConflictsCreateResponseSuccess + | mailboxesCalendarConflictsCreateResponseError; + +export const getMailboxesCalendarConflictsCreateUrl = (mailboxId: string) => { + return `/api/v1.0/mailboxes/${mailboxId}/calendar/conflicts/`; +}; + +export const mailboxesCalendarConflictsCreate = async ( + mailboxId: string, + calendarConflictsRequestRequest: CalendarConflictsRequestRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMailboxesCalendarConflictsCreateUrl(mailboxId), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(calendarConflictsRequestRequest), + }, + ); +}; + +export const getMailboxesCalendarConflictsCreateMutationOptions = < + TError = ErrorType< + MailboxesCalendarConflictsCreate400 | MailboxesCalendarConflictsCreate502 + >, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; data: CalendarConflictsRequestRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; data: CalendarConflictsRequestRequest }, + TContext +> => { + const mutationKey = ["mailboxesCalendarConflictsCreate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { mailboxId: string; data: CalendarConflictsRequestRequest } + > = (props) => { + const { mailboxId, data } = props ?? {}; + + return mailboxesCalendarConflictsCreate(mailboxId, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type MailboxesCalendarConflictsCreateMutationResult = NonNullable< + Awaited> +>; +export type MailboxesCalendarConflictsCreateMutationBody = + CalendarConflictsRequestRequest; +export type MailboxesCalendarConflictsCreateMutationError = ErrorType< + MailboxesCalendarConflictsCreate400 | MailboxesCalendarConflictsCreate502 +>; + +export const useMailboxesCalendarConflictsCreate = < + TError = ErrorType< + MailboxesCalendarConflictsCreate400 | MailboxesCalendarConflictsCreate502 + >, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; data: CalendarConflictsRequestRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { mailboxId: string; data: CalendarConflictsRequestRequest }, + TContext +> => { + const mutationOptions = + getMailboxesCalendarConflictsCreateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * Submit an RSVP response via a background CalDAV task. + */ +export type mailboxesCalendarRsvpCreateResponse200 = { + data: CalendarRsvpResponse; + status: 200; +}; + +export type mailboxesCalendarRsvpCreateResponse400 = { + data: MailboxesCalendarRsvpCreate400; + status: 400; +}; + +export type mailboxesCalendarRsvpCreateResponse503 = { + data: MailboxesCalendarRsvpCreate503; + status: 503; +}; + +export type mailboxesCalendarRsvpCreateResponseSuccess = + mailboxesCalendarRsvpCreateResponse200 & { + headers: Headers; + }; +export type mailboxesCalendarRsvpCreateResponseError = ( + | mailboxesCalendarRsvpCreateResponse400 + | mailboxesCalendarRsvpCreateResponse503 +) & { + headers: Headers; +}; + +export type mailboxesCalendarRsvpCreateResponse = + | mailboxesCalendarRsvpCreateResponseSuccess + | mailboxesCalendarRsvpCreateResponseError; + +export const getMailboxesCalendarRsvpCreateUrl = (mailboxId: string) => { + return `/api/v1.0/mailboxes/${mailboxId}/calendar/rsvp/`; +}; + +export const mailboxesCalendarRsvpCreate = async ( + mailboxId: string, + calendarRsvpRequestRequest: CalendarRsvpRequestRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMailboxesCalendarRsvpCreateUrl(mailboxId), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(calendarRsvpRequestRequest), + }, + ); +}; + +export const getMailboxesCalendarRsvpCreateMutationOptions = < + TError = ErrorType< + MailboxesCalendarRsvpCreate400 | MailboxesCalendarRsvpCreate503 + >, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; data: CalendarRsvpRequestRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; data: CalendarRsvpRequestRequest }, + TContext +> => { + const mutationKey = ["mailboxesCalendarRsvpCreate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { mailboxId: string; data: CalendarRsvpRequestRequest } + > = (props) => { + const { mailboxId, data } = props ?? {}; + + return mailboxesCalendarRsvpCreate(mailboxId, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type MailboxesCalendarRsvpCreateMutationResult = NonNullable< + Awaited> +>; +export type MailboxesCalendarRsvpCreateMutationBody = + CalendarRsvpRequestRequest; +export type MailboxesCalendarRsvpCreateMutationError = ErrorType< + MailboxesCalendarRsvpCreate400 | MailboxesCalendarRsvpCreate503 +>; + +export const useMailboxesCalendarRsvpCreate = < + TError = ErrorType< + MailboxesCalendarRsvpCreate400 | MailboxesCalendarRsvpCreate503 + >, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; data: CalendarRsvpRequestRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { mailboxId: string; data: CalendarRsvpRequestRequest }, + TContext +> => { + const mutationOptions = + getMailboxesCalendarRsvpCreateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; diff --git a/src/frontend/src/features/api/gen/index.ts b/src/frontend/src/features/api/gen/index.ts index 68388bf2..65ae9bf0 100644 --- a/src/frontend/src/features/api/gen/index.ts +++ b/src/frontend/src/features/api/gen/index.ts @@ -7,6 +7,7 @@ export * from "./import/import"; export * from "./labels/labels"; export * from "./mailboxes/mailboxes"; export * from "./mailbox-accesses/mailbox-accesses"; +export * from "./calendar/calendar"; export * from "./channels/channels"; export * from "./maildomains/maildomains"; export * from "./maildomain-accesses/maildomain-accesses"; diff --git a/src/frontend/src/features/api/gen/models/calendar_add_event_request_request.ts b/src/frontend/src/features/api/gen/models/calendar_add_event_request_request.ts new file mode 100644 index 00000000..d463d922 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/calendar_add_event_request_request.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export interface CalendarAddEventRequestRequest { + /** + * Raw ICS content of the event + * @minLength 1 + */ + ics_data: string; + /** + * Optional specific calendar URL + * @minLength 1 + * @nullable + */ + calendar_id?: string | null; +} diff --git a/src/frontend/src/features/api/gen/models/calendar_add_event_response.ts b/src/frontend/src/features/api/gen/models/calendar_add_event_response.ts new file mode 100644 index 00000000..97510a77 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/calendar_add_event_response.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export interface CalendarAddEventResponse { + task_id: string; +} diff --git a/src/frontend/src/features/api/gen/models/calendar_conflicts_request_request.ts b/src/frontend/src/features/api/gen/models/calendar_conflicts_request_request.ts new file mode 100644 index 00000000..6f956ff9 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/calendar_conflicts_request_request.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export interface CalendarConflictsRequestRequest { + /** Start of the time range (ISO 8601) */ + start: string; + /** End of the time range (ISO 8601) */ + end: string; + /** + * Optional UID of an event to exclude from conflicts (avoids flagging prior imports of the same invite). + * @nullable + */ + exclude_uid?: string | null; +} diff --git a/src/frontend/src/features/api/gen/models/calendar_conflicts_response.ts b/src/frontend/src/features/api/gen/models/calendar_conflicts_response.ts new file mode 100644 index 00000000..ad97a276 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/calendar_conflicts_response.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import type { CalendarConflictsResponseConflictsItem } from "./calendar_conflicts_response_conflicts_item"; + +export interface CalendarConflictsResponse { + conflicts: CalendarConflictsResponseConflictsItem[]; + /** + * PARTSTAT of the requesting mailbox on the prior copy of ``exclude_uid``, if such a copy exists. Lets the UI pre-select the user's prior RSVP. + * @nullable + */ + existing_partstat: string | null; +} diff --git a/src/frontend/src/features/api/gen/models/calendar_conflicts_response_conflicts_item.ts b/src/frontend/src/features/api/gen/models/calendar_conflicts_response_conflicts_item.ts new file mode 100644 index 00000000..84c71a0f --- /dev/null +++ b/src/frontend/src/features/api/gen/models/calendar_conflicts_response_conflicts_item.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export type CalendarConflictsResponseConflictsItem = { [key: string]: unknown }; diff --git a/src/frontend/src/features/api/gen/models/calendar_list_response.ts b/src/frontend/src/features/api/gen/models/calendar_list_response.ts new file mode 100644 index 00000000..508c90a8 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/calendar_list_response.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import type { CalendarListResponseCalendarsItem } from "./calendar_list_response_calendars_item"; + +export interface CalendarListResponse { + calendars: CalendarListResponseCalendarsItem[]; + /** + * Public URL of the calendar web UI, if configured. + * @nullable + */ + web_url: string | null; + /** True when a CalDAV service is configured for this mailbox (per-mailbox channel or deployment default). False means the integration is disabled. */ + configured: boolean; +} diff --git a/src/frontend/src/features/api/gen/models/calendar_list_response_calendars_item.ts b/src/frontend/src/features/api/gen/models/calendar_list_response_calendars_item.ts new file mode 100644 index 00000000..ea89934d --- /dev/null +++ b/src/frontend/src/features/api/gen/models/calendar_list_response_calendars_item.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export type CalendarListResponseCalendarsItem = { [key: string]: unknown }; diff --git a/src/frontend/src/features/api/gen/models/calendar_rsvp_request_request.ts b/src/frontend/src/features/api/gen/models/calendar_rsvp_request_request.ts new file mode 100644 index 00000000..fa449c38 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/calendar_rsvp_request_request.ts @@ -0,0 +1,28 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import type { ResponseEnum } from "./response_enum"; + +export interface CalendarRsvpRequestRequest { + /** + * Raw ICS content of the event + * @minLength 1 + */ + ics_data: string; + /** RSVP response + +* `ACCEPTED` - ACCEPTED +* `DECLINED` - DECLINED +* `TENTATIVE` - TENTATIVE */ + response: ResponseEnum; + /** + * Optional specific calendar URL + * @minLength 1 + * @nullable + */ + calendar_id?: string | null; +} diff --git a/src/frontend/src/features/api/gen/models/calendar_rsvp_response.ts b/src/frontend/src/features/api/gen/models/calendar_rsvp_response.ts new file mode 100644 index 00000000..c0ba6f00 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/calendar_rsvp_response.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export interface CalendarRsvpResponse { + task_id: string; +} diff --git a/src/frontend/src/features/api/gen/models/index.ts b/src/frontend/src/features/api/gen/models/index.ts index afd0615d..a8a7b4e0 100644 --- a/src/frontend/src/features/api/gen/models/index.ts +++ b/src/frontend/src/features/api/gen/models/index.ts @@ -9,6 +9,15 @@ export * from "./attachment"; export * from "./blob_upload_create201"; export * from "./blob_upload_create_body"; +export * from "./calendar_add_event_request_request"; +export * from "./calendar_add_event_response"; +export * from "./calendar_conflicts_request_request"; +export * from "./calendar_conflicts_response"; +export * from "./calendar_conflicts_response_conflicts_item"; +export * from "./calendar_list_response"; +export * from "./calendar_list_response_calendars_item"; +export * from "./calendar_rsvp_request_request"; +export * from "./calendar_rsvp_response"; export * from "./change_flag_request_request"; export * from "./channel"; export * from "./channel_request"; @@ -74,6 +83,13 @@ 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_calendar_add_create400"; +export * from "./mailboxes_calendar_add_create503"; +export * from "./mailboxes_calendar_calendars_retrieve502"; +export * from "./mailboxes_calendar_conflicts_create400"; +export * from "./mailboxes_calendar_conflicts_create502"; +export * from "./mailboxes_calendar_rsvp_create400"; +export * from "./mailboxes_calendar_rsvp_create503"; export * from "./mailboxes_image_proxy_list_params"; export * from "./mailboxes_message_templates_available_list_params"; export * from "./mailboxes_message_templates_available_list_type"; @@ -126,6 +142,7 @@ export * from "./reset_password_error"; export * from "./reset_password_internal_server_error"; export * from "./reset_password_not_found"; export * from "./reset_password_response"; +export * from "./response_enum"; export * from "./scope_level_enum"; export * from "./send_create400"; export * from "./send_create403"; diff --git a/src/frontend/src/features/api/gen/models/mailboxes_calendar_add_create400.ts b/src/frontend/src/features/api/gen/models/mailboxes_calendar_add_create400.ts new file mode 100644 index 00000000..0db872e4 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mailboxes_calendar_add_create400.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export type MailboxesCalendarAddCreate400 = { + detail?: string; +}; diff --git a/src/frontend/src/features/api/gen/models/mailboxes_calendar_add_create503.ts b/src/frontend/src/features/api/gen/models/mailboxes_calendar_add_create503.ts new file mode 100644 index 00000000..0b9206bf --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mailboxes_calendar_add_create503.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export type MailboxesCalendarAddCreate503 = { + detail?: string; +}; diff --git a/src/frontend/src/features/api/gen/models/mailboxes_calendar_calendars_retrieve502.ts b/src/frontend/src/features/api/gen/models/mailboxes_calendar_calendars_retrieve502.ts new file mode 100644 index 00000000..2dcde676 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mailboxes_calendar_calendars_retrieve502.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export type MailboxesCalendarCalendarsRetrieve502 = { + detail?: string; +}; diff --git a/src/frontend/src/features/api/gen/models/mailboxes_calendar_conflicts_create400.ts b/src/frontend/src/features/api/gen/models/mailboxes_calendar_conflicts_create400.ts new file mode 100644 index 00000000..8f9272f5 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mailboxes_calendar_conflicts_create400.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export type MailboxesCalendarConflictsCreate400 = { + detail?: string; +}; diff --git a/src/frontend/src/features/api/gen/models/mailboxes_calendar_conflicts_create502.ts b/src/frontend/src/features/api/gen/models/mailboxes_calendar_conflicts_create502.ts new file mode 100644 index 00000000..83b9a45e --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mailboxes_calendar_conflicts_create502.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export type MailboxesCalendarConflictsCreate502 = { + detail?: string; +}; diff --git a/src/frontend/src/features/api/gen/models/mailboxes_calendar_rsvp_create400.ts b/src/frontend/src/features/api/gen/models/mailboxes_calendar_rsvp_create400.ts new file mode 100644 index 00000000..aa8a39e6 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mailboxes_calendar_rsvp_create400.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export type MailboxesCalendarRsvpCreate400 = { + detail?: string; +}; diff --git a/src/frontend/src/features/api/gen/models/mailboxes_calendar_rsvp_create503.ts b/src/frontend/src/features/api/gen/models/mailboxes_calendar_rsvp_create503.ts new file mode 100644 index 00000000..be4684d4 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mailboxes_calendar_rsvp_create503.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export type MailboxesCalendarRsvpCreate503 = { + detail?: string; +}; diff --git a/src/frontend/src/features/api/gen/models/response_enum.ts b/src/frontend/src/features/api/gen/models/response_enum.ts new file mode 100644 index 00000000..dc41fabf --- /dev/null +++ b/src/frontend/src/features/api/gen/models/response_enum.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * * `ACCEPTED` - ACCEPTED + * `DECLINED` - DECLINED + * `TENTATIVE` - TENTATIVE + */ +export type ResponseEnum = (typeof ResponseEnum)[keyof typeof ResponseEnum]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ResponseEnum = { + ACCEPTED: "ACCEPTED", + DECLINED: "DECLINED", + TENTATIVE: "TENTATIVE", +} as const; diff --git a/src/frontend/src/features/controlled-modals/message-importer/index.tsx b/src/frontend/src/features/controlled-modals/message-importer/index.tsx index 9248b046..6134de4e 100644 --- a/src/frontend/src/features/controlled-modals/message-importer/index.tsx +++ b/src/frontend/src/features/controlled-modals/message-importer/index.tsx @@ -8,7 +8,7 @@ import { StepCompleted } from "./step-completed"; import clsx from "clsx"; import { useEffect, useRef, useState } from "react"; import { TaskImportCacheHelper } from "@/features/utils/task-import-cache"; -import { ImportTaskRecap } from "@/hooks/use-import-task"; +import { ImportTaskRecap } from "@/hooks/use-task-status"; export const MODAL_MESSAGE_IMPORTER_ID = "modal-message-importer"; diff --git a/src/frontend/src/features/controlled-modals/message-importer/step-completed.tsx b/src/frontend/src/features/controlled-modals/message-importer/step-completed.tsx index 2cd17cf2..aebae116 100644 --- a/src/frontend/src/features/controlled-modals/message-importer/step-completed.tsx +++ b/src/frontend/src/features/controlled-modals/message-importer/step-completed.tsx @@ -1,6 +1,6 @@ import { Button } from "@gouvfr-lasuite/cunningham-react"; import { useTranslation } from "react-i18next"; -import { ImportTaskRecap } from "@/hooks/use-import-task"; +import { ImportTaskRecap } from "@/hooks/use-task-status"; import clsx from "clsx"; type StepCompletedProps = { diff --git a/src/frontend/src/features/controlled-modals/message-importer/step-loader.tsx b/src/frontend/src/features/controlled-modals/message-importer/step-loader.tsx index 475ef7ba..84b4e627 100644 --- a/src/frontend/src/features/controlled-modals/message-importer/step-loader.tsx +++ b/src/frontend/src/features/controlled-modals/message-importer/step-loader.tsx @@ -1,6 +1,6 @@ import { StatusEnum } from "@/features/api/gen"; import ProgressBar from "@/features/ui/components/progress-bar"; -import { ImportTaskRecap, useImportTaskStatus } from "@/hooks/use-import-task"; +import { ImportTaskRecap, useTaskStatus } from "@/hooks/use-task-status"; import { Spinner } from "@gouvfr-lasuite/ui-kit"; import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; @@ -11,19 +11,9 @@ type StepLoaderProps = { onError: (error: string) => void; } -export type TaskMetadata = { - current_message: number; - total_messages: number | null; - failure_count: number; - success_count: number; - message_status: string; - type: string; - -} - const renderProgressText = ( t: ReturnType['t'], - importStatus: NonNullable> + importStatus: NonNullable> ) => { if (importStatus.progress !== null && importStatus.progress > 0) { return

{t('{{progress}}% imported', { progress: importStatus.progress })}

; @@ -36,7 +26,9 @@ const renderProgressText = ( export const StepLoader = ({ taskId, onComplete, onError }: StepLoaderProps) => { const { t } = useTranslation(); - const importStatus = useImportTaskStatus(taskId)!; + const importStatus = useTaskStatus(taskId, { + exhaustedError: t('An error occurred while importing messages.'), + }); // Use refs to avoid stale closures without requiring stable callback props const onCompleteRef = useRef(onComplete); @@ -45,14 +37,15 @@ export const StepLoader = ({ taskId, onComplete, onError }: StepLoaderProps) => onErrorRef.current = onError; useEffect(() => { - if (importStatus?.state === StatusEnum.SUCCESS) { + if (!importStatus) return; + if (importStatus.state === StatusEnum.SUCCESS) { onCompleteRef.current({ successCount: importStatus.successCount, failureCount: importStatus.failureCount, totalMessages: importStatus.totalMessages, }); - } else if (importStatus?.state === StatusEnum.FAILURE) { - const error = importStatus?.error || ''; + } else if (importStatus.state === StatusEnum.FAILURE) { + const error = importStatus.error || ''; const isAuthError = error.includes("AUTHENTICATIONFAILED") || error.includes("IMAP authentication failed"); @@ -76,6 +69,17 @@ export const StepLoader = ({ taskId, onComplete, onError }: StepLoaderProps) => } }, [importStatus?.state, t]); + if (!importStatus) { + return ( +
+ +
+

{t('Importing...')}

+
+
+ ); + } + return (
diff --git a/src/frontend/src/features/layouts/components/main/header/authenticated.tsx b/src/frontend/src/features/layouts/components/main/header/authenticated.tsx index c9db0e57..dd44a441 100644 --- a/src/frontend/src/features/layouts/components/main/header/authenticated.tsx +++ b/src/frontend/src/features/layouts/components/main/header/authenticated.tsx @@ -11,7 +11,7 @@ import { LanguagePicker } from "@/features/layouts/components/main/language-pick import { LagaufreButton } from "@/features/ui/components/lagaufre"; import { SurveyButton } from "@/features/ui/components/feedback-button"; import { useMailboxContext } from "@/features/providers/mailbox"; -import { useImportTaskStatus } from "@/hooks/use-import-task"; +import { useTaskStatus } from "@/hooks/use-task-status"; import { MessageTemplateTypeChoices, StatusEnum, useMailboxesMessageTemplatesList } from "@/features/api/gen"; import { CircularProgress } from "@/features/ui/components/circular-progress"; import { TaskImportCacheHelper } from "@/features/utils/task-import-cache"; @@ -151,7 +151,7 @@ const ApplicationMenu = () => { return taskImportCacheHelper.get(); }, [isDropdownOpen, selectedMailbox?.id]); - const taskStatus = useImportTaskStatus(taskId, { enabled: canImportMessages && isDropdownOpen }); + const taskStatus = useTaskStatus(taskId, { enabled: canImportMessages && isDropdownOpen }); const hasOptions = canAccessDomainAdmin || canImportMessages || canManageMessageTemplates || canManageIntegrations; const importMessageOption = useMemo(() => { let label = t("Import messages"); diff --git a/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/_index.scss b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/_index.scss index be7d57e9..1de88840 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/_index.scss @@ -2,8 +2,8 @@ border: 1px solid var(--c--contextuals--border--surface--primary); border-radius: 8px; background-color: var(--c--contextuals--background--surface--tertiary); - padding: var(--c--globals--spacings--base); - margin-bottom: var(--c--globals--spacings--base); + padding: var(--c--globals--spacings--sm) var(--c--globals--spacings--base); + margin-bottom: var(--c--globals--spacings--sm); &--loading, &--error, @@ -11,7 +11,7 @@ display: flex; align-items: center; gap: var(--c--globals--spacings--sm); - padding: var(--c--globals--spacings--base); + padding: var(--c--globals--spacings--sm) var(--c--globals--spacings--base); color: var(--c--contextuals--content--semantic--neutral--secondary); } @@ -42,30 +42,36 @@ & + & { margin-top: var(--c--globals--spacings--sm); padding-top: var(--c--globals--spacings--sm); - border-top: 1px solid var(--c--contextuals--border--surface--primary); } } .calendar-invite__header { display: flex; - align-items: flex-start; + align-items: center; gap: var(--c--globals--spacings--sm); - margin-bottom: var(--c--globals--spacings--sm); + margin-bottom: var(--c--globals--spacings--xs); +} + +.calendar-invite__header-right { + margin-left: auto; + display: flex; + align-items: center; + flex-shrink: 0; } .calendar-invite__icon { flex-shrink: 0; - width: 40px; - height: 40px; + width: 28px; + height: 28px; display: flex; align-items: center; justify-content: center; background-color: var(--c--contextuals--background--semantic--brand--tertiary); - border-radius: 8px; + border-radius: 6px; color: var(--c--contextuals--content--semantic--brand--primary); .material-icon { - font-size: 24px; + font-size: 18px; } } @@ -76,14 +82,14 @@ .calendar-invite__title { margin: 0; - font-size: var(--c--globals--font--sizes--lg); + font-size: var(--c--globals--font--sizes--md); font-weight: 600; color: var(--c--contextuals--content--semantic--neutral--primary); - word-break: break-word; + overflow-wrap: break-word; + line-height: 1.3; } .calendar-invite__event-status { - margin-top: var(--c--globals--spacings--2xs); text-transform: uppercase; &--confirmed { @@ -105,7 +111,7 @@ .calendar-invite__details { display: flex; flex-direction: column; - gap: var(--c--globals--spacings--xs); + gap: var(--c--globals--spacings--sm); } .calendar-invite__detail-row { @@ -133,72 +139,83 @@ p { margin: 0; white-space: pre-wrap; - word-break: break-word; + overflow-wrap: break-word; } } .calendar-invite__attendees { - margin-top: var(--c--globals--spacings--2xs); - .calendar-invite__show-more { margin-left: calc(18px + var(--c--globals--spacings--sm)); } } -.calendar-invite__attendees-header { - display: flex; - align-items: center; - gap: var(--c--globals--spacings--sm); - font-size: var(--c--globals--font--sizes--sm); - font-weight: 500; - color: var(--c--contextuals--content--semantic--neutral--primary); - margin-bottom: var(--c--globals--spacings--2xs); -} - .calendar-invite__attendee-list { list-style: none; margin: 0; padding: 0; - padding-left: calc(18px + var(--c--globals--spacings--sm)); display: flex; flex-direction: column; - gap: var(--c--globals--spacings--2xs); + gap: var(--c--globals--spacings--3xs); } .calendar-invite__attendee { display: flex; align-items: center; - justify-content: space-between; gap: var(--c--globals--spacings--sm); font-size: var(--c--globals--font--sizes--sm); - padding: var(--c--globals--spacings--2xs) 0; -} + padding: 0; + min-width: 0; -.calendar-invite__attendee-status { - display: flex; - align-items: center; - gap: var(--c--globals--spacings--3xs); - font-size: var(--c--globals--font--sizes--xs); - flex-shrink: 0; - - .material-icon { - font-size: 16px; + .calendar-invite__detail-icon { + margin-top: 0; } - &--accepted { + &--indented { + // Align attendee chip with organizer chip (icon width + gap). + padding-left: calc(18px + var(--c--globals--spacings--sm)); + } +} + +.calendar-invite__organizer-label, +.calendar-invite__attendee-pill { + font-size: var(--c--globals--font--sizes--xs); + padding: 0 var(--c--globals--spacings--2xs); + border-radius: 4px; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; +} + +.calendar-invite__attendee-pill { + display: inline-flex; + align-items: center; + gap: var(--c--globals--spacings--3xs); +} + +.calendar-invite__organizer-label { + background-color: var(--c--contextuals--background--semantic--brand--tertiary); + color: var(--c--contextuals--content--semantic--brand--primary); +} + +.calendar-invite__attendee-pill { + &.calendar-invite__attendee-status--accepted { + background-color: var(--c--contextuals--background--semantic--success--tertiary); color: var(--c--contextuals--content--semantic--success--primary); } - &--declined { + &.calendar-invite__attendee-status--declined { + background-color: var(--c--contextuals--background--semantic--error--tertiary); color: var(--c--contextuals--content--semantic--error--primary); } - &--tentative { + &.calendar-invite__attendee-status--tentative { + background-color: var(--c--contextuals--background--semantic--warning--tertiary); color: var(--c--contextuals--content--semantic--warning--primary); } - &--pending, - &--delegated { + &.calendar-invite__attendee-status--pending, + &.calendar-invite__attendee-status--delegated { + background-color: var(--c--contextuals--background--surface--secondary); color: var(--c--contextuals--content--semantic--neutral--secondary); } } @@ -228,9 +245,222 @@ } .calendar-invite__actions { - margin-top: var(--c--globals--spacings--sm); - padding-top: var(--c--globals--spacings--sm); - border-top: 1px solid var(--c--contextuals--border--surface--primary); + margin-top: var(--c--globals--spacings--xs); + min-height: 40px; display: flex; - gap: var(--c--globals--spacings--sm); + align-items: center; +} + +.calendar-invite__connection { + display: flex; + flex: 1; + align-items: center; + gap: var(--c--globals--spacings--sm); + flex-wrap: wrap; + font-size: var(--c--globals--font--sizes--sm); + + &--loading { + color: var(--c--contextuals--content--semantic--neutral--secondary); + } + + &--unavailable { + color: var(--c--contextuals--content--semantic--warning--primary); + } + + &--empty { + color: var(--c--contextuals--content--semantic--neutral--secondary); + } +} + +.calendar-invite__calendar-link { + display: inline-flex; + align-items: center; + gap: var(--c--globals--spacings--3xs); + font-size: var(--c--globals--font--sizes--sm); + color: var(--c--contextuals--content--semantic--brand--primary); + text-decoration: none; + padding: var(--c--globals--spacings--3xs) var(--c--globals--spacings--2xs); + border-radius: 4px; + + .material-icon { + font-size: 16px; + } + + &:hover { + text-decoration: underline; + } +} + +.calendar-invite__connection-actions { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--xs); + flex-wrap: wrap; +} + +.calendar-invite__conflicts { + margin-top: var(--c--globals--spacings--xs); + padding: var(--c--globals--spacings--xs) var(--c--globals--spacings--sm); + border-radius: 4px; + background-color: var(--c--contextuals--background--semantic--warning--tertiary); + color: var(--c--contextuals--content--semantic--warning--primary); + font-size: var(--c--globals--font--sizes--sm); +} + +.calendar-invite__conflicts-header { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--sm); + font-weight: 500; + margin-bottom: var(--c--globals--spacings--2xs); + + .material-icon { + font-size: 18px; + } +} + +.calendar-invite__conflicts-list { + list-style: none; + margin: 0; + padding: 0; + padding-left: calc(18px + var(--c--globals--spacings--sm)); +} + +.calendar-invite__conflict-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--c--globals--spacings--sm); + padding: var(--c--globals--spacings--3xs) 0; +} + +.calendar-invite__conflict-summary { + font-weight: 500; +} + +.calendar-invite__conflict-calendar { + font-size: var(--c--globals--font--sizes--xs); + opacity: 0.8; +} + +.calendar-invite__conflict-time { + color: var(--c--contextuals--content--semantic--warning--primary); + font-size: var(--c--globals--font--sizes--xs); + flex-shrink: 0; +} + +.calendar-invite__rsvp-buttons { + display: flex; + gap: var(--c--globals--spacings--xs); +} + +.calendar-invite__calendar-chooser { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--xs); + min-width: 280px; + max-width: 360px; + + .calendar-invite__detail-icon { + margin-top: 0; + } +} + +.calendar-select__option { + display: inline-flex; + align-items: center; + gap: var(--c--globals--spacings--2xs); + min-width: 0; +} + +.calendar-select__color { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; + border: 1px solid rgba(0, 0, 0, 0.08); +} + +.calendar-invite__calendar-pill { + display: inline-flex; + align-items: center; + gap: var(--c--globals--spacings--2xs); + padding: 0 var(--c--globals--spacings--xs); + height: 32px; + border-radius: 4px; + font-size: var(--c--globals--font--sizes--sm); + color: var(--c--contextuals--content--semantic--neutral--primary); + background-color: var(--c--contextuals--background--surface--secondary); +} + +.calendar-invite__calendar-pill-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 200px; +} + +.calendar-invite__calendar-swatch { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; + border: 1px solid rgba(0, 0, 0, 0.08); +} + +.calendar-invite__open-calendar { + display: inline-flex; + align-items: center; + color: inherit; + text-decoration: none; + + &:hover, + &:focus-visible { + color: var(--c--contextuals--content--semantic--info--primary); + } +} + +.calendar-invite__calendar-select { + flex: 1; + min-width: 0; + + // Compact the Cunningham select to match the 32px small buttons next to it. + // Cunningham's default labelled-box reserves space for a floating label — + // we override it since we use `hideLabel`. + .c__field, + .c__field--default { + margin: 0; + padding: 0; + } + + .labelled-box, + .labelled-box--no-label { + min-height: 32px; + height: 32px; + padding: 0; + + > span:not([class]), + .labelled-box__label { + display: none; + } + } + + .labelled-box__children { + min-height: 32px; + height: 32px; + } + + .c__select__wrapper { + min-height: 32px; + height: 32px; + } + + .c__select__inner { + min-height: 32px; + height: 32px; + padding-top: 0; + padding-bottom: 0; + } } diff --git a/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/calendar-helper.tsx b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/calendar-helper.tsx index 5aabadf6..39c34ca1 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/calendar-helper.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/calendar-helper.tsx @@ -22,7 +22,8 @@ export function durationToMs(d: IcsDuration): number { /** * Compute the end Date from an event that may use end or duration */ -export function getEventEnd(event: IcsEvent): Date | undefined { +export function getEventEnd(event: IcsEvent | undefined): Date | undefined { + if (!event) return undefined; if (event.end) return event.end.date; if (event.duration && event.start) { return new Date(event.start.date.getTime() + durationToMs(event.duration)); diff --git a/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/calendar-select.tsx b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/calendar-select.tsx new file mode 100644 index 00000000..d683ac4c --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/calendar-select.tsx @@ -0,0 +1,79 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Select } from "@gouvfr-lasuite/cunningham-react"; + +type CalendarOption = { + id: string; + name: string; + color?: string | null; +}; + +interface CalendarSelectProps { + calendars: CalendarOption[]; + value: string; + onChange: (id: string) => void; + className?: string; +} + +function CalendarOptionItem({ + name, + color, +}: { + name: string; + color: string; +}) { + return ( + + + {name} + + ); +} + +export function CalendarSelect({ + calendars, + value, + onChange, + className, +}: CalendarSelectProps) { + const { t } = useTranslation(); + + const options = useMemo( + () => + calendars.map((cal) => { + const color = cal.color || "#3788d8"; + const optionJsx = ( + + ); + return { + value: cal.id, + // Cunningham's Select renders ``label`` in the closed + // state and falls back to it in the open list when no + // ``render`` is provided. Passing JSX makes the swatch + // appear next to the calendar name everywhere; the + // type only declares ``string``, hence the cast. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + label: optionJsx as any, + render: () => optionJsx, + }; + }), + [calendars], + ); + + return ( + edit, + icon: , callback: () => onEdit(label), }, { label: t('Add a sub-label'), - icon: add, + icon: , callback: () => onEdit({ name: `${label.name}/`, color: label.color }), showSeparator: true, }, { label: t('Delete'), - icon: delete, + icon: , callback: handleDelete, variant: 'danger' }, diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx b/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx index 0f6a78c3..67d78eaa 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx @@ -306,7 +306,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is }, ...([SelectionReadStatus.MIXED, SelectionReadStatus.UNREAD].includes(selectionReadStatus) ? [{ label: markAllTooltip, - icon: drafts, + icon: , callback: () => { markAsReadAt({ threadIds: threadIdsToMark, @@ -320,7 +320,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is }] : []), ...([SelectionReadStatus.MIXED, SelectionReadStatus.READ, SelectionReadStatus.NONE].includes(selectionReadStatus) ? [{ label: markAllUnreadLabel, - icon: mark_email_unread, + icon: , callback: () => { // Close the open thread before the mutation so the visibility // observer cannot re-mark the newly-unread messages as read. @@ -363,7 +363,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
); diff --git a/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/share-modal.tsx b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/share-modal.tsx index f80c8336..ea6249f5 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/share-modal.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/share-modal.tsx @@ -30,6 +30,7 @@ import { type QuickSearchData, QuickSearchGroup, QuickSearchItemTemplate, + Icon, ShareInvitationItem, type DropdownMenuOption, type UserData, @@ -71,7 +72,7 @@ const SearchUserItem = ({ user }: SearchUserItemProps) => { right={
{tc("components.share.item.add")} - add +
} /> @@ -533,7 +534,7 @@ const ShowMoreButton = ({ show, onShowMore }: ShowMoreButtonProps) => { ))} {closeButton && ( @@ -66,7 +67,7 @@ export const ToasterItem = ({ color={buttonColor} variant="tertiary" size="small" - icon={close} + icon={} > )} diff --git a/src/frontend/src/features/utils/text-helper/index.test.tsx b/src/frontend/src/features/utils/text-helper/index.test.tsx index f5f6e734..9a7d7762 100644 --- a/src/frontend/src/features/utils/text-helper/index.test.tsx +++ b/src/frontend/src/features/utils/text-helper/index.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable i18next/no-literal-string */ import React from "react"; import { describe, it, expect } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; diff --git a/src/frontend/src/routes/index.tsx b/src/frontend/src/routes/index.tsx index 6133bbf4..cbb393a1 100644 --- a/src/frontend/src/routes/index.tsx +++ b/src/frontend/src/routes/index.tsx @@ -25,12 +25,12 @@ const HomePage = () => { hideLeftPanelOnDesktop leftPanelContent={} rightHeaderContent={} - icon={logo} + icon={{t("logo")}} > + { selectedMailbox && mailboxes && ( +
+ { + closeLeftPanel(); + navigate({ to: '/mailbox/$mailboxId', params: { mailboxId }, search: Object.fromEntries(searchParams) }); + }} + /> +
)} {!selectedMailbox || queryStates.mailboxes.isLoading ? : diff --git a/src/frontend/src/features/layouts/components/mailbox-selector/_index.scss b/src/frontend/src/features/layouts/components/mailbox-selector/_index.scss new file mode 100644 index 00000000..76239a48 --- /dev/null +++ b/src/frontend/src/features/layouts/components/mailbox-selector/_index.scss @@ -0,0 +1,99 @@ +.mailbox-selector { + // Make the dropdown trigger wrapper span the full available width so the + // card aligns with the surrounding sidebar/header content. + & > .c__dropdown-menu-trigger { + justify-content: flex-start; + width: 100%; + } + + // Card shared by the switchable (button) and the static (single-mailbox) + // variants: avatar on the left, bold name and the address underneath, with + // the unfold chevron pushed to the trailing edge. + .mailbox-selector__trigger { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--xs); + width: 100%; + height: inherit; + padding-block: var(--c--globals--spacings--base) var(--c--globals--spacings--sm); + padding-inline: var(--c--globals--spacings--base); + .c__button__icon { + transition: transform 0.25s ease; + backface-visibility: visible; + } + } + + .mailbox-selector__trigger[aria-expanded="true"] { + .c__button__icon { + transform: rotateX(-180deg); + } + } + + .mailbox-selector__trigger--static { + // Nothing to unfold for a single mailbox: drop the interactive cursor. + cursor: default; + } + + // Shared (non-identity) mailboxes get a rounded-square avatar to set them + // apart from the circular personal one. + .mailbox-selector__avatar[data-shared="true"] .c__avatar { + border-radius: 8px; + } + + .mailbox-selector__text { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + overflow: hidden; + text-align: left; + } + + .mailbox-selector__name { + font-size: var(--c--globals--font--sizes--sm); + line-height: 1.2; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mailbox-selector__email { + font-size: var(--c--globals--font--sizes--xs); + opacity: 0.8; + font-weight: 500; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +// Same rounded-square treatment for shared mailboxes in the dropdown options. +// The popover renders in a portal outside `.mailbox-selector`, so this rule is +// intentionally global rather than nested under it. +.mailbox-selector__option-avatar { + // Layout-transparent wrapper: the avatar sits exactly where the dropdown + // expects its icon, we only need the element as a styling hook. + display: contents; + + &[data-shared="true"] .c__avatar { + border-radius: 8px; + } +} + +// The dropdown caps its width at 320px, so a long mailbox name would otherwise +// wrap onto several lines. Truncate it with an ellipsis instead. Scoped to our +// items (which carry the avatar hook) since the popover lives in a portal. +.c__dropdown-menu-item:has(.mailbox-selector__option-avatar) { + .c__dropdown-menu-item__label-container { + min-width: 0; + } + + .c__dropdown-menu-item__label, + .c__dropdown-menu-item__label-subtext { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/src/frontend/src/features/layouts/components/mailbox-selector/index.tsx b/src/frontend/src/features/layouts/components/mailbox-selector/index.tsx new file mode 100644 index 00000000..0e729dba --- /dev/null +++ b/src/frontend/src/features/layouts/components/mailbox-selector/index.tsx @@ -0,0 +1,113 @@ +import { DropdownMenu, UserAvatar } from "@gouvfr-lasuite/ui-kit"; +import { ChevronDown } from "@gouvfr-lasuite/ui-kit/icons"; +import { Button } from "@gouvfr-lasuite/cunningham-react"; +import { useState } from "react"; +import { Mailbox } from "@/features/api/gen"; +import MailboxHelper from "@/features/utils/mailbox-helper"; + +/** Display name of a mailbox, falling back to its address when no contact name + * is set (a mailbox may legitimately have a null/blank name). */ +const getMailboxLabel = (mailbox: Mailbox) => mailbox.name?.trim() || mailbox.email; + +type MailboxSelectorProps = { + /** Mailboxes the user can switch to (already the eligible subset). */ + mailboxes: readonly Mailbox[]; + /** Currently displayed mailbox. */ + selectedMailbox: Mailbox; + /** Called with the picked mailbox id; never fired for the current one. */ + onSelect: (mailboxId: string) => void | Promise; +}; + +/** + * Mailbox switcher shared by the sidebar header and the settings modal: an + * avatar + bold name + address card that unfolds a dropdown of the eligible + * mailboxes. When the user owns a single mailbox there is nothing to switch to, + * so the card renders as static content (not a disabled button) to keep the + * name/address legible and avoid exposing a bogus "button, unavailable" control. + */ +export const MailboxSelector = ({ + mailboxes, + selectedMailbox, + onSelect, +}: MailboxSelectorProps) => { + const [isOpen, setIsOpen] = useState(false); + + const label = getMailboxLabel(selectedMailbox); + const sublabel = selectedMailbox.name?.trim() ? selectedMailbox.email : null; + const canSwitch = mailboxes.length > 1; + + // Avatar is decorative: the name it encodes is already shown next to it as + // text, so hide it from assistive tech to avoid a duplicate announcement. + const content = ( + <> + + + {label} + {sublabel && {sublabel}} + + + ); + + if (!canSwitch) { + return ( +
+
+ {content} +
+
+ ); + } + + const sortedMailboxes = MailboxHelper.sortByKind(mailboxes); + const options = sortedMailboxes.map((mailbox, index) => ({ + label: getMailboxLabel(mailbox), + subText: mailbox.name?.trim() ? mailbox.email : undefined, + value: mailbox.id, + icon: ( + + + + ), + showSeparator: MailboxHelper.showSeparatorAfter(sortedMailboxes, index), + })); + + return ( +
+ { + setIsOpen(false); + if (value !== selectedMailbox.id) { + void onSelect(value); + } + }} + > + + +
+ ); +}; diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/_index.scss b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/_index.scss index 27f7bd82..2d9e6a7c 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/_index.scss +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/_index.scss @@ -36,78 +36,14 @@ // macOS-style account card sitting at the very top of the settings sidebar: // mailbox avatar on the left, name in bold and (when set) the address in a // muted line underneath. + // Wrapper around the shared MailboxSelector in the sidebar header. Full width + // with min-width:0 so the selector's text column can shrink and ellipsize the + // long address instead of overflowing the narrow sidebar. &__identity { - /* gap: var(--c--globals--spacings--xs); */ width: 100%; - // Min-width:0 lets the flexible text column actually shrink so the long - // address can ellipsize instead of overflowing the narrow sidebar. min-width: 0; } - &__identity-text { - display: flex; - flex-direction: column; - align-items: flex-start; - min-width: 0; - line-height: 1.25; - } - - &__identity-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 100%; - font-size: var(--c--globals--font--sizes--sm); - font-weight: 600; - color: var(--c--contextuals--content--semantic--neutral--primary); - } - - &__identity-email { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: var(--c--globals--font--sizes--xs); - font-weight: 400; - min-width: 0; - max-width: 100%; - color: var(--c--contextuals--content--semantic--neutral--secondary); - } - - // Shared box for both card variants so the switchable (button) and the - // static (single-mailbox) renderings keep the exact same footprint. - &__dropdown-button, - &__identity-static { - padding: var(--c--globals--spacings--sm) var(--c--globals--spacings--xs); - } - - // Switchable variant: the whole card becomes a borderless button with an - // unfold chevron and a subtle hover fill, mirroring a macOS popup button. - &__dropdown-button { - border: none; - height: inherit; - justify-content: space-between; - } - - &__dropdown-button__content { - display: flex; - align-items: center; - gap: var(--c--globals--spacings--s); - // Let the content shrink inside the (full-width) card so the text column - // can ellipsize instead of pushing the card wider than the sidebar. - min-width: 0; - - // The avatar keeps its size; only the text column absorbs the shrink. - & > :first-child { - flex-shrink: 0; - } - } - - &__identity-chevron { - margin-left: auto; - flex-shrink: 0; - color: var(--c--contextuals--content--semantic--neutral--tertiary); - } - &__section { margin-bottom: var(--c--globals--spacings--lg); } diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/index.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/index.tsx index 67316958..e9ab8295 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/index.tsx @@ -1,16 +1,14 @@ import { - Button, Modal, ModalSize, ModalTab, } from "@gouvfr-lasuite/cunningham-react"; -import { DropdownMenu, HorizontalSeparator, UserAvatar } from "@gouvfr-lasuite/ui-kit"; +import { HorizontalSeparator } from "@gouvfr-lasuite/ui-kit"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Mailbox } from "@/features/api/gen"; import { useMailboxContext } from "@/features/providers/mailbox"; import { FEATURE_KEYS, useFeatureFlag } from "@/hooks/use-feature"; -import MailboxHelper from "@/features/utils/mailbox-helper"; +import { MailboxSelector } from "@/features/layouts/components/mailbox-selector"; import { useConfirmBeforeClose, useConfirmUnsavedChanges, @@ -21,7 +19,6 @@ import { MailboxSettingsSignaturesTab } from "./signatures-tab"; import { MailboxSettingsMessageTemplatesTab } from "./message-templates-tab"; import { MailboxSettingsAutorepliesTab } from "./autoreplies-tab"; import { MailboxSettingsIntegrationsTab } from "./integrations-tab"; -import { ChevronDown, ChevronUp } from "@gouvfr-lasuite/ui-kit/icons"; export type SettingsTabId = | "general" @@ -45,11 +42,6 @@ export const MODAL_MAILBOX_SETTINGS_ID = "modal-mailbox-settings"; // the sidebar↔content view itself can't be driven from outside the component. const COMPACT_MODAL_MEDIA_QUERY = "(max-width: 576px)"; -/** Display name shown on the identity card; falls back to the address when the - * mailbox has no contact name set. */ -const getMailboxName = (mailbox: Mailbox) => - mailbox.name?.trim() || mailbox.email; - /** * Settings modal for a mailbox the user can configure. Built on Cunningham's * "tab" modal layout: the sidebar lists setting categories (General — rename —, @@ -91,7 +83,6 @@ export const ModalMailboxSettings = ({ const [selectedMailboxId, setSelectedMailboxId] = useState( null, ); - const [isMailboxDropdownOpen, setIsMailboxDropdownOpen] = useState(false); const [isActiveTabDirty, setIsActiveTabDirty] = useState(false); const confirmUnsavedChanges = useConfirmUnsavedChanges(); @@ -130,18 +121,6 @@ export const ModalMailboxSettings = ({ : "general"; }); - const mailboxOptions = useMemo(() => { - const sortedMailboxes = MailboxHelper.sortByKind(settingsMailboxes); - - return sortedMailboxes.map((mailbox: Mailbox, index) => ({ - label: getMailboxName(mailbox), - subText: mailbox.name?.trim() ? mailbox.email : undefined, - value: mailbox.id, - icon: , - showSeparator: MailboxHelper.showSeparatorAfter(sortedMailboxes, index), - })); - }, [settingsMailboxes]); - // Ordered ids of the tabs the selected mailbox exposes, gated by its abilities // (and the integrations feature flag). Single source of truth: it drives both // the rendered `tabs` below and the active-tab synchronisation on mailbox @@ -191,81 +170,20 @@ export const ModalMailboxSettings = ({ return null; } - const hasMultipleMailboxes = settingsMailboxes.length > 1; - const mailboxName = getMailboxName(settingsMailbox); - const mailboxSubtitle = settingsMailbox.name?.trim() - ? settingsMailbox.email - : null; - - // The macOS-style account card: avatar, name and (when distinct) the address - // underneath. Shared between the static and the switchable variants below. - const identityCardContent = ( - <> - - - {mailboxName} - {mailboxSubtitle && ( - - {mailboxSubtitle} - - )} - - - ); - + // macOS-style account card switching the configured mailbox, shared with the + // sidebar header switcher. Switching remounts the General tab and discards any + // unsaved rename, so confirm first when there are pending edits. const sidebarHeader = ( <> - {hasMultipleMailboxes ? ( - { - setIsMailboxDropdownOpen(false); - if (value === settingsMailbox.id) { - return; - } - // Switching the configured mailbox remounts the General tab and - // discards any unsaved rename, just like a tab switch does. - if (await confirmUnsavedChanges(isActiveTabDirty)) { - setSelectedMailboxId(value); - } - }} - > - - - ) : ( - // Single mailbox: nothing to switch to, so the card is purely informative. - // Rendered as static content (not a disabled button) to avoid exposing a - // bogus "button, unavailable" control and to keep the name/address fully - // legible instead of greyed out by the disabled state. -
-
- {identityCardContent} -
-
- )} + { + if (await confirmUnsavedChanges(isActiveTabDirty)) { + setSelectedMailboxId(value); + } + }} + /> ); diff --git a/src/frontend/src/styles/globals.scss b/src/frontend/src/styles/globals.scss index a1b27efb..0fb3d3c2 100644 --- a/src/frontend/src/styles/globals.scss +++ b/src/frontend/src/styles/globals.scss @@ -16,6 +16,7 @@ body { margin: 0; background-color: var(--c--contextuals--background--surface--tertiary); color: var(--c--contextuals--content--semantic--neutral--primary); + perspective: 9000px; } * { diff --git a/src/frontend/src/styles/main.scss b/src/frontend/src/styles/main.scss index 7259c38e..b8268a46 100644 --- a/src/frontend/src/styles/main.scss +++ b/src/frontend/src/styles/main.scss @@ -30,6 +30,7 @@ @use "./../features/ui/components/suggestion-input"; @use "./../features/ui/components/transient-tooltip"; @use "./../features/ui/components/assignees-avatar-group"; +@use "./../features/layouts/components/mailbox-selector"; @use "./../features/layouts/components/mailbox-panel"; @use "./../features/layouts/components/mailbox-panel/components/mailbox-actions"; @use "./../features/layouts/components/mailbox-panel/components/mailbox-list"; From 08663a271c3deb75984d3ad70284064dbfc8c146 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste PENRATH Date: Fri, 12 Jun 2026 01:54:30 +0200 Subject: [PATCH 33/52] =?UTF-8?q?=F0=9F=A9=B9(frontend)=20remove=20useless?= =?UTF-8?q?=20perspective=20attribute=20(#709)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In previous work we forgot to remove a css property that was a test but create a scroll issue.. --- src/frontend/src/styles/globals.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/src/frontend/src/styles/globals.scss b/src/frontend/src/styles/globals.scss index 0fb3d3c2..a1b27efb 100644 --- a/src/frontend/src/styles/globals.scss +++ b/src/frontend/src/styles/globals.scss @@ -16,7 +16,6 @@ body { margin: 0; background-color: var(--c--contextuals--background--surface--tertiary); color: var(--c--contextuals--content--semantic--neutral--primary); - perspective: 9000px; } * { From e0cb1f4f08a4befc8b3ea604706e65fd0b007199 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste PENRATH Date: Mon, 15 Jun 2026 08:36:03 +0200 Subject: [PATCH 34/52] =?UTF-8?q?=F0=9F=9A=B8(frontend)=20improve=20thread?= =?UTF-8?q?=20navigation=20a11y=20and=20multiselect=20ux=20(#708)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declare the thread list as a listbox with multiselectable elements. Now when multiselect is enable, clicking on a thread add it to the current selection, it does not reset the selection. Furthermore, the keyboard navigation has been improved. --- .../src/__tests__/attachment-preview.spec.ts | 2 +- .../src/__tests__/mailbox-settings.spec.ts | 7 +- src/e2e/src/__tests__/message-import.spec.ts | 2 +- .../__tests__/message-inline-image.spec.ts | 4 +- src/e2e/src/__tests__/message-send.spec.ts | 4 +- src/e2e/src/__tests__/outbox.spec.ts | 16 +- src/e2e/src/__tests__/thread-event.spec.ts | 10 +- .../src/__tests__/thread-starred-read.spec.ts | 28 +-- src/frontend/public/locales/common/en-US.json | 3 +- src/frontend/public/locales/common/fr-FR.json | 3 +- .../components/thread-item/_index.scss | 18 ++ .../components/thread-item/index.tsx | 64 ++++--- .../hooks/listbox-navigation.test.ts | 58 ++++++ .../thread-panel/hooks/listbox-navigation.ts | 36 ++++ .../thread-panel/hooks/use-thread-listbox.ts | 135 ++++++++++++++ .../layouts/components/thread-panel/index.tsx | 21 ++- .../providers/thread-listbox-focus.tsx | 46 +++++ .../providers/thread-selection-core.test.ts | 89 ++++++++++ .../providers/thread-selection-core.ts | 77 ++++++++ .../features/providers/thread-selection.tsx | 165 +++++------------- .../src/routes/mailbox/$mailboxId/route.tsx | 5 +- 21 files changed, 599 insertions(+), 194 deletions(-) create mode 100644 src/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.test.ts create mode 100644 src/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.ts create mode 100644 src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-listbox.ts create mode 100644 src/frontend/src/features/providers/thread-listbox-focus.tsx create mode 100644 src/frontend/src/features/providers/thread-selection-core.test.ts create mode 100644 src/frontend/src/features/providers/thread-selection-core.ts diff --git a/src/e2e/src/__tests__/attachment-preview.spec.ts b/src/e2e/src/__tests__/attachment-preview.spec.ts index c028a90e..25d08745 100644 --- a/src/e2e/src/__tests__/attachment-preview.spec.ts +++ b/src/e2e/src/__tests__/attachment-preview.spec.ts @@ -59,7 +59,7 @@ test.describe("Attachment preview", () => { await page.getByText("Message sent successfully").waitFor({ state: "visible" }); await page.getByRole("link", { name: "Sent" }).click(); - await page.getByRole("link", { name: subject }).first().click(); + await page.getByRole("option", { name: subject }).first().click(); await page.getByRole("heading", { name: subject, level: 2 }).waitFor({ state: "visible" }); } diff --git a/src/e2e/src/__tests__/mailbox-settings.spec.ts b/src/e2e/src/__tests__/mailbox-settings.spec.ts index dd385f3c..e411d1f6 100644 --- a/src/e2e/src/__tests__/mailbox-settings.spec.ts +++ b/src/e2e/src/__tests__/mailbox-settings.spec.ts @@ -81,8 +81,11 @@ test.describe("Mailbox settings modal", () => { // The switcher renders a single-select dropdown, so its entries expose a // `menuitemradio`/`option` role depending on the design-system version; match - // both so the assertion does not hinge on that internal detail. - const options = page.locator('[role^="menuitem"], [role="option"]'); + // both so the assertion does not hinge on that internal detail. Scope to the + // dropdown popover: it is portalled outside the modal, and the thread list is + // itself a listbox whose `option` entries would otherwise be matched too. + const switcherPopover = page.getByRole("menu"); + const options = switcherPopover.locator('[role^="menuitem"], [role="option"]'); await expect(options).toHaveCount(2); await expect( options.filter({ hasText: `user.e2e.${browserName}@example.local` }), diff --git a/src/e2e/src/__tests__/message-import.spec.ts b/src/e2e/src/__tests__/message-import.spec.ts index 644ca92a..d8623497 100644 --- a/src/e2e/src/__tests__/message-import.spec.ts +++ b/src/e2e/src/__tests__/message-import.spec.ts @@ -103,7 +103,7 @@ test.describe("Import Message", () => { // Then expect the new message to be visible in the thread list await expect( - page.getByRole("link", { name: "Sardine 18/11/2025 An old message" }) + page.getByRole("option", { name: "Sardine 18/11/2025 An old message" }) ).toBeVisible(); }); diff --git a/src/e2e/src/__tests__/message-inline-image.spec.ts b/src/e2e/src/__tests__/message-inline-image.spec.ts index 5daa5628..a10876c7 100644 --- a/src/e2e/src/__tests__/message-inline-image.spec.ts +++ b/src/e2e/src/__tests__/message-inline-image.spec.ts @@ -97,7 +97,7 @@ test.describe("Inline Image in Composer", () => { // Verify the message appears in sentbox await page.getByRole("link", { name: "Sent" }).click(); - const sentItem = page.getByRole("link", { name: "Message with inline image" }).first(); + const sentItem = page.getByRole("option", { name: "Message with inline image" }).first(); await expect(sentItem).toBeVisible(); // Open the message and check content @@ -117,7 +117,7 @@ test.describe("Inline Image in Composer", () => { await page.getByRole("link", { name: "Inbox" }).click(); // Open the message and check content - const receivedItem = await page.getByRole("link", { name: "Message with inline image" }).first(); + const receivedItem = await page.getByRole("option", { name: "Message with inline image" }).first(); await expect(receivedItem).toBeVisible(); await receivedItem.click(); await page.getByRole("heading", { name: "Message with inline image", level: 2 }).waitFor({ state: "visible" }); diff --git a/src/e2e/src/__tests__/message-send.spec.ts b/src/e2e/src/__tests__/message-send.spec.ts index 0a451890..a26eca84 100644 --- a/src/e2e/src/__tests__/message-send.spec.ts +++ b/src/e2e/src/__tests__/message-send.spec.ts @@ -53,7 +53,7 @@ test.describe("Send Message", () => { // Go to the sentbox and check if the message is there await page.getByRole("link", { name: "Sent" }).click(); - const threadItem = page.getByRole("link", { name: "Hello everyone!" }).first(); + const threadItem = page.getByRole("option", { name: "Hello everyone!" }).first(); await expect(threadItem).toBeVisible(); expect(await threadItem.textContent()).toMatch(new RegExp(`User E2E ${browserName}`, "i")); @@ -64,7 +64,7 @@ test.describe("Send Message", () => { await page.getByRole("link", { name: "Inbox" }).click(); - const messageItem = page.getByRole("link", { name: "Hello everyone!" }).first(); + const messageItem = page.getByRole("option", { name: "Hello everyone!" }).first(); await expect(messageItem).toBeVisible(); expect(await messageItem.textContent()).toMatch(new RegExp(`User E2E ${browserName}`, "i")); diff --git a/src/e2e/src/__tests__/outbox.spec.ts b/src/e2e/src/__tests__/outbox.spec.ts index 65a3e28f..f28405d7 100644 --- a/src/e2e/src/__tests__/outbox.spec.ts +++ b/src/e2e/src/__tests__/outbox.spec.ts @@ -22,7 +22,7 @@ test.describe("Delivery failures", () => { // The thread with delivery issues should be visible in the list const threadItem = page - .getByRole("link", { name: "Test message with delivery failure" }) + .getByRole("option", { name: "Test message with delivery failure" }) .first(); await expect(threadItem).toBeVisible(); }); @@ -39,7 +39,7 @@ test.describe("Delivery failures", () => { // Click on the thread to open it const threadItem = page - .getByRole("link", { name: "Test message with delivery failure" }) + .getByRole("option", { name: "Test message with delivery failure" }) .first(); await threadItem.click(); @@ -79,7 +79,7 @@ test.describe("Delivery failures", () => { await page.waitForLoadState("networkidle"); await page - .getByRole("link", { name: "Test message with delivery failure" }) + .getByRole("option", { name: "Test message with delivery failure" }) .first() .click(); @@ -110,7 +110,7 @@ test.describe("Delivery failures", () => { await page.waitForLoadState("networkidle"); await page - .getByRole("link", { name: "Test message with delivery failure" }) + .getByRole("option", { name: "Test message with delivery failure" }) .first() .click(); @@ -144,7 +144,7 @@ test.describe("Delivery failures", () => { await page.waitForLoadState("networkidle"); await page - .getByRole("link", { name: "Test message with delivery failure" }) + .getByRole("option", { name: "Test message with delivery failure" }) .first() .click(); @@ -180,7 +180,7 @@ test.describe("Delivery failures", () => { await page.waitForLoadState("networkidle"); await page - .getByRole("link", { name: "Test message with delivery failure" }) + .getByRole("option", { name: "Test message with delivery failure" }) .first() .click(); @@ -225,7 +225,7 @@ test.describe("Delivery pending (retry only)", () => { await page.waitForLoadState("networkidle"); await page - .getByRole("link", { name: "Test message with pending delivery" }) + .getByRole("option", { name: "Test message with pending delivery" }) .first() .click(); @@ -258,7 +258,7 @@ test.describe("Delivery pending (retry only)", () => { await page.waitForLoadState("networkidle"); await page - .getByRole("link", { name: "Test message with pending delivery" }) + .getByRole("option", { name: "Test message with pending delivery" }) .first() .click(); diff --git a/src/e2e/src/__tests__/thread-event.spec.ts b/src/e2e/src/__tests__/thread-event.spec.ts index d972ac95..2aba2070 100644 --- a/src/e2e/src/__tests__/thread-event.spec.ts +++ b/src/e2e/src/__tests__/thread-event.spec.ts @@ -42,7 +42,7 @@ async function navigateToSharedThread(page: Page, browserName: BrowserName) { await inboxFolderLink(page).click(); await page.waitForLoadState("networkidle"); await page - .getByRole("link", { name: "Shared inbox thread for IM" }) + .getByRole("option", { name: "Shared inbox thread for IM" }) .first() .click(); await page @@ -93,7 +93,7 @@ test.describe("Thread Events (Internal Messages)", () => { await page.waitForLoadState("networkidle"); await page - .getByRole("link", { name: "Inbox thread alpha" }) + .getByRole("option", { name: "Inbox thread alpha" }) .first() .click(); await page @@ -489,7 +489,7 @@ test.describe("Thread Events (Internal Messages)", () => { // "Unread mention" badge. The thread list is re-fetched on navigation, // which makes it the most reliable indicator that the mention landed. const threadLink = page - .getByRole("link", { name: "Shared inbox thread for IM" }) + .getByRole("option", { name: "Shared inbox thread for IM" }) .first(); await expect( threadLink.getByLabel("Unread mention").first(), @@ -591,7 +591,7 @@ test.describe("Thread Events (Assignations)", () => { await inboxFolderLink(page).click(); await page.waitForLoadState("networkidle"); await page - .getByRole("link", { name: "Inbox thread alpha" }) + .getByRole("option", { name: "Inbox thread alpha" }) .first() .click(); await page @@ -778,7 +778,7 @@ test.describe("Thread Events (Assignations)", () => { await page.waitForLoadState("networkidle"); await expect( - page.getByRole("link", { name: "Shared inbox thread for IM" }).first(), + page.getByRole("option", { name: "Shared inbox thread for IM" }).first(), ).toBeVisible(); // Cleanup: drop the assignment so the test is rerun-safe even when diff --git a/src/e2e/src/__tests__/thread-starred-read.spec.ts b/src/e2e/src/__tests__/thread-starred-read.spec.ts index ece5079f..37d29b75 100644 --- a/src/e2e/src/__tests__/thread-starred-read.spec.ts +++ b/src/e2e/src/__tests__/thread-starred-read.spec.ts @@ -25,7 +25,7 @@ test.describe("Thread starred", () => { // Open the first thread await page - .getByRole("link", { name: "Test message with delivery failure" }) + .getByRole("option", { name: "Test message with delivery failure" }) .first() .click(); await page @@ -63,7 +63,7 @@ test.describe("Thread starred", () => { // Open the thread (starred from previous test) await page - .getByRole("link", { name: "Test message with delivery failure" }) + .getByRole("option", { name: "Test message with delivery failure" }) .first() .click(); await page @@ -112,7 +112,7 @@ test.describe("Thread read / unread", () => { // Open the thread (the IntersectionObserver auto-marks messages as read) await page - .getByRole("link", { name: "Inbox thread alpha" }) + .getByRole("option", { name: "Inbox thread alpha" }) .first() .click(); await page @@ -154,15 +154,15 @@ test.describe("Thread read / unread", () => { // Both threads should be visible (both unread) await expect( - page.getByRole("link", { name: "Inbox thread alpha" }).first(), + page.getByRole("option", { name: "Inbox thread alpha" }).first(), ).toBeVisible(); await expect( - page.getByRole("link", { name: "Inbox thread beta" }).first(), + page.getByRole("option", { name: "Inbox thread beta" }).first(), ).toBeVisible(); // Open a thread — the IntersectionObserver auto-marks it as read await page - .getByRole("link", { name: "Inbox thread alpha" }) + .getByRole("option", { name: "Inbox thread alpha" }) .first() .click(); await page @@ -176,7 +176,7 @@ test.describe("Thread read / unread", () => { // The thread should still be visible in the list thanks to thread pinning logic // Check @/features/providers/mailbox-cache.ts await expect( - page.getByRole("link", { name: "Inbox thread alpha" }).first(), + page.getByRole("option", { name: "Inbox thread alpha" }).first(), ).toBeVisible(); }); @@ -200,15 +200,15 @@ test.describe("Thread read / unread", () => { // Verify both threads are visible initially await expect( - page.getByRole("link", { name: "Inbox thread alpha" }).first(), + page.getByRole("option", { name: "Inbox thread alpha" }).first(), ).toBeVisible(); await expect( - page.getByRole("link", { name: "Inbox thread beta" }).first(), + page.getByRole("option", { name: "Inbox thread beta" }).first(), ).toBeVisible(); // Open the first thread to mark it as read (IntersectionObserver auto-read) await page - .getByRole("link", { name: "Inbox thread alpha" }) + .getByRole("option", { name: "Inbox thread alpha" }) .first() .click(); await page @@ -232,12 +232,12 @@ test.describe("Thread read / unread", () => { // The read thread should be filtered out await expect( - page.getByRole("link", { name: "Inbox thread alpha" }), + page.getByRole("option", { name: "Inbox thread alpha" }), ).not.toBeVisible(); // The unread thread (not opened) should still be visible await expect( - page.getByRole("link", { name: "Inbox thread beta" }).first(), + page.getByRole("option", { name: "Inbox thread beta" }).first(), ).toBeVisible(); // Click the filter button again to clear the filter @@ -246,10 +246,10 @@ test.describe("Thread read / unread", () => { // Both threads should be visible again await expect( - page.getByRole("link", { name: "Inbox thread alpha" }).first(), + page.getByRole("option", { name: "Inbox thread alpha" }).first(), ).toBeVisible(); await expect( - page.getByRole("link", { name: "Inbox thread beta" }).first(), + page.getByRole("option", { name: "Inbox thread beta" }).first(), ).toBeVisible(); }); }); diff --git a/src/frontend/public/locales/common/en-US.json b/src/frontend/public/locales/common/en-US.json index 881a83d5..ec4c5878 100755 --- a/src/frontend/public/locales/common/en-US.json +++ b/src/frontend/public/locales/common/en-US.json @@ -324,7 +324,6 @@ "Description": "Description", "Description must be less than 255 characters.": "Description must be less than 255 characters.", "Deselect all threads": "Deselect all threads", - "Deselect thread": "Deselect thread", "Did you forget an attachment?": "Did you forget an attachment?", "Disable thread selection": "Disable thread selection", "Display those images": "Display those images", @@ -654,7 +653,6 @@ "Select a thread": "Select a thread", "Select all threads": "Select all threads", "Select the mailbox to configure": "Select the mailbox to configure", - "Select thread": "Select thread", "Select threads": "Select threads", "Send": "Send", "Send and archive": "Send and archive", @@ -774,6 +772,7 @@ "This will move this message and all following messages to a new thread. Continue?": "This will move this message and all following messages to a new thread. Continue?", "Thread access removed": "Thread access removed", "Thread has been split successfully.": "Thread has been split successfully.", + "Thread list": "Thread list", "Thursday": "Thursday", "Timezone": "Timezone", "To": "To", diff --git a/src/frontend/public/locales/common/fr-FR.json b/src/frontend/public/locales/common/fr-FR.json index 99178c14..4ddd62ed 100755 --- a/src/frontend/public/locales/common/fr-FR.json +++ b/src/frontend/public/locales/common/fr-FR.json @@ -394,7 +394,6 @@ "Description": "Description", "Description must be less than 255 characters.": "La description ne peut pas excéder 255 caractères.", "Deselect all threads": "Désélectionner toutes les conversations", - "Deselect thread": "Désélectionner la conversation", "Did you forget an attachment?": "N'avez-vous pas oublié une pièce jointe ?", "Disable thread selection": "Désactiver la sélection", "Display those images": "Afficher ces images", @@ -735,7 +734,6 @@ "Select a thread": "Sélectionner une conversation", "Select all threads": "Sélectionner toutes les conversations", "Select the mailbox to configure": "Sélectionner la boîte aux lettres à configurer", - "Select thread": "Sélectionner une conversation", "Select threads": "Sélectionner des conversations", "Send": "Envoyer", "Send and archive": "Envoyer et archiver", @@ -859,6 +857,7 @@ "This will move this message and all following messages to a new thread. Continue?": "Cela déplacera ce message et tous les messages suivants dans une nouvelle conversation. Continuer ?", "Thread access removed": "Accès à la conversation supprimé", "Thread has been split successfully.": "La conversation a été séparée avec succès.", + "Thread list": "Liste des conversations", "Thursday": "Jeudi", "Timezone": "Fuseau horaire", "To": "À", diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss index 7790ccec..7143432c 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss @@ -34,6 +34,14 @@ border-color: var(--c--contextuals--border--semantic--neutral--tertiary); } +// Keyboard focus ring, drawn inward so the scroll container does not clip +// it. Outline does not conflict with the state backgrounds, so it stays +// visible on selected and active rows too. +.thread-item:focus-visible { + outline: 2px solid var(--c--contextuals--border--focus); + outline-offset: -2px; +} + .thread-item.thread-item--active { background-color: var(--c--contextuals--background--semantic--brand--tertiary); border-color: var(--c--contextuals--border--semantic--brand--tertiary); @@ -78,6 +86,16 @@ flex: 1; } +// This wrapper carries aria-hidden="true", so it must stay a real box in the +// layout. `display: contents` is handled inconsistently across browser/AT +// combinations and can drop the aria-hidden boundary, leaking the checkbox +// semantics into the parent role="option". inline-flex shrink-wraps the +// checkbox so the visual layout stays identical. +.thread-item__checkbox-wrapper { + display: inline-flex; + flex-shrink: 0; +} + .thread-item__checkbox { flex-shrink: 0; margin: 0; diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx index d72c376a..9d20901a 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx @@ -16,16 +16,18 @@ import { LabelBadge } from "@/features/ui/components/label-badge" import { useLayoutDragContext } from "@/features/layouts/components/layout-context" import ViewHelper from "@/features/utils/view-helper" import useCanEditThreads from "@/features/message/use-can-edit-threads" +import { ThreadListboxItemProps } from "../../hooks/use-thread-listbox" type ThreadItemProps = { thread: Thread isSelected: boolean - onToggleSelection: (threadId: string, shiftKey: boolean, ctrlKey: boolean, arrowUpKey?: 'up' | 'down') => void + onToggle: (threadId: string) => void + onSelectRange: (threadId: string) => void selectedThreadIds: Set isSelectionMode: boolean -} +} & ThreadListboxItemProps -export const ThreadItem = ({ thread, isSelected, onToggleSelection, selectedThreadIds, isSelectionMode }: ThreadItemProps) => { +export const ThreadItem = ({ thread, isSelected, onToggle, onSelectRange, selectedThreadIds, isSelectionMode, tabIndex, itemRef, onFocusItem }: ThreadItemProps) => { const { t, i18n } = useTranslation(); const params = useParams({ strict: false }) as { mailboxId?: string; threadId?: string } const [isDragging, setIsDragging] = useState(false) @@ -76,28 +78,31 @@ export const ThreadItem = ({ thread, isSelected, onToggleSelection, selectedThre const handleCheckboxClick = (e: React.MouseEvent) => { e.stopPropagation(); - onToggleSelection(thread.id, e.shiftKey, e.ctrlKey || e.metaKey); + e.preventDefault(); + onFocusItem(); + if (e.shiftKey) { + onSelectRange(thread.id); + } else { + onToggle(thread.id); + } }; const handleItemClick = (e: React.MouseEvent) => { - // If using modifier keys or in selection mode, toggle selection instead of navigating - if (e.shiftKey || e.ctrlKey || e.metaKey || hasSelection) { + onFocusItem(); + // Keyboard activation (Enter on the focused option) fires a click + // with detail === 0: let it navigate even while a selection is + // active, it is the only pointer-free way to open a thread. + if (e.detail === 0) return; + if (e.shiftKey) { e.preventDefault(); - onToggleSelection(thread.id, e.shiftKey, e.ctrlKey || e.metaKey); + onSelectRange(thread.id); + } else if (e.ctrlKey || e.metaKey || hasSelection) { + e.preventDefault(); + onToggle(thread.id); } // Otherwise, let the Link handle navigation normally }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (!hasSelection) return; - const arrowUpKey = e.key === 'ArrowUp'; - const arrowDownKey = e.key === 'ArrowDown'; - if (e.shiftKey && (arrowUpKey || arrowDownKey)) { - e.preventDefault(); - onToggleSelection(thread.id, e.shiftKey, e.ctrlKey || e.metaKey, arrowUpKey ? 'up' : 'down'); - } - }; - const handleDragStart = (e: React.DragEvent) => { setIsDragging(true) setGlobalDragging(true) @@ -159,23 +164,30 @@ export const ThreadItem = ({ thread, isSelected, onToggleSelection, selectedThre 'thread-item--selected': isSelected, }, )} - data-thread-id={thread.id} data-unread={hasUnread} draggable onDragStart={handleDragStart} onDragEnd={handleDragEnd} onClick={handleItemClick} - onKeyDown={handleKeyDown} - tabIndex={0} + onFocus={onFocusItem} + tabIndex={tabIndex} + ref={itemRef} + role="option" + aria-selected={isSelected} >
{showCheckbox && ( - + // Mouse-only affordance: the option itself carries the + // selection state (aria-selected), a focusable checkbox + // would add a second tab stop inside the option. + )}
diff --git a/src/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.test.ts b/src/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.test.ts new file mode 100644 index 00000000..a78ac663 --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { getNextFocusId, isListboxNavKey } from "./listbox-navigation"; + +const threads = ['t1', 't2', 't3'].map((id) => ({ id })); + +describe('getNextFocusId', () => { + it('moves focus down', () => { + expect(getNextFocusId(threads, 't1', 'ArrowDown')).toBe('t2'); + }); + + it('moves focus up', () => { + expect(getNextFocusId(threads, 't3', 'ArrowUp')).toBe('t2'); + }); + + it('clamps at the bottom edge', () => { + expect(getNextFocusId(threads, 't3', 'ArrowDown')).toBe('t3'); + }); + + it('clamps at the top edge', () => { + expect(getNextFocusId(threads, 't1', 'ArrowUp')).toBe('t1'); + }); + + it('jumps to the first thread on Home', () => { + expect(getNextFocusId(threads, 't3', 'Home')).toBe('t1'); + }); + + it('jumps to the last loaded thread on End', () => { + expect(getNextFocusId(threads, 't1', 'End')).toBe('t3'); + }); + + it('falls back to the first thread when no thread is focused', () => { + expect(getNextFocusId(threads, null, 'ArrowDown')).toBe('t1'); + }); + + it('falls back to the first thread when the focused thread left the list', () => { + expect(getNextFocusId(threads, 'gone', 'ArrowUp')).toBe('t1'); + }); + + it('returns null on an empty list', () => { + expect(getNextFocusId([], 't1', 'ArrowDown')).toBeNull(); + expect(getNextFocusId([], null, 'Home')).toBeNull(); + }); +}); + +describe('isListboxNavKey', () => { + it('accepts navigation keys', () => { + expect(isListboxNavKey('ArrowUp')).toBe(true); + expect(isListboxNavKey('ArrowDown')).toBe(true); + expect(isListboxNavKey('Home')).toBe(true); + expect(isListboxNavKey('End')).toBe(true); + }); + + it('rejects other keys', () => { + expect(isListboxNavKey('Enter')).toBe(false); + expect(isListboxNavKey(' ')).toBe(false); + expect(isListboxNavKey('a')).toBe(false); + }); +}); diff --git a/src/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.ts b/src/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.ts new file mode 100644 index 00000000..8b1ddbde --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.ts @@ -0,0 +1,36 @@ +/** + * Pure keyboard-navigation logic for the thread listbox. + * Kept framework-free so edge clamping can be unit-tested without React. + */ + +type ThreadRef = { id: string }; + +export type ListboxNavKey = 'ArrowUp' | 'ArrowDown' | 'Home' | 'End'; + +export const LISTBOX_NAV_KEYS: ReadonlyArray = ['ArrowUp', 'ArrowDown', 'Home', 'End']; + +export const isListboxNavKey = (key: string): key is ListboxNavKey => + (LISTBOX_NAV_KEYS as ReadonlyArray).includes(key); + +/** + * Resolve the thread that should receive focus after a navigation key. + * Arrow keys clamp at the list edges (no wrap-around). + * @returns the id of the thread to focus, or null when the list is empty + */ +export const getNextFocusId = ( + threads: ThreadRef[], + currentId: string | null, + key: ListboxNavKey, +): string | null => { + if (threads.length === 0) return null; + if (key === 'Home') return threads[0].id; + if (key === 'End') return threads[threads.length - 1].id; + + const currentIndex = currentId ? threads.findIndex((thread) => thread.id === currentId) : -1; + if (currentIndex === -1) return threads[0].id; + + const nextIndex = key === 'ArrowUp' + ? Math.max(0, currentIndex - 1) + : Math.min(threads.length - 1, currentIndex + 1); + return threads[nextIndex].id; +}; diff --git a/src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-listbox.ts b/src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-listbox.ts new file mode 100644 index 00000000..4dbc7350 --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-listbox.ts @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useRef } from "react"; +import { Thread } from "@/features/api/gen/models/thread"; +import { useThreadSelection } from "@/features/providers/thread-selection"; +import { useThreadListboxFocus } from "@/features/providers/thread-listbox-focus"; +import { getNextFocusId, isListboxNavKey } from "./listbox-navigation"; + +export type ThreadListboxItemProps = { + tabIndex: 0 | -1; + itemRef: (node: HTMLAnchorElement | null) => void; + onFocusItem: () => void; +}; + +/** + * Owns keyboard focus for the thread listbox (the selection provider owns + * what is selected): roving tabindex, arrow/Home/End navigation, Space + * toggle and Shift+Arrow range expansion. Enter needs no handling here — + * the focused anchor fires a native click (detail === 0) that the item's + * click handler lets through to navigation. + * + * The focus state itself lives in ThreadListboxFocusProvider because the + * ThreadPanel (and this hook with it) is remounted on route transitions; + * only the DOM refs map is local, it is rebuilt on every mount. + */ +export const useThreadListbox = (threads: Thread[] | undefined) => { + const { toggleThread, selectRange } = useThreadSelection(); + const { focusedThreadId, setFocusedThreadId, ownsFocusRef, lastFocusedIndexRef } = useThreadListboxFocus(); + const itemRefs = useRef(new Map()); + + const firstThreadId = threads?.[0]?.id ?? null; + + const getItemProps = useCallback((threadId: string): ThreadListboxItemProps => ({ + // Roving tabindex: a single tab stop, falling back to the first + // thread while nothing has been focused yet. + tabIndex: (focusedThreadId ?? firstThreadId) === threadId ? 0 : -1, + itemRef: (node: HTMLAnchorElement | null) => { + if (node) { + itemRefs.current.set(threadId, node); + } else { + itemRefs.current.delete(threadId); + } + }, + // Syncs state when focus arrives via Tab or click, and aligns DOM + // focus on the option anchor: Safari does not focus anchors on + // pointer click, and Chrome focuses the (aria-hidden) checkbox + // input on mousedown — both would leave Arrow/Home/End dead even + // though the roving state points at this item. The guard makes the + // call idempotent when invoked from the anchor's own focus event. + onFocusItem: () => { + ownsFocusRef.current = true; + setFocusedThreadId(threadId); + const node = itemRefs.current.get(threadId); + if (node && document.activeElement !== node) { + node.focus({ preventScroll: true }); + } + }, + }), [focusedThreadId, firstThreadId, setFocusedThreadId, ownsFocusRef]); + + // The list stops owning focus when it explicitly moves elsewhere. A blur + // with no relatedTarget (click on a non-focusable area) counts too — + // crucially, browsers fire NO blur at all when the focused node is + // unmounted, moved or recreated, which is how silent focus loss is told + // apart from a deliberate focus change. + const onBlur = useCallback((e: React.FocusEvent) => { + if (!(e.relatedTarget instanceof Node) || !e.currentTarget.contains(e.relatedTarget)) { + ownsFocusRef.current = false; + } + }, [ownsFocusRef]); + + // Restore focus dropped on when the focused item's node was + // unmounted, moved or recreated: list reorders (mark-as-read patch, + // pinned-threads merge) and full ThreadPanel remounts on route + // transitions. Without this, keyboard navigation dies after opening a + // thread. preventScroll: useScrollRestore already restores the list + // scroll position on remount, a focus-triggered scroll would fight it. + useEffect(() => { + if (!ownsFocusRef.current) return; + if (document.activeElement !== document.body) return; + const targetId = focusedThreadId ?? firstThreadId; + if (!targetId) return; + itemRefs.current.get(targetId)?.focus({ preventScroll: true }); + }); + + const onKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!threads?.length) return; + + if (e.key === ' ') { + if (!focusedThreadId) return; + // Prevent page scroll on Space + e.preventDefault(); + toggleThread(focusedThreadId); + return; + } + + if (!isListboxNavKey(e.key) || e.ctrlKey || e.metaKey || e.altKey) return; + + const nextId = getNextFocusId(threads, focusedThreadId, e.key); + if (!nextId) return; + e.preventDefault(); + + const previousFocusId = focusedThreadId ?? undefined; + setFocusedThreadId(nextId); + const node = itemRefs.current.get(nextId); + node?.focus({ preventScroll: true }); + node?.scrollIntoView({ block: 'nearest' }); + + if (e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { + // Finder-like expansion: the previous focus seeds the anchor + // when no anchor exists yet. + selectRange(nextId, previousFocusId); + } + }, [threads, focusedThreadId, toggleThread, selectRange, setFocusedThreadId]); + + // Keep the focused thread valid across refetch/prune: when it leaves + // the list, clamp to the same index. The restore effect above takes + // care of re-anchoring DOM focus after the state settles. + useEffect(() => { + if (!threads || focusedThreadId === null) return; + + const index = threads.findIndex((thread) => thread.id === focusedThreadId); + if (index !== -1) { + lastFocusedIndexRef.current = index; + return; + } + + if (threads.length === 0) { + setFocusedThreadId(null); + return; + } + + const clampedIndex = Math.min(lastFocusedIndexRef.current, threads.length - 1); + setFocusedThreadId(threads[clampedIndex].id); + }, [threads, focusedThreadId, setFocusedThreadId, lastFocusedIndexRef]); + + return { focusedThreadId, getItemProps, onKeyDown, onBlur }; +}; diff --git a/src/frontend/src/features/layouts/components/thread-panel/index.tsx b/src/frontend/src/features/layouts/components/thread-panel/index.tsx index 664f48d0..f48104dd 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-panel/index.tsx @@ -10,6 +10,7 @@ import ThreadPanelHeader from "./components/thread-panel-header"; import { useThreadSelection } from "@/features/providers/thread-selection"; import { useScrollRestore } from "@/features/providers/scroll-restore"; import { useThreadPanelFilters } from "./hooks/use-thread-panel-filters"; +import { useThreadListbox } from "./hooks/use-thread-listbox"; export const ThreadPanel = () => { const { threads, queryStates, unselectThread, loadNextThreads, selectedThread, selectedMailbox } = useMailboxContext(); @@ -26,7 +27,8 @@ export const ThreadPanel = () => { const { selectedThreadIds, isSelectionMode, - toggleThreadSelection, + toggleThread, + selectRange, selectAllThreads, clearSelection, enableSelectionMode, @@ -36,6 +38,8 @@ export const ThreadPanel = () => { selectionStarredStatus, } = useThreadSelection(); + const { getItemProps, onKeyDown: handleListboxKeyDown, onBlur: handleListboxBlur } = useThreadListbox(threads?.results); + const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => { const target = entries[0]; if (target.isIntersecting && threads?.next && !queryStates.threads.isFetchingNextPage) { @@ -114,15 +118,26 @@ export const ThreadPanel = () => {
) : ( -
+
{threads?.results.map((thread) => ( ))} {threads!.next && ( diff --git a/src/frontend/src/features/providers/thread-listbox-focus.tsx b/src/frontend/src/features/providers/thread-listbox-focus.tsx new file mode 100644 index 00000000..c74058e8 --- /dev/null +++ b/src/frontend/src/features/providers/thread-listbox-focus.tsx @@ -0,0 +1,46 @@ +import { createContext, Dispatch, PropsWithChildren, SetStateAction, useContext, useMemo, useRef, useState } from "react"; + +interface ThreadListboxFocusState { + focusedThreadId: string | null; + setFocusedThreadId: Dispatch>; + /** Whether the listbox currently owns keyboard focus. */ + ownsFocusRef: { current: boolean }; + /** Index of the last focused thread, used to clamp focus after prune. */ + lastFocusedIndexRef: { current: number }; +} + +const ThreadListboxFocusContext = createContext(null); + +/** + * Holds the thread listbox roving-focus state outside the ThreadPanel + * component: the panel is remounted on every route transition between + * "no thread open" and "thread open" (they are two distinct routes each + * mounting their own ThreadPanel), which would otherwise reset the + * focused thread and break keyboard navigation after opening a thread. + */ +export const ThreadListboxFocusProvider = ({ children }: PropsWithChildren) => { + const [focusedThreadId, setFocusedThreadId] = useState(null); + const ownsFocusRef = useRef(false); + const lastFocusedIndexRef = useRef(0); + + const value = useMemo(() => ({ + focusedThreadId, + setFocusedThreadId, + ownsFocusRef, + lastFocusedIndexRef, + }), [focusedThreadId]); + + return ( + + {children} + + ); +}; + +export const useThreadListboxFocus = () => { + const context = useContext(ThreadListboxFocusContext); + if (!context) { + throw new Error("useThreadListboxFocus must be used within a ThreadListboxFocusProvider"); + } + return context; +}; diff --git a/src/frontend/src/features/providers/thread-selection-core.test.ts b/src/frontend/src/features/providers/thread-selection-core.test.ts new file mode 100644 index 00000000..e85760df --- /dev/null +++ b/src/frontend/src/features/providers/thread-selection-core.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + computeRange, + computeToggle, + pruneSelection, + resolveAnchorIndex, +} from "./thread-selection-core"; + +const threads = ['t1', 't2', 't3', 't4', 't5'].map((id) => ({ id })); + +describe('computeToggle', () => { + it('adds an unselected thread without affecting the others', () => { + const next = computeToggle(new Set(['t1', 't2']), 't4'); + expect(next).toEqual(new Set(['t1', 't2', 't4'])); + }); + + it('removes a selected thread without affecting the others', () => { + const next = computeToggle(new Set(['t1', 't2', 't4']), 't2'); + expect(next).toEqual(new Set(['t1', 't4'])); + }); + + it('does not mutate the previous selection', () => { + const prev = new Set(['t1']); + computeToggle(prev, 't2'); + expect(prev).toEqual(new Set(['t1'])); + }); + + it('selects a thread when the selection is empty', () => { + expect(computeToggle(new Set(), 't3')).toEqual(new Set(['t3'])); + }); + + it('empties the selection when toggling off the last thread', () => { + expect(computeToggle(new Set(['t3']), 't3')).toEqual(new Set()); + }); +}); + +describe('resolveAnchorIndex', () => { + it('uses the current anchor when it is still in the list', () => { + expect(resolveAnchorIndex(threads, 4, 't2', 't3', 't1')).toBe(1); + }); + + it('falls back to the fallback anchor when the anchor is gone', () => { + expect(resolveAnchorIndex(threads, 4, 'gone', 't3', 't1')).toBe(2); + }); + + it('seeds from the open thread when no anchor is usable', () => { + expect(resolveAnchorIndex(threads, 4, null, undefined, 't1')).toBe(0); + }); + + it('anchors on the target itself as a last resort', () => { + expect(resolveAnchorIndex(threads, 3, null)).toBe(3); + expect(resolveAnchorIndex(threads, 3, 'gone', 'gone-too', 'gone-as-well')).toBe(3); + }); +}); + +describe('computeRange', () => { + it('selects the inclusive range between anchor and target', () => { + expect(computeRange(threads, 1, 3)).toEqual(new Set(['t2', 't3', 't4'])); + }); + + it('handles a reversed range (target above anchor)', () => { + expect(computeRange(threads, 3, 1)).toEqual(new Set(['t2', 't3', 't4'])); + }); + + it('selects a single thread when anchor and target match', () => { + expect(computeRange(threads, 2, 2)).toEqual(new Set(['t3'])); + }); +}); + +describe('pruneSelection', () => { + it('drops ids that left the thread list', () => { + const pruned = pruneSelection(new Set(['t1', 'gone', 't3']), threads); + expect(pruned).toEqual(new Set(['t1', 't3'])); + }); + + it('returns the same reference when nothing changed', () => { + const prev = new Set(['t1', 't3']); + expect(pruneSelection(prev, threads)).toBe(prev); + }); + + it('returns the same reference when the selection is empty', () => { + const prev = new Set(); + expect(pruneSelection(prev, [])).toBe(prev); + }); + + it('prunes to empty when no selected thread remains', () => { + expect(pruneSelection(new Set(['gone']), threads)).toEqual(new Set()); + }); +}); diff --git a/src/frontend/src/features/providers/thread-selection-core.ts b/src/frontend/src/features/providers/thread-selection-core.ts new file mode 100644 index 00000000..eef05373 --- /dev/null +++ b/src/frontend/src/features/providers/thread-selection-core.ts @@ -0,0 +1,77 @@ +/** + * Pure selection logic for the thread multi-selection feature. + * + * These helpers are framework-free so the selection semantics (additive + * toggle, range anchoring, pruning) can be unit-tested without React. + */ + +type ThreadRef = { id: string }; + +/** + * Additively toggle a thread in/out of the selection without affecting + * the other selected threads. + * @returns a new Set with the thread added or removed + */ +export const computeToggle = (prev: Set, threadId: string): Set => { + const next = new Set(prev); + if (next.has(threadId)) { + next.delete(threadId); + } else { + next.add(threadId); + } + return next; +}; + +/** + * Resolve the index of the range-selection anchor. + * + * Resolution order: the current anchor (if still in the list), then the + * provided fallback (e.g. the previously focused thread), then the thread + * currently opened in the view, then the target itself. + * + * @param threads ordered list of visible threads + * @param targetIndex index of the thread the range extends to + * @param anchorId id of the current selection anchor, if any + * @param fallbackAnchorId id used to seed the anchor when none is set + * @param openThreadId id of the thread opened in the thread view, if any + * @returns the index to anchor the range on + */ +export const resolveAnchorIndex = ( + threads: ThreadRef[], + targetIndex: number, + anchorId: string | null, + fallbackAnchorId?: string, + openThreadId?: string, +): number => { + for (const candidate of [anchorId, fallbackAnchorId, openThreadId]) { + if (!candidate) continue; + const index = threads.findIndex((thread) => thread.id === candidate); + if (index !== -1) return index; + } + return targetIndex; +}; + +/** + * Build the selection covering the inclusive range between two indices. + * @returns a new Set containing exactly the threads in the range + */ +export const computeRange = ( + threads: ThreadRef[], + anchorIndex: number, + targetIndex: number, +): Set => { + const start = Math.min(anchorIndex, targetIndex); + const end = Math.max(anchorIndex, targetIndex); + return new Set(threads.slice(start, end + 1).map((thread) => thread.id)); +}; + +/** + * Drop selected ids that no longer exist in the thread list. + * @returns the same Set reference when nothing changed (to avoid re-renders) + */ +export const pruneSelection = (prev: Set, threads: ThreadRef[]): Set => { + if (prev.size === 0) return prev; + const threadIds = new Set(threads.map((thread) => thread.id)); + const pruned = new Set([...prev].filter((id) => threadIds.has(id))); + return pruned.size === prev.size ? prev : pruned; +}; diff --git a/src/frontend/src/features/providers/thread-selection.tsx b/src/frontend/src/features/providers/thread-selection.tsx index becf2709..460589a9 100644 --- a/src/frontend/src/features/providers/thread-selection.tsx +++ b/src/frontend/src/features/providers/thread-selection.tsx @@ -2,6 +2,7 @@ import { createContext, PropsWithChildren, useCallback, useContext, useEffect, u import { useUrlSearchParams } from "@/hooks/use-url-search-params"; import { useMailboxContext } from "./mailbox"; import { Thread } from "@/features/api/gen/models/thread"; +import { computeRange, computeToggle, pruneSelection, resolveAnchorIndex } from "./thread-selection-core"; export enum SelectionReadStatus { NONE = 'none', @@ -19,12 +20,8 @@ export enum SelectionStarredStatus { interface ThreadSelectionState { selectedThreadIds: Set; isSelectionMode: boolean; - toggleThreadSelection: ( - threadId: string, - shiftKey?: boolean, - ctrlKey?: boolean, - arrowUpKey?: 'up' | 'down' - ) => void; + toggleThread: (threadId: string) => void; + selectRange: (threadId: string, fallbackAnchorId?: string) => void; selectAllThreads: () => void; clearSelection: () => void; enableSelectionMode: () => void; @@ -40,118 +37,42 @@ const useThreadSelectionState = (threads: Thread[] | undefined, selectedThread: const searchParams = useUrlSearchParams(); const [selectedThreadIds, setSelectedThreadIds] = useState>(new Set()); const [isSelectionMode, setIsSelectionMode] = useState(false); - const lastActiveThreadIdRef = useRef(null); const anchorThreadIdRef = useRef(null); - const focusThreadIdRef = useRef(null); - const toggleThreadSelection = useCallback(( - threadId: string, - shiftKey: boolean = false, - ctrlKey: boolean = false, - arrowUpKey?: 'up' | 'down' - ) => { + /** + * Additively toggle a thread in/out of the selection. The toggled + * thread becomes the anchor for subsequent range selections. + * Selection mode stays on even when the selection empties, so the + * bulk-action header does not flicker mid-interaction. + */ + const toggleThread = useCallback((threadId: string) => { + setSelectedThreadIds((prev) => computeToggle(prev, threadId)); + anchorThreadIdRef.current = threadId; + setIsSelectionMode(true); + }, []); + + /** + * Select the range between the current anchor and the given thread. + * Successive range selections pivot from the same anchor. + * @param fallbackAnchorId seeds the anchor when none is set (e.g. the + * previously focused thread during Shift+Arrow keyboard expansion) + */ + const selectRange = useCallback((threadId: string, fallbackAnchorId?: string) => { if (!threads) return; + const targetIndex = threads.findIndex((thread) => thread.id === threadId); + if (targetIndex === -1) return; - setSelectedThreadIds((prev) => { - let newSet: Set; - - if (shiftKey && arrowUpKey) { - // Shift+Arrow key: macOS Finder-like behavior - if (anchorThreadIdRef.current === null || focusThreadIdRef.current === null) { - if (prev.size > 0) { - const firstSelectedId = Array.from(prev)[0]; - anchorThreadIdRef.current = firstSelectedId; - focusThreadIdRef.current = firstSelectedId; - } else { - anchorThreadIdRef.current = threadId; - focusThreadIdRef.current = threadId; - } - } - - const currentFocusIndex = threads.findIndex((t) => t.id === focusThreadIdRef.current); - if (currentFocusIndex === -1) { - // Focused thread was removed from list, reset to current thread - focusThreadIdRef.current = threadId; - anchorThreadIdRef.current = threadId; - newSet = new Set([threadId]); - } else { - let newFocusIndex = currentFocusIndex; - if (arrowUpKey === 'up' && newFocusIndex > 0) { - newFocusIndex = newFocusIndex - 1; - } else if (arrowUpKey === 'down' && newFocusIndex < threads.length - 1) { - newFocusIndex = newFocusIndex + 1; - } - - const newFocusThreadId = threads[newFocusIndex].id; - focusThreadIdRef.current = newFocusThreadId; - - const anchorIndex = threads.findIndex((t) => t.id === anchorThreadIdRef.current); - const effectiveAnchorIndex = anchorIndex !== -1 ? anchorIndex : newFocusIndex; - const start = Math.min(effectiveAnchorIndex, newFocusIndex); - const end = Math.max(effectiveAnchorIndex, newFocusIndex); - const range = threads.slice(start, end + 1); - newSet = new Set(range.map((thread) => thread.id)); - - setTimeout(() => { - const threadItem = document.querySelector(`[data-thread-id="${newFocusThreadId}"]`); - threadItem?.focus(); - }, 0); - } - } - else if (shiftKey) { - // Shift+Click: range selection - const index = threads.findIndex((t) => t.id === threadId); - let anchorIndex: number; - - if (lastActiveThreadIdRef.current !== null) { - const foundIndex = threads.findIndex((t) => t.id === lastActiveThreadIdRef.current); - anchorIndex = foundIndex !== -1 ? foundIndex : index; - } else if (selectedThread) { - const activeThreadIndex = threads.findIndex((t) => t.id === selectedThread.id); - anchorIndex = activeThreadIndex !== -1 ? activeThreadIndex : index; - lastActiveThreadIdRef.current = selectedThread.id; - } else { - anchorIndex = index; - lastActiveThreadIdRef.current = threadId; - } - - anchorThreadIdRef.current = threads[anchorIndex]?.id ?? null; - focusThreadIdRef.current = threadId; - - const start = Math.min(anchorIndex, index); - const end = Math.max(anchorIndex, index); - const range = threads.slice(start, end + 1); - newSet = new Set(range.map((thread) => thread.id)); - } else if (ctrlKey) { - // Ctrl/Cmd+Click: toggle individual without affecting others - newSet = new Set(prev); - if (newSet.has(threadId)) { - newSet.delete(threadId); - } else { - newSet.add(threadId); - } - lastActiveThreadIdRef.current = threadId; - focusThreadIdRef.current = threadId; - } else { - // Normal click: if already selected, unselect it; otherwise, clear others and select only this one - if (prev.has(threadId)) { - newSet = new Set(prev); - newSet.delete(threadId); - } else { - newSet = new Set([threadId]); - } - lastActiveThreadIdRef.current = threadId; - anchorThreadIdRef.current = threadId; - focusThreadIdRef.current = threadId; - } - - if (newSet.size > 0) { - setIsSelectionMode(true); - } - - return newSet; - }); - }, [threads, selectedThread]); + const anchorIndex = resolveAnchorIndex( + threads, + targetIndex, + selectedThreadIds.size > 0 ? anchorThreadIdRef.current : null, + fallbackAnchorId, + selectedThread?.id, + ); + anchorThreadIdRef.current = threads[anchorIndex].id; + setSelectedThreadIds(computeRange(threads, anchorIndex, targetIndex)); + setIsSelectionMode(true); + }, [threads, selectedThread, selectedThreadIds.size]); const selectAllThreads = useCallback(() => { if (!threads) return; @@ -162,9 +83,7 @@ const useThreadSelectionState = (threads: Thread[] | undefined, selectedThread: const clearSelection = useCallback(() => { setSelectedThreadIds(new Set()); - lastActiveThreadIdRef.current = null; anchorThreadIdRef.current = null; - focusThreadIdRef.current = null; setIsSelectionMode(false); }, []); @@ -202,11 +121,8 @@ const useThreadSelectionState = (threads: Thread[] | undefined, selectedThread: useEffect(() => { if (!threads) return; setSelectedThreadIds((prev) => { - if (prev.size === 0) return prev; - const threadIds = new Set(threads.map((t) => t.id)); - const pruned = new Set([...prev].filter((id) => threadIds.has(id))); - if (pruned.size === prev.size) return prev; - if (pruned.size === 0) { + const pruned = pruneSelection(prev, threads); + if (pruned !== prev && pruned.size === 0) { setIsSelectionMode(false); } return pruned; @@ -216,9 +132,7 @@ const useThreadSelectionState = (threads: Thread[] | undefined, selectedThread: // Clear selection when search params change useEffect(() => { setSelectedThreadIds(new Set()); - lastActiveThreadIdRef.current = null; anchorThreadIdRef.current = null; - focusThreadIdRef.current = null; setIsSelectionMode(false); }, [searchParams]); @@ -257,12 +171,13 @@ const useThreadSelectionState = (threads: Thread[] | undefined, selectedThread: return () => { document.removeEventListener('keydown', handleKeyDown, true); }; - }, [selectedThreadIds.size, isSelectionMode, clearSelection, selectAllThreads]); + }, [isSelectionMode, clearSelection, selectAllThreads]); return { selectedThreadIds, isSelectionMode, - toggleThreadSelection, + toggleThread, + selectRange, selectAllThreads, clearSelection, enableSelectionMode, diff --git a/src/frontend/src/routes/mailbox/$mailboxId/route.tsx b/src/frontend/src/routes/mailbox/$mailboxId/route.tsx index 101da8be..b5c2af4a 100644 --- a/src/frontend/src/routes/mailbox/$mailboxId/route.tsx +++ b/src/frontend/src/routes/mailbox/$mailboxId/route.tsx @@ -1,12 +1,15 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; import { MainLayout } from "@/features/layouts/components/main"; +import { ThreadListboxFocusProvider } from "@/features/providers/thread-listbox-focus"; import { ThreadSelectionProvider } from "@/features/providers/thread-selection"; const MailboxLayoutRoute = () => ( - + + + ); From ca04508b566384b5fcca960bfb5f31492e2431e2 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste PENRATH Date: Mon, 15 Jun 2026 13:45:23 +0200 Subject: [PATCH 35/52] =?UTF-8?q?=F0=9F=94=A7(deps)=20install=20jmap-email?= =?UTF-8?q?=20from=20pypi=20(#711)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that jmap-email 0.1.0 is available on pypi we install it from this registry and remove all tweaks to install the deps from local folder. We keep the volume override for backend services in order to be able to work on jmap-email and test it with ease in local development environment. --- .github/workflows/docker-publish.yml | 7 ------- .github/workflows/messages-ghcr.yml | 5 ----- Makefile | 5 ----- compose.yaml | 26 +++++++------------------- src/backend/Dockerfile | 8 -------- src/backend/pyproject.toml | 8 +------- src/backend/uv.lock | 17 +++++------------ 7 files changed, 13 insertions(+), 63 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index afc16cd4..77f0c26d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -26,11 +26,6 @@ name: Build and Push Container Image required: false default: "" description: "Build arg name to pass first amd64 tag to arm64 build (skips arch-independent build steps)" - build_contexts: - type: string - required: false - default: "" - description: "Newline-separated BuildKit named build contexts (e.g. ``jmap-email=src/jmap-email``)." # see https://docs.github.com/en/enterprise-cloud@latest/actions/how-tos/use-cases-and-examples/publishing-packages/publishing-docker-images#publishing-images-to-github-packages jobs: @@ -88,7 +83,6 @@ jobs: provenance: false tags: ${{ steps.platform-tags.outputs.amd64 }} labels: ${{ steps.meta.outputs.labels }} - build-contexts: ${{ inputs.build_contexts }} - name: Build and push (arm64) uses: docker/build-push-action@v6 with: @@ -99,7 +93,6 @@ jobs: provenance: false tags: ${{ steps.platform-tags.outputs.arm64 }} labels: ${{ steps.meta.outputs.labels }} - build-contexts: ${{ inputs.build_contexts }} build-args: | ${{ inputs.arm64_reuse_amd64_build_arg && format('{0}={1}', inputs.arm64_reuse_amd64_build_arg, steps.platform-tags.outputs.amd64_first) || '' }} - name: Create multi-arch manifests diff --git a/.github/workflows/messages-ghcr.yml b/.github/workflows/messages-ghcr.yml index bbec93fd..44e49ade 100644 --- a/.github/workflows/messages-ghcr.yml +++ b/.github/workflows/messages-ghcr.yml @@ -73,11 +73,6 @@ jobs: image_name: "backend" context: "src/backend" target: runtime-prod - # Sibling jmap-email package plumbed in as a BuildKit named context. - # The Dockerfile pulls it in via ``COPY --from=jmap-email``; keeping - # the primary context narrow at ``src/backend`` preserves cache locality. - build_contexts: | - jmap-email=src/jmap-email docker-publish-keycloak: uses: ./.github/workflows/docker-publish.yml diff --git a/Makefile b/Makefile index e2880195..369cffc8 100644 --- a/Makefile +++ b/Makefile @@ -114,13 +114,8 @@ build: ## build the project containers .PHONY: build build-back-distroless: ## build the distroless production image - # Sibling jmap-email package is plumbed via BuildKit named context - # (see src/backend/Dockerfile ``COPY --from=jmap-email``). Use buildx - # explicitly so ``--build-context`` is available even on hosts where - # ``docker build`` still resolves to the legacy builder. @docker buildx build --load --target runtime-distroless-prod -t messages-distroless \ -f src/backend/Dockerfile \ - --build-context jmap-email=src/jmap-email \ src/backend/ .PHONY: build-back-distroless diff --git a/compose.yaml b/compose.yaml index e88f6bb2..074204f4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -83,8 +83,6 @@ services: backend-base: build: context: src/backend - additional_contexts: - jmap-email: ./src/jmap-email target: runtime-dev args: DOCKER_USER: ${DOCKER_USER:-1000} @@ -92,11 +90,13 @@ services: volumes: - ./src/backend:/app - ./data/static:/data/static - # Live-mount the jmap-email package over the frozen install so - # source edits propagate without a rebuild. The wheel installed - # at ``/venv/lib/${PYTHON_VERSION}/site-packages/jmap_email`` is - # overlaid with the editable source. Override ``PYTHON_VERSION`` - # in the environment when the backend's Python floor moves. + # Dev-only override: live-mount the jmap-email working tree over the + # package installed from PyPI so local source edits propagate without a + # rebuild. The wheel installed at + # ``/venv/lib/${PYTHON_VERSION}/site-packages/jmap_email`` is overlaid + # with the working-tree source. Override ``PYTHON_VERSION`` in the + # environment when the backend's Python floor moves. Comment this mount + # out to run exactly what CI/prod install from PyPI. - ./src/jmap-email/jmap_email:/venv/lib/${PYTHON_VERSION:-python3.14}/site-packages/jmap_email healthcheck: test: ["CMD", "python", "-c", "import urllib.request as u; u.urlopen('http://localhost:8000/__heartbeat__/', timeout=1)"] @@ -150,24 +150,14 @@ services: - tools volumes: - ./src/backend:/app - # Sibling package for path-based uv dependency. Both `backend` - # and `jmap-email` must be visible at the SAME relative path - # from /app as they are on the host (../jmap-email), so the - # ``[tool.uv.sources]`` declaration in src/backend/pyproject.toml - # resolves correctly inside the container. - - ./src/jmap-email:/jmap-email build: context: src/backend - additional_contexts: - jmap-email: ./src/jmap-email target: uv pull_policy: build worker-dev: build: context: src/backend - additional_contexts: - jmap-email: ./src/jmap-email target: runtime-dev args: DOCKER_USER: ${DOCKER_USER:-1000} @@ -188,8 +178,6 @@ services: worker-ui: build: context: src/backend - additional_contexts: - jmap-email: ./src/jmap-email target: runtime-dev args: DOCKER_USER: ${DOCKER_USER:-1000} diff --git a/src/backend/Dockerfile b/src/backend/Dockerfile index ffd12d37..56b11929 100644 --- a/src/backend/Dockerfile +++ b/src/backend/Dockerfile @@ -48,14 +48,6 @@ RUN uv python install 3.14.5 FROM uv AS base-with-deps COPY pyproject.toml uv.lock ./ -# Path-based sibling dependency from src/jmap-email — uv's sync needs -# the directory on disk at the same relative path the source tree uses -# (``../jmap-email``). The sibling package is plumbed in as a named -# build context (BuildKit's ``additional_contexts`` — see compose.yaml -# ``backend-base``) so the primary build context stays narrow at -# ``src/backend`` and Docker only re-scans the jmap-email tree when it -# actually changes. -COPY --from=jmap-email . /jmap-email ENV PATH="/venv/bin:$PATH" diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 5164c296..fdf20068 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -51,7 +51,7 @@ dependencies = [ "factory_boy==3.3.3", "gunicorn==25.1.0", "icalendar==7.0.3", - "jmap-email", + "jmap-email==0.1.0", "jsonschema==4.26.0", "nested-multipart-parser==1.6.0", "openai==2.21.0", @@ -106,12 +106,6 @@ module-root = "" source-include = ["core/**"] source-exclude = ["core/tests/**"] -# Path-based dependency on the jmap-email package while it lives in -# this monorepo. After publishing to PyPI, replace with a normal -# version pin in the ``dependencies`` list above. -[tool.uv.sources] -jmap-email = { path = "../jmap-email", editable = true } - [tool.ruff] # Pin ruff at py313 even though our runtime floor is py314.5 (chosen for # stdlib `email` fixes — see ``src/jmap-email/README.md``). Targeting py313 diff --git a/src/backend/uv.lock b/src/backend/uv.lock index f21d58c7..5f6fad74 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -946,18 +946,11 @@ wheels = [ [[package]] name = "jmap-email" version = "0.1.0" -source = { editable = "../jmap-email" } - -[package.metadata] -requires-dist = [ - { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.151.0" }, - { name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.4" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.0" }, - { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.44" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/65/86143692dc38e57ea950d85f6a7d66ea9df9a3b64debd8d258cb8ba2cb4d/jmap_email-0.1.0.tar.gz", hash = "sha256:04d906c22aff25ac62819bda04f467bc9f65e041be4a1f96bb5439a54c4d2de1", size = 149817, upload-time = "2026-06-09T13:41:36.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/bf/41fd7b86859391bc112114162ae9d21d1a98c09986dfdc0872132e19dbf7/jmap_email-0.1.0-py3-none-any.whl", hash = "sha256:a32d8b49f5f33ae27de1279a1d402c1714c85c35a1b55844251c985a32ff1498", size = 59633, upload-time = "2026-06-09T13:41:34.395Z" }, ] -provides-extras = ["dev"] [[package]] name = "jmespath" @@ -1201,7 +1194,7 @@ requires-dist = [ { name = "gunicorn", specifier = "==25.1.0" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = "==6.151.9" }, { name = "icalendar", specifier = "==7.0.3" }, - { name = "jmap-email", editable = "../jmap-email" }, + { name = "jmap-email", specifier = "==0.1.0" }, { name = "jsonschema", specifier = "==4.26.0" }, { name = "libpff-python", specifier = "==20231205" }, { name = "nested-multipart-parser", specifier = "==1.6.0" }, From 745b626e4ef49c7ef21f1a5b2b16086858003a79 Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Mon, 15 Jun 2026 16:06:41 +0200 Subject: [PATCH 36/52] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F(hardening)=20add=20?= =?UTF-8?q?some=20defense-in-depth=20bits=20(#706)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performed a large LLM-assisted review, no gaping holes found but many small future footguns prevented. --- src/backend/core/api/openapi.json | 16 +- src/backend/core/api/permissions.py | 72 --- src/backend/core/api/viewsets/blob.py | 3 - src/backend/core/api/viewsets/draft.py | 53 +- src/backend/core/api/viewsets/inbound/mta.py | 47 +- .../core/api/viewsets/inbound/widget.py | 68 ++- src/backend/core/api/viewsets/send.py | 91 +++- src/backend/core/api/viewsets/submit.py | 113 ++-- src/backend/core/api/viewsets/thread.py | 17 +- src/backend/core/mda/inbound_auth.py | 57 +- src/backend/core/mda/inbound_create.py | 493 ++++++++++-------- src/backend/core/mda/inbound_tasks.py | 10 +- src/backend/core/mda/outbound_direct.py | 21 +- src/backend/core/mda/signing.py | 27 +- .../migrations/0031_message_mime_id_index.py | 18 + src/backend/core/models.py | 4 +- src/backend/core/services/importer/imap.py | 76 ++- src/backend/core/services/search/search.py | 11 + src/backend/core/services/ssrf.py | 25 + .../core/tests/api/test_attachments.py | 21 + .../core/tests/api/test_inbound_mta.py | 31 ++ .../core/tests/api/test_inbound_widget.py | 119 +++++ .../core/tests/api/test_messages_create.py | 267 ++++++---- .../core/tests/api/test_messages_import.py | 10 +- .../tests/api/test_send_message_signature.py | 246 ++++++--- src/backend/core/tests/api/test_submit.py | 57 +- .../core/tests/api/test_threads_list.py | 46 +- .../tests/importer/test_imap_connection.py | 87 +++- .../core/tests/importer/test_imap_import.py | 8 +- .../tests/importer/test_import_service.py | 6 +- src/backend/core/tests/mda/test_inbound.py | 59 ++- .../core/tests/mda/test_inbound_auth.py | 102 +++- src/backend/core/tests/mda/test_outbound.py | 53 ++ .../core/tests/mda/test_outbound_e2e.py | 50 +- src/backend/core/tests/mda/test_signing.py | 45 +- .../core/tests/mda/test_spam_processing.py | 36 +- src/backend/core/tests/search/test_e2e.py | 12 + src/backend/core/tests/search/test_search.py | 14 +- .../tests/search/test_search_modifiers.py | 31 -- src/backend/core/tests/services/test_ssrf.py | 35 ++ src/backend/messages/settings.py | 29 +- src/e2e/caddy/Caddyfile | 9 + src/frontend/caddy/Caddyfile | 14 +- src/frontend/index.html | 6 + .../src/features/api/gen/messages/messages.ts | 16 +- .../src/features/api/gen/models/index.ts | 2 +- .../{send_create503.ts => send_create500.ts} | 2 +- .../api/gen/models/send_message_response.ts | 2 - .../blocknote/signature-block/index.tsx | 10 +- .../src/features/message/use-print.tsx | 11 + src/mta-in/src/api/mda.py | 17 +- src/mta-in/src/delivery_milter.py | 8 +- src/mta-in/tests/test_email_delivery.py | 40 ++ 53 files changed, 2020 insertions(+), 703 deletions(-) create mode 100644 src/backend/core/migrations/0031_message_mime_id_index.py rename src/frontend/src/features/api/gen/models/{send_create503.ts => send_create500.ts} (77%) diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index 45a3f8ed..12f09d19 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -5187,14 +5187,11 @@ "$ref": "#/components/schemas/SendMessageResponse" }, "examples": { - "SendDraft": { + "SendDraftResult": { "value": { - "messageId": "123e4567-e89b-12d3-a456-426614174000", - "senderId": "a1b2c3d4-e5f6-7890-1234-567890abcdef", - "textBody": "Hello, world!", - "htmlBody": "

Hello, world!

" + "task_id": "123e4567-e89b-12d3-a456-426614174000" }, - "summary": "Send Draft" + "summary": "Send Draft Result" } } } @@ -5225,7 +5222,7 @@ }, "description": "" }, - "503": { + "500": { "content": { "application/json": { "schema": { @@ -9638,16 +9635,13 @@ "SendMessageResponse": { "type": "object", "properties": { - "message": { - "$ref": "#/components/schemas/Message" - }, "task_id": { "type": "string", + "format": "uuid", "description": "Task ID for tracking" } }, "required": [ - "message", "task_id" ] }, diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index da51b2a6..906e1ecc 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -205,78 +205,6 @@ class IsAllowedToAccess(IsAuthenticated): return False -class IsAllowedToCreateMessage(IsAuthenticated): - """Permission class for access to create a message.""" - - def has_permission(self, request, view): - """Check if user is allowed to create a message.""" - - if not IsAuthenticated.has_permission(self, request, view): - return False - - # a sender mailbox is required to create/send a message - sender_id = request.data.get("senderId") - parent_id = request.data.get("parentId") - if not sender_id: - return False - - # get mailbox instance from sender id - try: - # Store mailbox on the view for later use (e.g., in the view logic) - view.mailbox = models.Mailbox.objects.get(id=sender_id) - except models.Mailbox.DoesNotExist: - return False # Invalid senderId - - # Check if user has required role on the sender Mailbox - has_edit_role = view.mailbox.accesses.filter( - user=request.user, - role__in=enums.MAILBOX_ROLES_CAN_EDIT, - ).exists() - - # if user does not have edit role with this sender mailbox, return False - if not has_edit_role: - return False - - # --- Additional check for replies --- - # If creating a reply (parentId is provided), check access to the parent thread - if parent_id: - try: - parent_message = models.Message.objects.select_related("thread").get( - id=parent_id - ) - # Check if the user has access to the thread they are replying to - if models.ThreadAccess.objects.filter( - thread=parent_message.thread, - mailbox=view.mailbox, - role=enums.ThreadAccessRoleChoices.EDITOR, - ).exists(): - return True - except models.Message.DoesNotExist: - return False # Treat invalid parentId as permission failure - - # --- Additional check for updating existing draft --- - # If updating (messageId is provided), check access to the draft's thread - message_id = request.data.get("messageId") - if message_id and request.method == "PUT": # Check only needed for updates - try: - draft_message = models.Message.objects.select_related("thread").get( - id=message_id, is_draft=True - ) - # Check if the user has access to the thread of the draft being updated - if not models.ThreadAccess.objects.filter( - thread=draft_message.thread, - mailbox=view.mailbox, - role=enums.ThreadAccessRoleChoices.EDITOR, - ).exists(): - return False - except models.Message.DoesNotExist: - # Let the view handle invalid messageId - return False # Treat invalid messageId as permission failure - - # If all checks pass - return True - - def _user_can_manage_thread_access(user, thread_id): """True if ``user`` has full edit rights on the thread. diff --git a/src/backend/core/api/viewsets/blob.py b/src/backend/core/api/viewsets/blob.py index 85edc579..a169a930 100644 --- a/src/backend/core/api/viewsets/blob.py +++ b/src/backend/core/api/viewsets/blob.py @@ -5,9 +5,7 @@ import logging from django.conf import settings from django.core.exceptions import ValidationError as DjangoValidationError from django.http import HttpResponse -from django.utils.decorators import method_decorator from django.utils.http import content_disposition_header -from django.views.decorators.csrf import csrf_exempt import magic from drf_spectacular.types import OpenApiTypes @@ -104,7 +102,6 @@ class BlobViewSet(ViewSet): }, tags=["blob"], ) - @method_decorator(csrf_exempt) @action(detail=False, methods=["post"], url_path="upload/(?P[^/.]+)") def upload(self, request, mailbox_id=None): """ diff --git a/src/backend/core/api/viewsets/draft.py b/src/backend/core/api/viewsets/draft.py index 266f33b4..a2bf2c82 100644 --- a/src/backend/core/api/viewsets/draft.py +++ b/src/backend/core/api/viewsets/draft.py @@ -3,6 +3,7 @@ import json import logging +from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction import rest_framework as drf @@ -190,8 +191,34 @@ class DraftMessageView(APIView): Return updated draft message """ - permission_classes = [permissions.IsAllowedToCreateMessage] - mailbox = None + permission_classes = [permissions.IsAuthenticated] + + @staticmethod + def _resolve_editable_sender_mailbox(user, sender_id): + """Return the ``sender_id`` mailbox the ``user`` may draft/send from. + + Resolves and authorizes in one query: the mailbox is looked up scoped + to a ``MAILBOX_ROLES_CAN_EDIT`` access for ``user``. Raises 403 when the + mailbox is missing OR the user lacks an editor role on it — without + distinguishing the two, so the endpoint never reveals which mailboxes + exist. (This is the authorization that previously lived in the + single-use, request-shape-coupled ``IsAllowedToCreateMessage`` + permission; create_draft still enforces parent-thread access for + replies, and the PUT draft lookup scopes to an editable draft thread.) + """ + try: + mailbox = models.Mailbox.objects.filter( + id=sender_id, + accesses__user=user, + accesses__role__in=enums.MAILBOX_ROLES_CAN_EDIT, + ).first() + except (DjangoValidationError, ValueError, TypeError): + mailbox = None # malformed senderId — treat as no access + if mailbox is None: + raise drf.exceptions.PermissionDenied( + "You do not have permission to send as this mailbox." + ) + return mailbox @transaction.atomic def post(self, request): @@ -203,13 +230,10 @@ class DraftMessageView(APIView): subject = request.data.get("subject") - # Get mailbox (permission class validates access) - try: - sender_mailbox = models.Mailbox.objects.get(id=sender_id) - except models.Mailbox.DoesNotExist as exc: - raise drf.exceptions.NotFound( - f"Mailbox with senderId {sender_id} not found." - ) from exc + # Resolve + authorize the sender mailbox (user must hold an editor-or- + # above role on it). create_draft separately enforces access to the + # parent thread for replies. + sender_mailbox = self._resolve_editable_sender_mailbox(request.user, sender_id) # Create draft message = create_draft( @@ -249,13 +273,10 @@ class DraftMessageView(APIView): "senderId is required in request body for update." ) - # Get mailbox - try: - sender_mailbox = models.Mailbox.objects.get(id=sender_id) - except models.Mailbox.DoesNotExist as exc: - raise drf.exceptions.NotFound( - f"Mailbox with senderId {sender_id} not found." - ) from exc + # Resolve + authorize the sender mailbox (editor-or-above role). The + # draft lookup below additionally scopes to a draft whose thread this + # mailbox can edit (404 otherwise). + sender_mailbox = self._resolve_editable_sender_mailbox(request.user, sender_id) # Get the draft message try: diff --git a/src/backend/core/api/viewsets/inbound/mta.py b/src/backend/core/api/viewsets/inbound/mta.py index fc2cb9e7..0551736d 100644 --- a/src/backend/core/api/viewsets/inbound/mta.py +++ b/src/backend/core/api/viewsets/inbound/mta.py @@ -24,9 +24,26 @@ logger = logging.getLogger(__name__) class MTAJWTAuthentication(BaseAuthentication): - """ - Custom authentication for MTA endpoints using JWT tokens with email hash validation. - Returns None or (user, auth) + """Authenticate the MTA-to-MDA channel via an HS256 JWT. + + Trust model: the whole channel rests on the shared ``MDA_API_SECRET``. + Only the MTA-in service knows it, so a valid HMAC signature *is* the proof + of identity — there is no per-request identity beyond "signed by the + secret". Consequently we do NOT attempt replay protection (a ``jti`` nonce + store, etc.): anyone able to forge the signature already holds the secret + and could mint fresh tokens at will, and anyone who cannot is stopped by + the signature check. Keeping the secret out of source (it has no default — + see ``settings.MDA_API_SECRET``) and the transport on TLS is what actually + secures this path. + + On top of the signature we keep two cheap, narrow guards: + - ``exp``: bounds a leaked token's useful lifetime. The issuer sizes the + claim to cover its full retry window (see mta-in ``mda_api_call``). + - ``body_hash``: binds the token to its exact request body, so a captured + token can't be repurposed for a *different* body within that window. + Enforced even for an empty body (the bodyless ``/check`` path). + + Returns None or (user, auth). """ def authenticate(self, request): @@ -41,20 +58,26 @@ class MTAJWTAuthentication(BaseAuthentication): settings.MDA_API_SECRET, algorithms=["HS256"], options={ - "require": ["exp"], + # exp bounds the lifetime; body_hash binds the token to its + # payload. Both are mandatory. + "require": ["exp", "body_hash"], "verify_exp": True, "verify_signature": True, }, ) - if not payload.get("exp"): - raise jwt.InvalidTokenError("Missing expiration time") - - # Validate email hash if there's a body - if request.body: - body_hash = hashlib.sha256(request.body).hexdigest() - if not secrets.compare_digest(body_hash, payload["body_hash"]): - raise jwt.InvalidTokenError("Invalid email hash") + # Bind the token to its payload. Always enforced — including for + # an empty body (sha256 of b"") — so the bodyless /check endpoint + # can't be driven with a token minted for a different request. + claimed_hash = payload["body_hash"] + # ``compare_digest`` raises TypeError on mismatched types (e.g. a + # numeric ``body_hash`` claim), which would surface as a 500 rather + # than an auth failure. Reject a non-string claim up front. + if not isinstance(claimed_hash, str): + raise jwt.InvalidTokenError("Invalid email hash") + body_hash = hashlib.sha256(request.body or b"").hexdigest() + if not secrets.compare_digest(body_hash, claimed_hash): + raise jwt.InvalidTokenError("Invalid email hash") service_account = models.User() return (service_account, payload) diff --git a/src/backend/core/api/viewsets/inbound/widget.py b/src/backend/core/api/viewsets/inbound/widget.py index a2281b00..b2acc3ce 100644 --- a/src/backend/core/api/viewsets/inbound/widget.py +++ b/src/backend/core/api/viewsets/inbound/widget.py @@ -4,6 +4,8 @@ import logging from html import escape as html_escape from urllib.parse import urlparse +from django.conf import settings + from drf_spectacular.utils import extend_schema from jmap_email import compose_email, parse_address from rest_framework import status, viewsets @@ -11,6 +13,7 @@ from rest_framework.authentication import BaseAuthentication from rest_framework.decorators import action from rest_framework.exceptions import AuthenticationFailed from rest_framework.response import Response +from rest_framework.throttling import SimpleRateThrottle from core import models from core.api.permissions import IsAuthenticated @@ -20,6 +23,47 @@ from core.mda.utils import current_sent_at logger = logging.getLogger(__name__) +class WidgetChannelThrottle(SimpleRateThrottle): + """Per-channel rate limit for the public widget deliver endpoint. + + The channel id is the literal value embedded in the public HTML snippet, + so it offers no secrecy — anyone who scrapes it can POST. Keying the + throttle on the channel (not the source IP) caps the total inbound volume + a single widget can push into its mailbox regardless of how many IPs the + caller rotates through, bounding mailbox/blob/Contact/Thread growth and + per-message AI labeling cost. + """ + + scope = "widget_inbound_channel" + + def get_cache_key(self, request, view): + auth = getattr(request, "auth", None) + channel = auth.get("channel") if isinstance(auth, dict) else None + if channel is None: + return None # Unauthenticated — auth layer will reject it. + return self.cache_format % {"scope": self.scope, "ident": str(channel.id)} + + +class WidgetIPThrottle(SimpleRateThrottle): + """Per-IP burst limit, layered under the per-channel cap above. + + Stops a single source from saturating a channel's quota and gives a + cheaper first line of defense against floods from one host. + """ + + scope = "widget_inbound_ip" + + def get_cache_key(self, request, view): + # Key on REMOTE_ADDR, not DRF's get_ident(): get_ident() prefers the + # raw X-Forwarded-For header (client-spoofable, and only trustworthy + # when NUM_PROXIES is configured — this project does not use it). + # Instead REMOTE_ADDR is normalized to the real client IP by + # XForwardedForMiddleware when USE_X_FORWARDED_FOR is enabled, which is + # the IP the rest of this view already trusts. + ident = request.META.get("REMOTE_ADDR") + return self.cache_format % {"scope": self.scope, "ident": ident} + + class WidgetAuthentication(BaseAuthentication): """ Custom authentication for widget endpoints using channel_id header @@ -51,6 +95,17 @@ class InboundWidgetViewSet(viewsets.GenericViewSet): permission_classes = [IsAuthenticated] authentication_classes = [WidgetAuthentication] + def get_throttles(self): + """Rate-limit only the public ``deliver`` endpoint. + + ``config`` is a cheap idempotent read fetched on widget load and is + left unthrottled; ``deliver`` is the write path an attacker could + abuse, so it carries both the per-channel and per-IP throttles. + """ + if getattr(self, "action", None) == "deliver": + return [WidgetChannelThrottle(), WidgetIPThrottle()] + return super().get_throttles() + @extend_schema(exclude=True) @action( detail=False, @@ -78,8 +133,6 @@ class InboundWidgetViewSet(viewsets.GenericViewSet): def deliver(self, request): """Handle incoming widget message.""" - # TODO: throttle - data = request.data auth_data = request.auth channel = auth_data["channel"] @@ -92,6 +145,17 @@ class InboundWidgetViewSet(viewsets.GenericViewSet): {"detail": "Missing email"}, status=status.HTTP_400_BAD_REQUEST ) + # Cap the body so a caller can't fill blob storage with one giant + # message. ``message_text`` is the only unbounded field; it is expanded + # into both the text and HTML parts of the stored MIME, so bounding it + # bounds the resulting blob. Mirrors the MAX_INCOMING_EMAIL_SIZE limit + # the MTA path already enforces. + if len(message_text.encode("utf-8")) > settings.MAX_INCOMING_EMAIL_SIZE: + return Response( + {"detail": "Message too large"}, + status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + ) + # Validate through the same parser the rest of the pipeline # uses. ``parse_address`` is strict by default and returns # ``("", "")`` on garbage input. diff --git a/src/backend/core/api/viewsets/send.py b/src/backend/core/api/viewsets/send.py index a84e1547..182d2233 100644 --- a/src/backend/core/api/viewsets/send.py +++ b/src/backend/core/api/viewsets/send.py @@ -1,6 +1,9 @@ """API ViewSet for sending messages.""" import logging +import uuid + +from django.db import transaction from drf_spectacular.utils import ( OpenApiExample, @@ -13,7 +16,7 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView -from core import models +from core import enums, models from core.api.viewsets.task import register_task_owner from core.mda.outbound import prepare_outbound_message from core.mda.outbound_tasks import send_message_task @@ -30,8 +33,7 @@ logger = logging.getLogger(__name__) 200: inline_serializer( name="SendMessageResponse", fields={ - "message": serializers.MessageSerializer(), - "task_id": drf_serializers.CharField(help_text="Task ID for tracking"), + "task_id": drf_serializers.UUIDField(help_text="Task ID for tracking"), }, ), 400: OpenApiExample( @@ -42,8 +44,8 @@ logger = logging.getLogger(__name__) "Permission Error", value={"detail": "You do not have permission to send this message."}, ), - 503: OpenApiExample( - "Service Unavailable", + 500: OpenApiExample( + "Prepare Failure", value={"detail": "Failed to prepare message for sending."}, ), }, @@ -63,6 +65,12 @@ logger = logging.getLogger(__name__) "textBody": "Hello, world!", "htmlBody": "

Hello, world!

", }, + request_only=True, + ), + OpenApiExample( + "Send Draft Result", + value={"task_id": "123e4567-e89b-12d3-a456-426614174000"}, + response_only=True, ), ], ) @@ -107,29 +115,60 @@ class SendMessageView(APIView): self.check_object_permissions(request, message) - prepared = 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, + # 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." ) - # Launch async task for sending the message - task = send_message_task.delay(str(message.id), must_archive=must_archive) - register_task_owner(task.id, request.user.id) + # 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()) - # --- Finalize --- - # Message state should be updated by prepare_outbound_message/send_message - # Refresh from DB to get final state (e.g., is_draft=False) - message.refresh_from_db() + with transaction.atomic(): + prepared = 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, + ) - # Update thread stats after un-drafting - message.thread.update_stats() + register_task_owner(task_id, request.user.id) - return Response({"task_id": task.id}, status=status.HTTP_200_OK) + # 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) diff --git a/src/backend/core/api/viewsets/submit.py b/src/backend/core/api/viewsets/submit.py index e895567e..0ed47795 100644 --- a/src/backend/core/api/viewsets/submit.py +++ b/src/backend/core/api/viewsets/submit.py @@ -10,6 +10,7 @@ creation) and dispatches SMTP delivery asynchronously via Celery. import logging from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import transaction from drf_spectacular.utils import extend_schema from jmap_email import parse_email @@ -126,50 +127,57 @@ class SubmitRawEmailView(APIView): status=status.HTTP_403_FORBIDDEN, ) - # Create thread, contacts, message, and recipients from the parsed email. - # is_outbound=True skips blob creation (handled by prepare_outbound_message - # with DKIM) and AI features. - message = _create_message_from_inbound( - recipient_email=mailbox_email, - parsed_email=parsed, - raw_data=raw_mime, - mailbox=mailbox, - is_outbound=True, - ) - if not message: - return Response( - {"detail": "Failed to create message."}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + # Create the message, sign it, and arm the SMTP dispatch atomically. + # The whole thing rolls back on any failure (no orphan draft), and the + # Celery task is dispatched via ``transaction.on_commit`` so the broker + # never receives a delivery task for a message that is still uncommitted + # or whose transaction later rolls back. + with transaction.atomic(): + # Create thread, contacts, message, and recipients from the parsed + # email. is_outbound=True skips blob creation (handled by + # prepare_outbound_message with DKIM) and AI features. + message = _create_message_from_inbound( + recipient_email=mailbox_email, + parsed_email=parsed, + raw_data=raw_mime, + mailbox=mailbox, + is_outbound=True, ) + if not message: + # Roll back so any partial writes from the failed creation + # don't commit — returning from inside the atomic block would + # otherwise commit them (mirrors the prepare-failure path below). + transaction.set_rollback(True) + return Response( + {"detail": "Failed to create message."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) - # Add envelope-only recipients as BCC. _create_message_from_inbound - # creates MessageRecipient rows from the MIME To/Cc/Bcc headers, but - # true BCC recipients appear only in the envelope (X-Rcpt-To), never - # in the MIME headers — that's how BCC works in SMTP. - mime_recipients = { - e.lower() - for e in message.recipients.values_list("contact__email", flat=True) - } - for addr in recipient_emails: - if addr.lower() not in mime_recipients: - try: - contact, _ = models.Contact.objects.get_or_create( - email=addr, - mailbox=mailbox, - defaults={"name": addr.split("@")[0]}, - ) - models.MessageRecipient.objects.get_or_create( - message=message, - contact=contact, - type=models.MessageRecipientTypeChoices.BCC, - ) - except Exception: # pylint: disable=broad-exception-caught - logger.warning("Failed to add BCC recipient (masked)") + # Add envelope-only recipients as BCC. _create_message_from_inbound + # creates MessageRecipient rows from the MIME To/Cc/Bcc headers, but + # true BCC recipients appear only in the envelope (X-Rcpt-To), never + # in the MIME headers — that's how BCC works in SMTP. + mime_recipients = { + e.lower() + for e in message.recipients.values_list("contact__email", flat=True) + } + for addr in recipient_emails: + if addr.lower() not in mime_recipients: + try: + contact, _ = models.Contact.objects.get_or_create( + email=addr, + mailbox=mailbox, + defaults={"name": addr.split("@")[0]}, + ) + models.MessageRecipient.objects.get_or_create( + message=message, + contact=contact, + type=models.MessageRecipientTypeChoices.BCC, + ) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("Failed to add BCC recipient (masked)") - # Synchronous: validate recipients, throttle, DKIM sign, create blob. - # This is a one-shot API — clean up on any failure so no orphan - # draft remains. - try: + # Synchronous: validate recipients, throttle, DKIM sign, create blob. prepared = prepare_outbound_message( mailbox, message, @@ -177,19 +185,18 @@ class SubmitRawEmailView(APIView): "", raw_mime=raw_mime, ) - except Exception: - message.delete() - raise + if not prepared: + # Roll back so no orphan draft survives the failed prepare — + # returning from inside the atomic block would otherwise commit + # the partially-built message. + transaction.set_rollback(True) + return Response( + {"detail": "Failed to prepare message for sending."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) - if not prepared: - message.delete() - return Response( - {"detail": "Failed to prepare message for sending."}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - - # Dispatch async SMTP delivery - send_message_task.delay(str(message.id)) + # Dispatch async SMTP delivery once the message is durably committed. + transaction.on_commit(lambda: send_message_task.delay(str(message.id))) return Response( {"message_id": str(message.id), "status": "accepted"}, diff --git a/src/backend/core/api/viewsets/thread.py b/src/backend/core/api/viewsets/thread.py index 33cd617b..63dc31f8 100644 --- a/src/backend/core/api/viewsets/thread.py +++ b/src/backend/core/api/viewsets/thread.py @@ -702,10 +702,25 @@ class ThreadViewSet( page = int(self.paginator.get_page_number(request, self)) page_size = int(self.paginator.get_page_size(request)) + # Scope the search to the user's mailboxes. With an explicit + # mailbox_id we already verified access above; without one, fall + # back to every mailbox the user can access — never the whole + # cluster. An unscoped query leaks hit-totals and content-existence + # across mailboxes even though the bodies are access-filtered below. + if mailbox_id: + search_mailbox_ids = [mailbox_id] + else: + search_mailbox_ids = [ + str(mid) + for mid in models.MailboxAccess.objects.filter( + user=request.user + ).values_list("mailbox_id", flat=True) + ] + # Get search results from OpenSearch results = search_threads( query=search_query, - mailbox_ids=[mailbox_id] if mailbox_id else None, + mailbox_ids=search_mailbox_ids, filters=es_filters, from_offset=(page - 1) * page_size, size=page_size, diff --git a/src/backend/core/mda/inbound_auth.py b/src/backend/core/mda/inbound_auth.py index 8e0b630b..8388a49f 100644 --- a/src/backend/core/mda/inbound_auth.py +++ b/src/backend/core/mda/inbound_auth.py @@ -14,8 +14,13 @@ Rules applied for every backend: - If DMARC is absent or passes, DKIM alone decides. The backend is picked by ``SPAM_CONFIG["inbound_auth"]``: - - ``"native"``: verify DKIM locally (crypto + DNS). DMARC is not yet - implemented for native, so only the DKIM rule applies. + - ``"native"``: verify DKIM locally (crypto + DNS) AND require the signing + ``d=`` domain to match the From: domain (strict alignment). Raw DKIM only + proves *some* domain signed the message; without alignment an attacker who + controls any DKIM-enabled domain could sign a message bearing a forged + From:. Full DMARC policy lookup is not implemented for native, so an + unaligned-but-cryptographically-valid signature collapses to ``"none"`` + (we can't call it forgery without the From domain's published policy). - ``"rspamd"``: read DKIM / DMARC symbols from the rspamd /checkv2 result (reused from the spam check, or fetched on demand by the caller). - ``"authentication-results"``: parse ``dkim=`` / ``dmarc=`` entries from the @@ -34,7 +39,7 @@ import logging import re from typing import Any -from jmap_email import JmapEmail +from jmap_email import JmapEmail, first_address_email from core.mda.signing import verify_message_dkim from core.mda.utils import headers_blocks @@ -164,12 +169,50 @@ def _ar_outcome(check: str, ar_values: list[str]) -> str | None: return outcome if found else None -def _native_dkim_outcome(raw_data: bytes) -> str | None: +def _from_header_domain(parsed_email: JmapEmail) -> str | None: + """Return the lowercased domain of the RFC5322 From address, or ``None``.""" + from_email = first_address_email(parsed_email.get("from")) + if not from_email: + return None + domain = from_email.strip().rstrip(".").lower().rpartition("@")[2] + return domain or None + + +def _native_dkim_outcome(raw_data: bytes, parsed_email: JmapEmail) -> str | None: + """Verify DKIM locally and require From/DKIM identifier alignment. + + A valid DKIM signature only proves that *some* domain signed the message, + so we additionally require the signing domain (``d=``) to match the From: + domain — strict alignment, an exact case-insensitive match. Without it an + attacker who owns any DKIM-enabled domain could sign a message carrying a + forged From: and have it shown as verified. + + Native mode never returns ``_FAIL``: it does no DMARC policy lookup, and a + bare DKIM verify can't tell a *missing* signature from an *invalid* one, so + it has no grounds to assert an explicit failure. Every non-pass outcome — + no/invalid signature, or a valid signature whose ``d=`` doesn't align with + From — collapses to ``_NONE`` ("unverified"). The unaligned case also logs + the mismatch, since a *valid* signature not matching From is the spoofing + signature. + """ try: - return _PASS if verify_message_dkim(raw_data) else _FAIL + signing_domain = verify_message_dkim(raw_data) except Exception as e: # pylint: disable=broad-exception-caught logger.warning("Native DKIM verification errored: %s", e) return None + if not signing_domain: + # No signature, or one that didn't validate — a bare verify can't tell + # them apart, so this is "can't verify", not an explicit failure. + return _NONE + from_domain = _from_header_domain(parsed_email) + if from_domain and signing_domain == from_domain: + return _PASS + logger.info( + "Native DKIM signature not aligned with From: d=%s from=%s -> unverified", + signing_domain, + from_domain, + ) + return _NONE VERDICT_UNVERIFIED = "none" @@ -201,13 +244,13 @@ def check_inbound_authentication( return None if mode == "native": - dkim = _native_dkim_outcome(raw_data) + dkim = _native_dkim_outcome(raw_data, parsed_email) dmarc: str | None = None elif mode == "rspamd": dkim = _rspamd_outcome("dkim", rspamd_result) dmarc = _rspamd_outcome("dmarc", rspamd_result) elif mode == "authentication-results": - trusted_relays = int(spam_config.get("trusted_relays", 1)) + trusted_relays = int(spam_config.get("trusted_relays", 0)) ar_values = _authentication_results_values(parsed_email, trusted_relays) dkim = _ar_outcome("dkim", ar_values) dmarc = _ar_outcome("dmarc", ar_values) diff --git a/src/backend/core/mda/inbound_create.py b/src/backend/core/mda/inbound_create.py index 15b44768..7ae1add8 100644 --- a/src/backend/core/mda/inbound_create.py +++ b/src/backend/core/mda/inbound_create.py @@ -4,9 +4,11 @@ import logging import re +import uuid +from contextlib import contextmanager, nullcontext from django.core.exceptions import ValidationError -from django.db import transaction +from django.db import connection, transaction from django.db.utils import Error as DjangoDbError from django.utils import timezone @@ -36,6 +38,42 @@ logger = logging.getLogger(__name__) TOKEN_THRESHOLD_FOR_SUMMARY = 200 # Minimum token count to trigger summarization MINIMUM_MESSAGES_FOR_SUMMARY = 3 # Minimum number of messages to trigger summarization +# Advisory-lock namespace for inbound delivery. Distinct ``classid`` from the +# blob-cohort locks (see core.services.tiered_storage) so the two never +# collide in Postgres' single global advisory-lock keyspace. +_ADVISORY_LOCK_CLASSID_INBOUND = 0x696E626E # 'inbn' in ASCII + + +@contextmanager +def inbound_mailbox_lock(mailbox_id: uuid.UUID): + """Serialize inbound message creation for one mailbox. + + Dedup (does a message with this ``mime_id`` already exist?) and + thread-bucketing (does a thread already exist for this ``In-Reply-To`` / + ``References``?) are both read-then-decide: two concurrent inbound + deliveries to the same mailbox could each read "nothing there yet" and + both create a row, yielding duplicate Messages or two parallel Threads + with no reconcile path. Holding a per-mailbox Postgres advisory lock for + the duration of the find-or-create makes that critical section + cluster-wide serial. + + Must be called inside ``transaction.atomic()`` — ``pg_advisory_xact_lock`` + binds the lock to the current transaction and releases it on + commit/rollback. The lock is held only across the (DB-only) find-or-create + work; slow steps (spam scoring, AI summary/labels) run outside it. + """ + # First 4 bytes of the mailbox UUID as a signed int32 — the two-arg + # pg_advisory_xact_lock(classid, objid) form takes two int4s. A 2^-32 + # false-share collision is operationally invisible given the short, + # DB-only critical section it guards. + objid = int.from_bytes(mailbox_id.bytes[:4], byteorder="big", signed=True) + with connection.cursor() as cursor: + cursor.execute( + "SELECT pg_advisory_xact_lock(%s, %s)", + [_ADVISORY_LOCK_CLASSID_INBOUND, objid], + ) + yield + def _canonicalize_subject(subject: str | None) -> str: """Strip leading ``Re:`` / ``Fwd:`` (and i18n variants) for thread match.""" @@ -192,219 +230,266 @@ def _create_message_from_inbound( # pylint: disable=too-many-arguments # pylint: disable=too-many-locals,too-many-branches,too-many-statements message_flags = {} - # --- 3. Find or Create Thread --- # - try: - thread = None - if is_import: - thread = find_thread_for_import(parsed_email, mailbox) + mime_id = first_msgid(parsed_email.get("messageId")) or None - # If no thread found or not an import, use normal thread finding logic - if not thread: - thread = find_thread_for_inbound_message(parsed_email, mailbox) - - if not thread: - thread = _create_thread(parsed_email, mailbox) - - except (DjangoDbError, ValidationError) as e: - logger.error("Failed to find or create thread for %s: %s", recipient_email, e) - return None # Indicate failure - except Exception as e: - logger.exception( - "Unexpected error finding/creating thread for %s: %s", - recipient_email, - e, - ) - return None - - if is_import: - # get labels from parsed_email - labels, message_flags = compute_labels_and_flags( - parsed_email, imap_labels, imap_flags - ) - for label in labels: - try: - label_obj, _ = models.Label.objects.get_or_create( - name=label, mailbox=mailbox - ) - thread.labels.add(label_obj) - except Exception as e: - logger.exception("Error creating label %s: %s", label, e) - continue - - # Apply labels from channel settings (e.g., widget channel tags) - if channel and channel.settings: - channel_tags = channel.settings.get("tags", []) - for tag_id in channel_tags: - try: - label_obj = models.Label.objects.get(id=tag_id, mailbox=mailbox) - thread.labels.add(label_obj) - except models.Label.DoesNotExist: - logger.warning( - "Label %s not found for channel %s, skipping", tag_id, channel.id - ) - except Exception as e: - logger.exception("Error adding label %s from channel: %s", tag_id, e) - - # --- 4. Get or Create Sender Contact --- # - sender_email = first_address_email(parsed_email.get("from")) - sender_name = first_address_name(parsed_email.get("from")) - - if not sender_email: - logger.warning( - "Inbound message for %s missing 'From' email, using fallback.", - recipient_email, - ) - sender_email = f"unknown-sender@{mailbox.domain.name}" # Use recipient's domain - sender_name = sender_name or "Unknown Sender" - - try: - # Validate sender_email format before saving - models.Contact(email=sender_email).full_clean( - exclude=["mailbox", "name"] - ) # Validate email format - - sender_contact, created = models.Contact.objects.get_or_create( - email=sender_email, - mailbox=mailbox, # Associate contact with the recipient mailbox - defaults={ - "name": sender_name or sender_email.split("@")[0], - "email": sender_email, # Ensure correct casing is saved - }, - ) - if created: - logger.info( - "Created contact for sender %s in mailbox %s", sender_email, mailbox.id - ) - - except ValidationError as e: - logger.error( - "Validation error for sender contact %s in mailbox %s: %s. Using fallback.", - sender_email, - mailbox.id, - e, - ) - # Fallback: Use a generic placeholder contact if validation fails - sender_email = f"invalid-sender@{mailbox.domain.name}" - sender_name = "Invalid Sender Address" - sender_contact, _ = models.Contact.objects.get_or_create( - email=sender_email, - mailbox=mailbox, - defaults={"name": sender_name, "email": sender_email}, - ) - except DjangoDbError as e: - logger.error( - "DB error getting/creating sender contact %s in mailbox %s: %s", - sender_email, - mailbox.id, - e, - ) - return None # Indicate failure - except Exception as e: - logger.exception( - "Unexpected error with sender contact %s in mailbox %s: %s", - sender_email, - mailbox.id, - e, - ) - return None - - # --- 5. Create Message --- # - try: - # Can we get a parent message for reference? - # TODO: validate this doesn't create security issues - parent_message = None - parent_msg_id = first_msgid(parsed_email.get("inReplyTo")) - if parent_msg_id: - parent_message = models.Message.objects.filter( - mime_id=parent_msg_id, thread=thread + # Dedup, thread-bucketing and the message INSERT form one read-then-write + # critical section. Serialize it per mailbox under a Postgres advisory lock + # (held only across this DB-only work) so concurrent inbound deliveries to + # the same mailbox cannot create duplicate Messages or split a conversation + # into two parallel Threads. Imports are a single-writer backfill path and + # skip the lock to avoid serializing bulk loads. + lock_ctx = nullcontext() if is_import else inbound_mailbox_lock(mailbox.id) + with transaction.atomic(), lock_ctx: + # Recheck for an already-stored copy now that we hold the lock. + # deliver_inbound_message dedups before queueing, but the async + # processing path and concurrent deliveries can still reach here twice + # for the same Message-ID; this makes creation idempotent per + # (mailbox, mime_id). + if not is_outbound and mime_id: + existing_message = models.Message.objects.filter( + mime_id=mime_id, thread__accesses__mailbox=mailbox ).first() + if existing_message: + logger.info( + "Duplicate inbound message %s (MIME ID: %s) in mailbox %s; " + "skipping create", + existing_message.id, + mime_id, + mailbox.id, + ) + return existing_message - # Truncate subject to 255 characters if it exceeds max_length - subject = parsed_email.get("subject") - if subject and len(subject) > 255: - subject = subject[:255] + # --- 3. Find or Create Thread --- # + try: + thread = None + if is_import: + thread = find_thread_for_import(parsed_email, mailbox) - is_sender = is_outbound or (is_import and is_import_sender) - sent_at = sent_at_to_datetime(parsed_email.get("sentAt")) + # If no thread found or not an import, use normal thread finding logic + if not thread: + thread = find_thread_for_inbound_message(parsed_email, mailbox) - # The Blob INSERT and the Message INSERT must commit together - # so the GC sweep never sees the Blob row without its - # referencing FK on ``Message.blob``. Outbound messages have - # no blob yet — ``prepare_outbound_message`` adds it later. - with transaction.atomic(): - blob = None - if not is_outbound: - blob = models.Blob.objects.create_blob( - content=raw_data, - content_type="message/rfc822", + if not thread: + thread = _create_thread(parsed_email, mailbox) + + except (DjangoDbError, ValidationError) as e: + logger.error( + "Failed to find or create thread for %s: %s", recipient_email, e + ) + # Returning from inside the atomic block would commit any partial + # writes (e.g. a thread without its message); roll back instead. + transaction.set_rollback(True) + return None # Indicate failure + except Exception as e: + logger.exception( + "Unexpected error finding/creating thread for %s: %s", + recipient_email, + e, + ) + transaction.set_rollback(True) + return None + + if is_import: + # get labels from parsed_email + labels, message_flags = compute_labels_and_flags( + parsed_email, imap_labels, imap_flags + ) + for label in labels: + try: + label_obj, _ = models.Label.objects.get_or_create( + name=label, mailbox=mailbox + ) + thread.labels.add(label_obj) + except Exception as e: + logger.exception("Error creating label %s: %s", label, e) + continue + + # Apply labels from channel settings (e.g., widget channel tags) + if channel and channel.settings: + channel_tags = channel.settings.get("tags", []) + for tag_id in channel_tags: + try: + label_obj = models.Label.objects.get(id=tag_id, mailbox=mailbox) + thread.labels.add(label_obj) + except models.Label.DoesNotExist: + logger.warning( + "Label %s not found for channel %s, skipping", + tag_id, + channel.id, + ) + except Exception as e: + logger.exception( + "Error adding label %s from channel: %s", tag_id, e + ) + + # --- 4. Get or Create Sender Contact --- # + sender_email = first_address_email(parsed_email.get("from")) + sender_name = first_address_name(parsed_email.get("from")) + + if not sender_email: + logger.warning( + "Inbound message for %s missing 'From' email, using fallback.", + recipient_email, + ) + sender_email = ( + f"unknown-sender@{mailbox.domain.name}" # Use recipient's domain + ) + sender_name = sender_name or "Unknown Sender" + + try: + # Validate sender_email format before saving + models.Contact(email=sender_email).full_clean( + exclude=["mailbox", "name"] + ) # Validate email format + + sender_contact, created = models.Contact.objects.get_or_create( + email=sender_email, + mailbox=mailbox, # Associate contact with the recipient mailbox + defaults={ + "name": sender_name or sender_email.split("@")[0], + "email": sender_email, # Ensure correct casing is saved + }, + ) + if created: + logger.info( + "Created contact for sender %s in mailbox %s", + sender_email, + mailbox.id, ) - message = models.Message.objects.create( - thread=thread, - sender=sender_contact, - subject=subject, - blob=blob, - mime_id=first_msgid(parsed_email.get("messageId")) or None, - parent=parent_message, - sent_at=(None if is_outbound else (sent_at or timezone.now())), - is_draft=is_outbound, # Outbound: draft until prepare_outbound_message finalizes - is_sender=is_sender, - is_trashed=False, - is_spam=is_spam, - has_attachments=len(parsed_email.get("attachments", [])) > 0, - channel=channel, + except ValidationError as e: + logger.error( + "Validation error for sender contact %s in mailbox %s: %s. Using fallback.", + sender_email, + mailbox.id, + e, ) - if is_import: - # We need to set the created_at field to the date of the message - # because the inbound message is not created at the same time as the message is received - message.created_at = sent_at or timezone.now() - # Extract flags handled via ThreadAccess (not Message fields) - import_is_unread = message_flags.pop("is_unread", True) - import_is_starred = message_flags.pop("_starred", False) + # Fallback: Use a generic placeholder contact if validation fails + sender_email = f"invalid-sender@{mailbox.domain.name}" + sender_name = "Invalid Sender Address" + sender_contact, _ = models.Contact.objects.get_or_create( + email=sender_email, + mailbox=mailbox, + defaults={"name": sender_name, "email": sender_email}, + ) + except DjangoDbError as e: + logger.error( + "DB error getting/creating sender contact %s in mailbox %s: %s", + sender_email, + mailbox.id, + e, + ) + transaction.set_rollback(True) + return None # Indicate failure + except Exception as e: + logger.exception( + "Unexpected error with sender contact %s in mailbox %s: %s", + sender_email, + mailbox.id, + e, + ) + transaction.set_rollback(True) + return None - for flag, value in message_flags.items(): - if hasattr(message, flag): - setattr(message, flag, value) - message.save( - update_fields=[ - "created_at", - *message_flags.keys(), - ] - ) - # Update ThreadAccess for read/starred state - access = models.ThreadAccess.objects.filter( - thread=thread, mailbox=mailbox - ).first() - if access: - update_fields = [] - # Sent messages are always considered read by the sender - if (is_sender or not import_is_unread) and ( - access.read_at is None or message.created_at > access.read_at - ): + # --- 5. Create Message --- # + try: + # Can we get a parent message for reference? + # TODO: validate this doesn't create security issues + parent_message = None + parent_msg_id = first_msgid(parsed_email.get("inReplyTo")) + if parent_msg_id: + parent_message = models.Message.objects.filter( + mime_id=parent_msg_id, thread=thread + ).first() + + # Truncate subject to 255 characters if it exceeds max_length + subject = parsed_email.get("subject") + if subject and len(subject) > 255: + subject = subject[:255] + + is_sender = is_outbound or (is_import and is_import_sender) + sent_at = sent_at_to_datetime(parsed_email.get("sentAt")) + + # The Blob INSERT and the Message INSERT must commit together + # so the GC sweep never sees the Blob row without its + # referencing FK on ``Message.blob``. Outbound messages have + # no blob yet — ``prepare_outbound_message`` adds it later. + with transaction.atomic(): + blob = None + if not is_outbound: + blob = models.Blob.objects.create_blob( + content=raw_data, + content_type="message/rfc822", + ) + + message = models.Message.objects.create( + thread=thread, + sender=sender_contact, + subject=subject, + blob=blob, + mime_id=first_msgid(parsed_email.get("messageId")) or None, + parent=parent_message, + sent_at=(None if is_outbound else (sent_at or timezone.now())), + is_draft=is_outbound, # Outbound: draft until prepare_outbound_message finalizes + is_sender=is_sender, + is_trashed=False, + is_spam=is_spam, + has_attachments=len(parsed_email.get("attachments", [])) > 0, + channel=channel, + ) + if is_import: + # We need to set the created_at field to the date of the message + # because the inbound message is not created at the same time as the message is received + message.created_at = sent_at or timezone.now() + # Extract flags handled via ThreadAccess (not Message fields) + import_is_unread = message_flags.pop("is_unread", True) + import_is_starred = message_flags.pop("_starred", False) + + for flag, value in message_flags.items(): + if hasattr(message, flag): + setattr(message, flag, value) + message.save( + update_fields=[ + "created_at", + *message_flags.keys(), + ] + ) + # Update ThreadAccess for read/starred state + access = models.ThreadAccess.objects.filter( + thread=thread, mailbox=mailbox + ).first() + if access: + update_fields = [] + # Sent messages are always considered read by the sender + if (is_sender or not import_is_unread) and ( + access.read_at is None or message.created_at > access.read_at + ): + access.read_at = message.created_at + update_fields.append("read_at") + if import_is_starred and access.starred_at is None: + access.starred_at = message.created_at + update_fields.append("starred_at") + if update_fields: + access.save(update_fields=update_fields) + elif is_sender: + access = models.ThreadAccess.objects.filter( + thread=thread, mailbox=mailbox + ).first() + if access: access.read_at = message.created_at - update_fields.append("read_at") - if import_is_starred and access.starred_at is None: - access.starred_at = message.created_at - update_fields.append("starred_at") - if update_fields: - access.save(update_fields=update_fields) - elif is_sender: - access = models.ThreadAccess.objects.filter( - thread=thread, mailbox=mailbox - ).first() - if access: - access.read_at = message.created_at - access.save(update_fields=["read_at"]) - except (DjangoDbError, ValidationError) as e: - logger.error("Failed to create message in thread %s: %s", thread.id, e) - return None # Indicate failure - except Exception as e: - logger.exception( - "Unexpected error creating message in thread %s: %s", - thread.id, - e, - ) - return None + access.save(update_fields=["read_at"]) + except (DjangoDbError, ValidationError) as e: + logger.error("Failed to create message in thread %s: %s", thread.id, e) + transaction.set_rollback(True) + return None # Indicate failure + except Exception as e: + logger.exception( + "Unexpected error creating message in thread %s: %s", + thread.id, + e, + ) + transaction.set_rollback(True) + return None # --- 6. Create Recipient Contacts and Links --- # # deduplicate recipients diff --git a/src/backend/core/mda/inbound_tasks.py b/src/backend/core/mda/inbound_tasks.py index 695e77b6..bb83c1eb 100644 --- a/src/backend/core/mda/inbound_tasks.py +++ b/src/backend/core/mda/inbound_tasks.py @@ -95,8 +95,14 @@ def _check_spam_with_hardcoded_rules( # third Received (relay 2+). blocks = headers_blocks(parsed_email) - # Default trusted relays = 1 means we trust block 0 and block 1. - trusted_relays = spam_config.get("trusted_relays", 1) + # Default trusted_relays = 0: trust only block 0 (the Received our + # own MTA prepends, plus the headers above it). A sender can prepend + # their own Received lines, which land in block 1+ — trusting those + # by default would let them slip a forged header (e.g. an + # action="ham" allowlist match) into the trusted slice. Operators + # with real upstream relays opt in by setting trusted_relays to the + # number of hops they actually control. + trusted_relays = spam_config.get("trusted_relays", 0) # block 0 (our Received) + trusted_relays upstream blocks. blocks_to_check = trusted_relays + 1 diff --git a/src/backend/core/mda/outbound_direct.py b/src/backend/core/mda/outbound_direct.py index cfd4842b..79cba4e9 100644 --- a/src/backend/core/mda/outbound_direct.py +++ b/src/backend/core/mda/outbound_direct.py @@ -15,6 +15,7 @@ from django.conf import settings import dns.resolver from core.mda.smtp import SmtpProxy, send_smtp_mail +from core.services.ssrf import SSRFValidationError, assert_public_ip logger = logging.getLogger(__name__) @@ -51,13 +52,27 @@ def resolve_mx_records(domain: str) -> List[Tuple[int, str]]: def resolve_hostname_ip(hostname: str) -> Optional[str]: - """ - Resolve a hostname to its first A record IP address, with a direct DNS query + """Resolve a hostname to its first *public* A-record IP. + + SSRF guard: a recipient domain's MX (or A-record fallback) is + attacker-controlled, so any address that fails SSRF validation + (loopback / link-local / private / reserved / multicast / cloud-metadata) + is skipped — the SMTP worker must never be steered into dialing internal + infrastructure. Returns None when the host has no usable public IP, which + makes the caller skip this MX and ultimately permanent-fail the recipient + rather than connecting anywhere unsafe. Because we connect to exactly the + IP returned here, there is no DNS-rebinding window between check and dial. """ try: answers = dns.resolver.resolve(hostname, "A", lifetime=10) for r in answers: - return str(r) + ip_str = str(r) + try: + assert_public_ip(ip_str, hostname) + except SSRFValidationError as e: + logger.warning("Refusing non-public MX target %s: %s", hostname, e) + continue + return ip_str except Exception as e: # pylint: disable=broad-exception-caught logger.error("Error resolving IP for %s: %s", hostname, e) return None diff --git a/src/backend/core/mda/signing.py b/src/backend/core/mda/signing.py index 18f7674a..6d113767 100644 --- a/src/backend/core/mda/signing.py +++ b/src/backend/core/mda/signing.py @@ -6,8 +6,8 @@ import logging import dns.resolver from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa +from dkim import DKIM from dkim import sign as dkim_sign -from dkim import verify as dkim_verify from core.enums import DKIMAlgorithmChoices @@ -116,7 +116,7 @@ def sign_message_dkim(raw_mime_message: bytes, maildomain) -> bytes | None: return None -def verify_message_dkim(raw_mime_message: bytes) -> bool: +def verify_message_dkim(raw_mime_message: bytes) -> str | None: """Verify a DKIM signature on a raw MIME message using public DNS. This verifies that the DKIM signature will pass validation when the receiving @@ -127,7 +127,13 @@ def verify_message_dkim(raw_mime_message: bytes) -> bool: raw_mime_message: The raw bytes of the MIME message with DKIM signature. Returns: - True if the DKIM signature is valid, False otherwise. + The signing domain (the signature's ``d=`` tag, lowercased) if the DKIM + signature is valid, otherwise ``None``. Returning the domain rather than + a bare bool lets callers enforce identifier alignment against the From: + header — a valid signature only proves that *some* domain signed the + message, not that the visible From: address is authentic (that is + DMARC's job). Callers that only care whether *any* valid signature + exists can treat the result as truthy/falsy. """ try: # Create a DNS function that performs actual DNS lookups @@ -165,9 +171,18 @@ def verify_message_dkim(raw_mime_message: bytes) -> bool: return None - # Verify the DKIM signature using public DNS - return dkim_verify(raw_mime_message, dnsfunc=get_dns_txt) + # Verify the DKIM signature using public DNS. We drive the DKIM object + # directly (rather than the module-level ``verify`` helper) so we can + # read back the ``d=`` domain of the signature that validated: ``verify`` + # records it on ``self.domain``. + dkim_obj = DKIM(raw_mime_message) + if not dkim_obj.verify(dnsfunc=get_dns_txt): + return None + signing_domain = dkim_obj.domain + if not signing_domain: + return None + return signing_domain.decode("ascii", "replace").rstrip(".").lower() except Exception as e: # pylint: disable=broad-exception-caught logger.error("Error during DKIM verification: %s", e, exc_info=True) - return False + return None diff --git a/src/backend/core/migrations/0031_message_mime_id_index.py b/src/backend/core/migrations/0031_message_mime_id_index.py new file mode 100644 index 00000000..b9f96796 --- /dev/null +++ b/src/backend/core/migrations/0031_message_mime_id_index.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-06-09 19:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0030_remove_attachment__deprecated_messages_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='message', + name='mime_id', + field=models.CharField(blank=True, db_index=True, max_length=998, null=True, verbose_name='mime id'), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 51bfdb2b..7ad6fe62 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1939,7 +1939,9 @@ class Message(BaseModel): sent_at = models.DateTimeField("sent at", null=True, blank=True) archived_at = models.DateTimeField("archived at", null=True, blank=True) - mime_id = models.CharField("mime id", max_length=998, null=True, blank=True) + mime_id = models.CharField( + "mime id", max_length=998, null=True, blank=True, db_index=True + ) channel = models.ForeignKey( "Channel", diff --git a/src/backend/core/services/importer/imap.py b/src/backend/core/services/importer/imap.py index 52a6197b..1ea92453 100644 --- a/src/backend/core/services/importer/imap.py +++ b/src/backend/core/services/importer/imap.py @@ -63,19 +63,61 @@ def decode_imap_utf7(s): return re.sub(r"&([^-]*)-", decode_match, s) -def _validate_imap_host(server: str) -> None: - """Validate that the IMAP server hostname is not a private/internal address. +def _validate_imap_host(server: str) -> str: + """Validate the IMAP server hostname and return the vetted IP to pin to. - Wraps the shared SSRF validator but allows public IP literals, which are - legitimate addresses for customer-supplied IMAP servers. + Wraps the shared SSRF validator (allowing public IP literals, which are + legitimate for customer-supplied IMAP servers) and returns the first + validated IP address. The caller connects to *exactly* that address so the + address we vetted is the address we dial — closing the DNS-rebinding + (TOCTOU) window where stock imaplib would re-resolve the hostname and could + land on an internal IP. Raises: - ValueError: If the hostname resolves to a blocked IP address. + ValueError: If the hostname resolves to a blocked / non-public address. """ try: - validate_hostname(server, allow_ip_literal=True) + valid_ips = validate_hostname(server, allow_ip_literal=True) except SSRFValidationError as exc: raise ValueError(f"IMAP server {server} is not allowed: {exc}") from exc + if not valid_ips: + raise ValueError(f"IMAP server {server} did not resolve to a usable address") + return valid_ips[0] + + +class _IPPinnedIMAP4(imaplib.IMAP4): + """``imaplib.IMAP4`` that dials a pre-validated IP instead of re-resolving. + + SSRF hardening: ``validate_hostname`` vets the server name, but stock + imaplib re-resolves the hostname when it opens the socket — a DNS-rebinding + window in which the second lookup can return an internal address. We pin the + connection to the already-validated IP. The original hostname is kept as + ``self.host`` (used for STARTTLS SNI/cert verification upstream). + """ + + def __init__(self, host, port, *, connect_ip, timeout=None): + self._connect_ip = connect_ip + super().__init__(host, port, timeout) + + def _create_socket(self, timeout): + return socket.create_connection((self._connect_ip, self.port), timeout) + + +class _IPPinnedIMAP4SSL(imaplib.IMAP4_SSL): + """SSL variant of :class:`_IPPinnedIMAP4`. + + Connects to the pinned IP but verifies the TLS certificate against the + original hostname (``server_hostname`` SNI), so pinning never weakens + certificate validation. + """ + + def __init__(self, host, port, *, connect_ip, timeout=None): + self._connect_ip = connect_ip + super().__init__(host, port, timeout=timeout) + + def _create_socket(self, timeout): + sock = socket.create_connection((self._connect_ip, self.port), timeout) + return self.ssl_context.wrap_socket(sock, server_hostname=self.host) class IMAPConnectionManager: @@ -92,8 +134,10 @@ class IMAPConnectionManager: self.connection = None def __enter__(self): - # Validate the server hostname to prevent SSRF - _validate_imap_host(self.server) + # Validate the server hostname AND pin the vetted IP to prevent SSRF + # (including DNS-rebinding TOCTOU): we connect to exactly the address + # that passed validation, never a freshly re-resolved one. + connect_ip = _validate_imap_host(self.server) # Port 143 typically uses STARTTLS, port 993 uses SSL direct # If use_ssl=True and port is 143, use STARTTLS instead of SSL direct @@ -104,8 +148,11 @@ class IMAPConnectionManager: if self.use_ssl and not use_starttls: # SSL direct (typically port 993) try: - self.connection = imaplib.IMAP4_SSL( - self.server, self.port, timeout=settings.IMAP_TIMEOUT + self.connection = _IPPinnedIMAP4SSL( + self.server, + self.port, + connect_ip=connect_ip, + timeout=settings.IMAP_TIMEOUT, ) except ssl.SSLError as e: # SSL handshake failed - likely wrong port or server doesn't support SSL @@ -118,8 +165,11 @@ class IMAPConnectionManager: raise IMAPSecurityError(error_msg) from e else: # Non-encrypted connection initially (will upgrade to TLS if use_ssl=True) - self.connection = imaplib.IMAP4( - self.server, self.port, timeout=settings.IMAP_TIMEOUT + self.connection = _IPPinnedIMAP4( + self.server, + self.port, + connect_ip=connect_ip, + timeout=settings.IMAP_TIMEOUT, ) if use_starttls: @@ -147,7 +197,7 @@ class IMAPConnectionManager: # else: use_ssl=False, connection remains unencrypted (explicit user choice) # Set UTF-8 encoding for the IMAP connection - self.connection._encoding = "utf-8" # noqa: SLF001 + self.connection._encoding = "utf-8" # noqa: SLF001 # pylint: disable=attribute-defined-outside-init # Login self.connection.login(self.username, self.password) diff --git a/src/backend/core/services/search/search.py b/src/backend/core/services/search/search.py index ec989581..06948dd9 100644 --- a/src/backend/core/services/search/search.py +++ b/src/backend/core/services/search/search.py @@ -41,6 +41,17 @@ def search_threads( # pylint: disable=too-many-branches logger.debug("OpenSearch search is disabled, returning empty results") return {"threads": [], "total": 0, "from": from_offset, "size": size} + # Scope is mandatory. An empty/None ``mailbox_ids`` would otherwise fall + # through to the ``if mailbox_ids:`` filter below and run an unscoped, + # cluster-wide search: the returned thread bodies are access-filtered by the + # caller, but the hit-total and pagination are not, leaking match counts and + # content-existence across every mailbox. The only caller passes the + # requesting user's accessible mailboxes, so "no mailboxes" must mean + # "no results", never "all mailboxes". + if not mailbox_ids: + logger.debug("search_threads called without mailbox_ids; returning empty") + return {"threads": [], "total": 0, "from": from_offset, "size": size} + try: # pylint: disable=too-many-nested-blocks es = get_opensearch_client() diff --git a/src/backend/core/services/ssrf.py b/src/backend/core/services/ssrf.py index 5f23322a..e58921d0 100644 --- a/src/backend/core/services/ssrf.py +++ b/src/backend/core/services/ssrf.py @@ -38,6 +38,31 @@ def _check_ip(ip_addr: ipaddress._BaseAddress, hostname: str) -> None: raise SSRFValidationError(f"{hostname} resolves to reserved address") if ip_addr.is_private: raise SSRFValidationError(f"{hostname} resolves to private IP address") + # Catch-all for anything not globally routable that the specific checks + # above miss — notably shared address space / CGNAT (100.64.0.0/10), which + # is neither is_private nor is_reserved in Python's ipaddress module. + if not ip_addr.is_global: + raise SSRFValidationError(f"{hostname} resolves to non-global address") + + +def assert_public_ip(ip: str, hostname: str = "") -> None: + """Raise ``SSRFValidationError`` unless ``ip`` is a public address. + + Companion to ``validate_hostname`` for callers that have *already* + resolved a destination to a concrete IP and dial that exact IP — e.g. + outbound SMTP, which pins an MX host's A record and connects to it + directly (so there is no DNS-rebinding window to defend, only the IP to + vet). Blocks loopback / link-local / multicast / reserved / private + ranges and the cloud-metadata endpoints, plus a final ``is_global`` + catch-all (see ``_check_ip``) that rejects any remaining non-globally- + routable address — notably CGNAT / shared address space (100.64.0.0/10), + which is neither ``is_private`` nor ``is_reserved`` in Python's ipaddress. + """ + try: + ip_addr = ipaddress.ip_address(ip) + except ValueError as exc: + raise SSRFValidationError(f"Invalid IP address {ip!r}") from exc + _check_ip(ip_addr, hostname or ip) def validate_hostname(hostname: str, *, allow_ip_literal: bool = False) -> list[str]: diff --git a/src/backend/core/tests/api/test_attachments.py b/src/backend/core/tests/api/test_attachments.py index 9cfb4f92..9f615913 100644 --- a/src/backend/core/tests/api/test_attachments.py +++ b/src/backend/core/tests/api/test_attachments.py @@ -54,6 +54,27 @@ class TestBlobAPI: ) return test_file + def test_upload_session_auth_requires_csrf_token(self, api_client, user_mailbox): + """A cookie-session upload without a CSRF token is rejected (403). + + The upload action carries no ``@csrf_exempt`` and uses the default DRF + auth classes, so ``SessionAuthentication`` enforces CSRF on + cookie-authenticated requests. Unlike the other tests (which use + ``force_authenticate`` and bypass the auth/CSRF path entirely), this + logs in via a real session and asserts the request is refused without a + token — pinning that the endpoint is not, and was never, CSRF-exempt. + """ + _, user = api_client + csrf_client = APIClient(enforce_csrf_checks=True) + csrf_client.force_login(user) # real session → SessionAuthentication path + url = reverse("blob-upload", kwargs={"mailbox_id": user_mailbox.id}) + + response = csrf_client.post( + url, {"file": self._create_test_file()}, format="multipart" + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + def test_upload_download_blob( self, api_client, diff --git a/src/backend/core/tests/api/test_inbound_mta.py b/src/backend/core/tests/api/test_inbound_mta.py index 8b015f47..ed3c780e 100644 --- a/src/backend/core/tests/api/test_inbound_mta.py +++ b/src/backend/core/tests/api/test_inbound_mta.py @@ -508,6 +508,37 @@ class TestEmailAddressParsing: ).exists() +@pytest.mark.django_db +class TestMTAJWTHardening: + """exp / body-hash guards on the shared-secret-authenticated MTA JWT.""" + + @staticmethod + def _token(body): + """Mint a token binding the given body, signed with the shared secret.""" + payload = { + "body_hash": hashlib.sha256(body).hexdigest(), + "exp": datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=30), + "original_recipients": ["recipient@example.com"], + } + return jwt.encode(payload, settings.MDA_API_SECRET, algorithm="HS256") + + def test_body_hash_enforced_on_empty_body(self, api_client): + """body_hash is checked even when the request body is empty. + + A token minted for a non-empty body but presented with an empty body + must fail — closing the old ``if request.body:`` bypass that skipped + the hash check (and let the bodyless /check path accept any token). + """ + token = self._token(b"some real body") + response = api_client.post( + "/api/v1.0/inbound/mta/deliver/", + data=b"", + content_type="message/rfc822", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + @pytest.mark.django_db class TestMTAInboundEmailThreading: """Test the threading logic for MTA inbound emails.""" diff --git a/src/backend/core/tests/api/test_inbound_widget.py b/src/backend/core/tests/api/test_inbound_widget.py index 6dd7b6f6..54c7cb95 100644 --- a/src/backend/core/tests/api/test_inbound_widget.py +++ b/src/backend/core/tests/api/test_inbound_widget.py @@ -2,7 +2,9 @@ from unittest.mock import patch +from django.core.cache import cache from django.core.exceptions import ValidationError +from django.test import override_settings import pytest from rest_framework import status @@ -10,9 +12,23 @@ from rest_framework.exceptions import AuthenticationFailed from rest_framework.test import APIClient from core import factories, models +from core.api.viewsets.inbound import widget as widget_module from core.api.viewsets.inbound.widget import WidgetAuthentication +@pytest.fixture(autouse=True) +def _clear_throttle_cache(): + """Reset throttle state between tests. + + The widget deliver endpoint is rate-limited per IP, and the test client + always presents the same address, so DRF's throttle history would + otherwise accumulate across tests in the in-process LocMem cache and trip + unrelated cases. Clearing the cache keeps each test independent. + """ + cache.clear() + yield + + @pytest.fixture(name="api_client") def fixture_api_client(): """Return an API client.""" @@ -471,3 +487,106 @@ class TestInboundWidgetDeliver: ) assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +class TestInboundWidgetAbuse: + """Throttling and body-size cap on the public widget deliver path.""" + + @patch( + "core.api.viewsets.inbound.widget.deliver_inbound_message", return_value=True + ) + def test_per_ip_throttle_blocks_flood(self, _mock_deliver, api_client, channel): + """Once the per-IP rate is exhausted, further posts get 429. + + The rate is forced low so the test is deterministic and fast. + """ + data = {"email": "sender@example.com", "textBody": "hi"} + + with patch.object( + widget_module.WidgetIPThrottle, "get_rate", return_value="1/minute" + ): + first = api_client.post( + "/api/v1.0/inbound/widget/deliver/", + data=data, + HTTP_X_CHANNEL_ID=str(channel.id), + ) + second = api_client.post( + "/api/v1.0/inbound/widget/deliver/", + data=data, + HTTP_X_CHANNEL_ID=str(channel.id), + ) + + assert first.status_code == status.HTTP_200_OK + assert second.status_code == status.HTTP_429_TOO_MANY_REQUESTS + # The throttled request must be rejected before the view runs delivery: + # only the first (200) post reached deliver_inbound_message. + _mock_deliver.assert_called_once() + + @patch( + "core.api.viewsets.inbound.widget.deliver_inbound_message", return_value=True + ) + def test_per_channel_throttle_blocks_flood( + self, _mock_deliver, api_client, channel + ): + """The per-channel cap trips even when the per-IP cap is generous.""" + data = {"email": "sender@example.com", "textBody": "hi"} + + with ( + patch.object( + widget_module.WidgetChannelThrottle, "get_rate", return_value="1/minute" + ), + patch.object( + widget_module.WidgetIPThrottle, "get_rate", return_value="1000/minute" + ), + ): + first = api_client.post( + "/api/v1.0/inbound/widget/deliver/", + data=data, + HTTP_X_CHANNEL_ID=str(channel.id), + ) + second = api_client.post( + "/api/v1.0/inbound/widget/deliver/", + data=data, + HTTP_X_CHANNEL_ID=str(channel.id), + ) + + assert first.status_code == status.HTTP_200_OK + assert second.status_code == status.HTTP_429_TOO_MANY_REQUESTS + + @patch( + "core.api.viewsets.inbound.widget.deliver_inbound_message", return_value=True + ) + @override_settings(MAX_INCOMING_EMAIL_SIZE=1024) + def test_oversized_body_rejected(self, mock_deliver, api_client, channel): + """A body over MAX_INCOMING_EMAIL_SIZE is rejected before delivery.""" + data = { + "email": "sender@example.com", + "textBody": "x" * 2048, # exceeds the 1 KB limit + } + + response = api_client.post( + "/api/v1.0/inbound/widget/deliver/", + data=data, + HTTP_X_CHANNEL_ID=str(channel.id), + ) + + assert response.status_code == status.HTTP_413_REQUEST_ENTITY_TOO_LARGE + mock_deliver.assert_not_called() + + @patch( + "core.api.viewsets.inbound.widget.deliver_inbound_message", return_value=True + ) + @override_settings(MAX_INCOMING_EMAIL_SIZE=1024) + def test_body_within_limit_accepted(self, mock_deliver, api_client, channel): + """A body within the limit still goes through.""" + data = {"email": "sender@example.com", "textBody": "x" * 100} + + response = api_client.post( + "/api/v1.0/inbound/widget/deliver/", + data=data, + HTTP_X_CHANNEL_ID=str(channel.id), + ) + + assert response.status_code == status.HTTP_200_OK + mock_deliver.assert_called_once() diff --git a/src/backend/core/tests/api/test_messages_create.py b/src/backend/core/tests/api/test_messages_create.py index 19273c18..366ffaf7 100644 --- a/src/backend/core/tests/api/test_messages_create.py +++ b/src/backend/core/tests/api/test_messages_create.py @@ -65,7 +65,12 @@ class TestApiDraftAndSendMessage: @patch("core.mda.outbound.send_outbound_message") def test_draft_and_send_message_success( - self, mock_send_outbound_message, mailbox, authenticated_user, send_url + self, + mock_send_outbound_message, + mailbox, + authenticated_user, + send_url, + django_capture_on_commit_callbacks, ): """Test create draft message and then successfully send it via the service.""" @@ -151,15 +156,16 @@ class TestApiDraftAndSendMessage: assert draft_api_message["is_draft"] is True assert draft_api_message["bcc"][0]["contact"]["email"] == "jean@external.com" - send_response = client.post( - send_url, - { - "messageId": draft_message_id, - "senderId": mailbox.id, - "textBody": "test", - }, - format="json", - ) + with django_capture_on_commit_callbacks(execute=True): + send_response = client.post( + send_url, + { + "messageId": draft_message_id, + "senderId": mailbox.id, + "textBody": "test", + }, + format="json", + ) assert send_response.status_code == status.HTTP_200_OK @@ -234,7 +240,12 @@ class TestApiDraftAndSendMessage: @patch("core.mda.outbound.send_outbound_message") def test_draft_and_send_message_success_delegated_access( - self, mock_send_outbound_message, mailbox, authenticated_user, send_url + self, + mock_send_outbound_message, + mailbox, + authenticated_user, + send_url, + django_capture_on_commit_callbacks, ): """Test create draft message and then successfully send it via the service.""" mock_send_outbound_message.side_effect = ( @@ -332,14 +343,15 @@ class TestApiDraftAndSendMessage: assert draft_api_message["draftBody"] == draft_content assert draft_api_message["is_draft"] is True - send_response = client.post( - send_url, - { - "messageId": draft_message_id, - "senderId": mailbox.id, - }, - format="json", - ) + with django_capture_on_commit_callbacks(execute=True): + send_response = client.post( + send_url, + { + "messageId": draft_message_id, + "senderId": mailbox.id, + }, + format="json", + ) assert send_response.status_code == status.HTTP_200_OK @@ -383,6 +395,7 @@ class TestApiDraftAndSendMessage: mailbox, authenticated_user, send_url, + django_capture_on_commit_callbacks, ): """Test sending a draft message when the delivery service fails.""" @@ -430,15 +443,16 @@ class TestApiDraftAndSendMessage: assert draft_response.status_code == status.HTTP_201_CREATED draft_message_id = draft_response.data["id"] - send_response = client.post( - send_url, - { - "messageId": draft_message_id, - "senderId": str(mailbox.id), - "textBody": "test", - }, - format="json", - ) + with django_capture_on_commit_callbacks(execute=True): + send_response = client.post( + send_url, + { + "messageId": draft_message_id, + "senderId": str(mailbox.id), + "textBody": "test", + }, + format="json", + ) assert send_response.status_code == status.HTTP_200_OK @@ -787,7 +801,12 @@ class TestApiDraftAndSendMessage: @patch("core.mda.outbound.send_outbound_message") def test_send_message_with_empty_subject( - self, mock_send_outbound_message, mailbox, authenticated_user, send_url + self, + mock_send_outbound_message, + mailbox, + authenticated_user, + send_url, + django_capture_on_commit_callbacks, ): """Test sending a message with empty subject (migration 0018).""" mock_send_outbound_message.side_effect = ( @@ -825,14 +844,15 @@ class TestApiDraftAndSendMessage: draft_message_id = draft_response.data["id"] # Send the message - send_response = client.post( - send_url, - { - "messageId": draft_message_id, - "senderId": mailbox.id, - }, - format="json", - ) + with django_capture_on_commit_callbacks(execute=True): + send_response = client.post( + send_url, + { + "messageId": draft_message_id, + "senderId": mailbox.id, + }, + format="json", + ) assert send_response.status_code == status.HTTP_200_OK @@ -1099,7 +1119,13 @@ class TestApiDraftAndSendMessage: class TestApiDraftAndSendReply: """Test API draft and send reply endpoints.""" - def test_draft_and_send_reply_success(self, mailbox, authenticated_user, send_url): + def test_draft_and_send_reply_success( + self, + mailbox, + authenticated_user, + send_url, + django_capture_on_commit_callbacks, + ): """Create draft reply to an existing message and then send it.""" # Create a mailbox access on this mailbox for the authenticated user factories.MailboxAccessFactory( @@ -1150,15 +1176,16 @@ class TestApiDraftAndSendReply: assert draft_api_message["parent_id"] == str(message.id) # Step 2: Send the draft reply - send_response = client.post( - send_url, - { - "messageId": draft_message.id, - "senderId": mailbox.id, - "textBody": "test", - }, - format="json", - ) + with django_capture_on_commit_callbacks(execute=True): + send_response = client.post( + send_url, + { + "messageId": draft_message.id, + "senderId": mailbox.id, + "textBody": "test", + }, + format="json", + ) # Assert the send response is successful assert send_response.status_code == status.HTTP_200_OK @@ -1307,6 +1334,7 @@ class TestApiDraftAndSendReply: mailbox_role, draft_detail_url, send_url, + django_capture_on_commit_callbacks, ): """Test updating a draft message successfully.""" # Create a mailbox access on this mailbox for the authenticated user @@ -1395,14 +1423,15 @@ class TestApiDraftAndSendReply: # assert thread.snippet == "updated content"[:100] # Step 3: Send the updated draft message - send_response = client.post( - send_url, - { - "messageId": updated_message.id, - "senderId": mailbox.id, - }, - format="json", - ) + with django_capture_on_commit_callbacks(execute=True): + send_response = client.post( + send_url, + { + "messageId": updated_message.id, + "senderId": mailbox.id, + }, + format="json", + ) sent_message = models.Message.objects.get(id=updated_message.id) assert sent_message.subject == updated_subject @@ -1510,7 +1539,58 @@ class TestApiDraftAndSendReply: # Assert the response is unauthorized assert response.status_code == status.HTTP_401_UNAUTHORIZED - def test_api_email_exchange_single_thread(self, send_url): + def test_update_draft_ignores_body_message_id_for_authorization( + self, mailbox, authenticated_user, draft_detail_url + ): + """The draft being updated is the one in the URL, not a body messageId. + + A caller with edit rights on a draft they own must not edit a + *different* draft (named in the URL) that their sender mailbox cannot + access by passing the accessible draft's id in the body. The body id is + never read for authorization, so the inaccessible URL draft is denied + (the view scopes the lookup to an editable thread → 404). + """ + # Sender mailbox the user can edit. + factories.MailboxAccessFactory( + mailbox=mailbox, + user=authenticated_user, + role=enums.MailboxRoleChoices.EDITOR, + ) + # A draft the sender mailbox CAN edit (the decoy passed in the body). + own_access = factories.ThreadAccessFactory( + mailbox=mailbox, role=enums.ThreadAccessRoleChoices.EDITOR + ) + own_draft = factories.MessageFactory(thread=own_access.thread, is_draft=True) + # A draft the sender mailbox CANNOT access (the real target, in the URL). + other_mailbox = factories.MailboxFactory() + victim_access = factories.ThreadAccessFactory( + mailbox=other_mailbox, role=enums.ThreadAccessRoleChoices.EDITOR + ) + victim_draft = factories.MessageFactory( + thread=victim_access.thread, is_draft=True, subject="victim" + ) + + client = APIClient() + client.force_authenticate(user=authenticated_user) + response = client.put( + draft_detail_url(victim_draft.id), + { + "senderId": mailbox.id, + "messageId": own_draft.id, # decoy — must be ignored + "subject": "hijacked", + }, + format="json", + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + victim_draft.refresh_from_db() + assert victim_draft.subject == "victim" + + def test_api_email_exchange_single_thread( + self, + send_url, + django_capture_on_commit_callbacks, + ): """Test a multi-step API email exchange results in one thread per mailbox.""" # Setup Users and Mailboxes user1 = factories.UserFactory(email="user1@exchange.api") @@ -1543,15 +1623,16 @@ class TestApiDraftAndSendReply: assert draft1_response.status_code == status.HTTP_201_CREATED message1_id = draft1_response.data["id"] - send1_response = client.post( - send_url, - { - "messageId": message1_id, - "senderId": str(mailbox1.id), - "textBody": "Hello User Two!", - }, - format="json", - ) + with django_capture_on_commit_callbacks(execute=True): + send1_response = client.post( + send_url, + { + "messageId": message1_id, + "senderId": str(mailbox1.id), + "textBody": "Hello User Two!", + }, + format="json", + ) assert send1_response.status_code == status.HTTP_200_OK # Message should be marked as sent immediately for local delivery @@ -1595,15 +1676,16 @@ class TestApiDraftAndSendReply: assert draft2_response.status_code == status.HTTP_201_CREATED message2_id = draft2_response.data["id"] - send2_response = client.post( - send_url, - { - "messageId": message2_id, - "senderId": str(mailbox2.id), - "textBody": "Hi User One, thanks!", - }, - format="json", - ) + with django_capture_on_commit_callbacks(execute=True): + send2_response = client.post( + send_url, + { + "messageId": message2_id, + "senderId": str(mailbox2.id), + "textBody": "Hi User One, thanks!", + }, + format="json", + ) assert send2_response.status_code == status.HTTP_200_OK # Mark message as sent (local delivery) @@ -1651,11 +1733,12 @@ class TestApiDraftAndSendReply: assert draft3_response.status_code == status.HTTP_201_CREATED message3_id = draft3_response.data["id"] - send3_response = client.post( - send_url, - {"messageId": message3_id, "senderId": str(mailbox1.id)}, - format="json", - ) + with django_capture_on_commit_callbacks(execute=True): + send3_response = client.post( + send_url, + {"messageId": message3_id, "senderId": str(mailbox1.id)}, + format="json", + ) assert send3_response.status_code == status.HTTP_200_OK assert models.Thread.objects.count() == 2 # Still only 2 threads @@ -1687,7 +1770,12 @@ class TestApiDraftAndSendReply: assert models.Thread.objects.filter(accesses__mailbox=mailbox2).count() == 1 def test_send_message_with_user_having_role_on_two_mailboxes_on_same_thread( - self, mailbox, mailbox2, authenticated_user, send_url + self, + mailbox, + mailbox2, + authenticated_user, + send_url, + django_capture_on_commit_callbacks, ): """ Test that sending a message succeeds when a user has access to two mailboxes @@ -1753,15 +1841,16 @@ class TestApiDraftAndSendReply: assert draft_api_message["parent_id"] == str(message.id) # Step 2: Send the draft reply - send_response = client.post( - send_url, - { - "messageId": draft_message.id, - "senderId": mailbox.id, - "textBody": "test", - }, - format="json", - ) + with django_capture_on_commit_callbacks(execute=True): + send_response = client.post( + send_url, + { + "messageId": draft_message.id, + "senderId": mailbox.id, + "textBody": "test", + }, + format="json", + ) # Assert the send response is successful assert send_response.status_code == status.HTTP_200_OK diff --git a/src/backend/core/tests/api/test_messages_import.py b/src/backend/core/tests/api/test_messages_import.py index 5e1211ae..032b4683 100644 --- a/src/backend/core/tests/api/test_messages_import.py +++ b/src/backend/core/tests/api/test_messages_import.py @@ -332,7 +332,7 @@ def test_api_import_imap(api_client, user, mailbox): """Test import of IMAP messages.""" mailbox.accesses.create(user=user, role=MailboxRoleChoices.ADMIN) # Mock IMAP connection and responses - with patch("imaplib.IMAP4_SSL") as mock_imap: + with patch("core.services.importer.imap._IPPinnedIMAP4SSL") as mock_imap: mock_imap_instance = mock_imap.return_value # Mock login @@ -706,7 +706,7 @@ def test_api_import_duplicate_imap_messages(api_client, user, mailbox): assert Thread.objects.count() == 0 # Mock IMAP connection and responses - with patch("imaplib.IMAP4_SSL") as mock_imap: + with patch("core.services.importer.imap._IPPinnedIMAP4SSL") as mock_imap: mock_imap_instance = mock_imap.return_value # Mock login @@ -781,7 +781,7 @@ def test_api_import_duplicate_imap_messages_different_mailboxes( mailbox2 = factories.MailboxFactory() mailbox2.accesses.create(user=user, role=MailboxRoleChoices.ADMIN) # Mock IMAP connection and responses - with patch("imaplib.IMAP4_SSL") as mock_imap: + with patch("core.services.importer.imap._IPPinnedIMAP4SSL") as mock_imap: mock_imap_instance = mock_imap.return_value # Mock login @@ -869,7 +869,7 @@ Date: Mon, 26 May 2025 10:00:00 +0000 This is a draft message.""" # Mock IMAP connection and responses - with patch("imaplib.IMAP4_SSL") as mock_imap: + with patch("core.services.importer.imap._IPPinnedIMAP4SSL") as mock_imap: mock_imap_instance = mock_imap.return_value # Mock login @@ -935,7 +935,7 @@ Date: Mon, 26 May 2025 10:00:00 +0000 This is a regular message.""" # Mock IMAP connection and responses - with patch("imaplib.IMAP4_SSL") as mock_imap: + with patch("core.services.importer.imap._IPPinnedIMAP4SSL") as mock_imap: mock_imap_instance = mock_imap.return_value # Mock login diff --git a/src/backend/core/tests/api/test_send_message_signature.py b/src/backend/core/tests/api/test_send_message_signature.py index 2095e1dc..fdd7e603 100644 --- a/src/backend/core/tests/api/test_send_message_signature.py +++ b/src/backend/core/tests/api/test_send_message_signature.py @@ -2,6 +2,7 @@ # pylint: disable=unused-argument +import uuid from unittest.mock import MagicMock, patch from django.test import override_settings @@ -132,7 +133,9 @@ class TestSendMessageAPIView: ) assert response.status_code == status.HTTP_200_OK - assert response.data["task_id"] == "task-123" + assert uuid.UUID( + response.data["task_id"] + ) # view-generated id, dispatched on commit message = models.Message.objects.get(id=draft_message.id) content = message.blob.get_content().decode() @@ -175,7 +178,9 @@ class TestSendMessageAPIView: ) assert response.status_code == status.HTTP_200_OK - assert response.data["task_id"] == "task-123" + assert uuid.UUID( + response.data["task_id"] + ) # view-generated id, dispatched on commit @override_settings(SCHEMA_CUSTOM_ATTRIBUTES_USER=SCHEMA_CUSTOM_ATTRIBUTES) def test_api_send_message_with_text_body_only( @@ -212,7 +217,9 @@ class TestSendMessageAPIView: ) assert response.status_code == status.HTTP_200_OK - assert response.data["task_id"] == "task-123" + assert uuid.UUID( + response.data["task_id"] + ) # view-generated id, dispatched on commit message = models.Message.objects.get(id=draft_message.id) content = message.blob.get_content().decode() @@ -257,7 +264,9 @@ class TestSendMessageAPIView: ) assert response.status_code == status.HTTP_200_OK - assert response.data["task_id"] == "task-123" + assert uuid.UUID( + response.data["task_id"] + ) # view-generated id, dispatched on commit message = models.Message.objects.get(id=draft_message.id) content = message.blob.get_content().decode() @@ -312,7 +321,9 @@ class TestSendMessageAPIView: ) assert response.status_code == status.HTTP_200_OK - assert response.data["task_id"] == "task-123" + assert uuid.UUID( + response.data["task_id"] + ) # view-generated id, dispatched on commit message = models.Message.objects.get(id=draft_message.id) content = message.blob.get_content().decode() @@ -329,6 +340,7 @@ class TestSendMessageAPIView: mailbox, draft_message, signature_template, + django_capture_on_commit_callbacks, ): """Test sending a message with archive=True passes the parameter to the task.""" # Authenticate user @@ -337,31 +349,38 @@ class TestSendMessageAPIView: # Mock the send_message_task with patch("core.api.viewsets.send.send_message_task") as mock_task: - mock_task_instance = MagicMock() - mock_task_instance.id = "task-123" - mock_task.delay.return_value = mock_task_instance - - # Send request with HTML body only - response = client.post( - reverse("send-message"), - format="json", - data={ - "messageId": str(draft_message.id), - "senderId": str(mailbox.id), - "htmlBody": "

Hello world!

", - "archive": True, - }, - ) + # Send request with HTML body only. The delivery task is dispatched + # via transaction.on_commit, so capture and run the callbacks. + with django_capture_on_commit_callbacks(execute=True): + response = client.post( + reverse("send-message"), + format="json", + data={ + "messageId": str(draft_message.id), + "senderId": str(mailbox.id), + "htmlBody": "

Hello world!

", + "archive": True, + }, + ) assert response.status_code == status.HTTP_200_OK - assert response.data["task_id"] == "task-123" + assert uuid.UUID( + response.data["task_id"] + ) # view-generated id, dispatched on commit - mock_task.delay.assert_called_once_with( - str(draft_message.id), must_archive=True + mock_task.apply_async.assert_called_once_with( + args=[str(draft_message.id)], + kwargs={"must_archive": True}, + task_id=response.data["task_id"], ) def test_api_send_message_with_archive_false( - self, user, mailbox_access, mailbox, draft_message + self, + user, + mailbox_access, + mailbox, + draft_message, + django_capture_on_commit_callbacks, ): """Test sending a message with archive=False passes the parameter to the task.""" # Authenticate user @@ -370,33 +389,39 @@ class TestSendMessageAPIView: # Mock the send_message_task with patch("core.api.viewsets.send.send_message_task") as mock_task: - mock_task_instance = MagicMock() - mock_task_instance.id = "task-123" - mock_task.delay.return_value = mock_task_instance - # Send request with archive=False - response = client.post( - reverse("send-message"), - format="json", - data={ - "messageId": str(draft_message.id), - "senderId": str(mailbox.id), - "textBody": "Hello world!", - "htmlBody": "

Hello world!

", - "archive": False, - }, - ) + with django_capture_on_commit_callbacks(execute=True): + response = client.post( + reverse("send-message"), + format="json", + data={ + "messageId": str(draft_message.id), + "senderId": str(mailbox.id), + "textBody": "Hello world!", + "htmlBody": "

Hello world!

", + "archive": False, + }, + ) assert response.status_code == status.HTTP_200_OK - assert response.data["task_id"] == "task-123" + assert uuid.UUID( + response.data["task_id"] + ) # view-generated id, dispatched on commit - # Verify the task was called with must_archive=False - mock_task.delay.assert_called_once_with( - str(draft_message.id), must_archive=False + # Verify the task was dispatched with must_archive=False + mock_task.apply_async.assert_called_once_with( + args=[str(draft_message.id)], + kwargs={"must_archive": False}, + task_id=response.data["task_id"], ) def test_api_send_message_without_archive_parameter( - self, user, mailbox_access, mailbox, draft_message + self, + user, + mailbox_access, + mailbox, + draft_message, + django_capture_on_commit_callbacks, ): """Test sending a message without archive parameter defaults to False.""" # Authenticate user @@ -405,26 +430,123 @@ class TestSendMessageAPIView: # Mock the send_message_task with patch("core.api.viewsets.send.send_message_task") as mock_task: - mock_task_instance = MagicMock() - mock_task_instance.id = "task-123" - mock_task.delay.return_value = mock_task_instance - # Send request without archive parameter - response = client.post( - reverse("send-message"), - format="json", - data={ - "messageId": str(draft_message.id), - "senderId": str(mailbox.id), - "textBody": "Hello world!", - "htmlBody": "

Hello world!

", - }, - ) + with django_capture_on_commit_callbacks(execute=True): + response = client.post( + reverse("send-message"), + format="json", + data={ + "messageId": str(draft_message.id), + "senderId": str(mailbox.id), + "textBody": "Hello world!", + "htmlBody": "

Hello world!

", + }, + ) assert response.status_code == status.HTTP_200_OK - assert response.data["task_id"] == "task-123" + assert uuid.UUID( + response.data["task_id"] + ) # view-generated id, dispatched on commit - # Verify the task was called with must_archive=False (default) - mock_task.delay.assert_called_once_with( - str(draft_message.id), must_archive=False + # Verify the task was dispatched with must_archive=False (default) + mock_task.apply_async.assert_called_once_with( + args=[str(draft_message.id)], + kwargs={"must_archive": False}, + task_id=response.data["task_id"], ) + + +class TestSendMessageSecurity: + """Security regressions for SendMessageView.""" + + def test_cannot_send_as_mailbox_user_only_views( + self, user, mailbox, draft_message, django_capture_on_commit_callbacks + ): + """A VIEWER on the sender mailbox cannot send as it by leaning on a + SENDER role held on a *different* mailbox that shares the thread. + + Setup: + - ``mailbox`` (the senderId, "B"): the draft lives here, user is VIEWER. + - ``other_mailbox`` ("A"): user is SENDER, and it also has EDITOR + ThreadAccess on the same thread. + + The old object-level check passed as long as the user could SEND + through *any* EDITOR mailbox on the thread — so A's SENDER role would + wrongly authorise sending as B. The fix re-checks the role on the + specific senderId, so this must be 403. + """ + # User is only a VIEWER on the sender mailbox B. + factories.MailboxAccessFactory( + mailbox=mailbox, user=user, role=enums.MailboxRoleChoices.VIEWER + ) + + # User is SENDER on a different mailbox A that also edits the thread. + other_mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=other_mailbox, user=user, role=enums.MailboxRoleChoices.SENDER + ) + factories.ThreadAccessFactory( + mailbox=other_mailbox, + thread=draft_message.thread, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + + client = APIClient() + client.force_authenticate(user=user) + + with patch("core.api.viewsets.send.send_message_task") as mock_task: + with django_capture_on_commit_callbacks(execute=True): + response = client.post( + reverse("send-message"), + format="json", + data={ + "messageId": str(draft_message.id), + "senderId": str(mailbox.id), # send AS B + "textBody": "Hello world!", + }, + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + # Nothing was dispatched. + mock_task.apply_async.assert_not_called() + mock_task.delay.assert_not_called() + + def test_send_task_dispatched_only_after_commit( + self, + user, + mailbox_access, + mailbox, + draft_message, + django_capture_on_commit_callbacks, + ): + """The delivery task is registered on transaction.on_commit, not fired + inline — so a rolled-back send never leaks a task to the broker. + """ + client = APIClient() + client.force_authenticate(user=user) + + with patch("core.api.viewsets.send.send_message_task") as mock_task: + # execute=False: capture the on_commit callbacks without running them. + with django_capture_on_commit_callbacks(execute=False) as callbacks: + response = client.post( + reverse("send-message"), + format="json", + data={ + "messageId": str(draft_message.id), + "senderId": str(mailbox.id), + "textBody": "Hello world!", + }, + ) + + assert response.status_code == status.HTTP_200_OK + # Inside the request/transaction the task must NOT be dispatched. + mock_task.apply_async.assert_not_called() + + # The send dispatch was deferred to commit (other unrelated + # on_commit hooks, e.g. search reindex, may also be present). + send_callbacks = [ + cb + for cb in callbacks + if "SendMessageView" in getattr(cb, "__qualname__", "") + ] + assert len(send_callbacks) == 1 diff --git a/src/backend/core/tests/api/test_submit.py b/src/backend/core/tests/api/test_submit.py index a5a0eb2b..3ab00c4c 100644 --- a/src/backend/core/tests/api/test_submit.py +++ b/src/backend/core/tests/api/test_submit.py @@ -364,19 +364,29 @@ class TestSubmitDispatch: @patch(PREPARE_MOCK, return_value=True) @patch(CREATE_MSG_MOCK) def test_accepted( - self, mock_create, mock_prepare, mock_task, client, auth_header, mailbox + self, + mock_create, + mock_prepare, + mock_task, + client, + auth_header, + mailbox, + django_capture_on_commit_callbacks, ): fake_message = self._fake_message() mock_create.return_value = fake_message - response = client.post( - SUBMIT_URL, - data=MINIMAL_MIME, - content_type="message/rfc822", - HTTP_X_MAIL_FROM=str(mailbox.id), - HTTP_X_RCPT_TO="attendee@example.com", - **auth_header, - ) + # The delivery task is dispatched via transaction.on_commit, so capture + # and run the callbacks to observe the dispatch. + with django_capture_on_commit_callbacks(execute=True) as callbacks: + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="attendee@example.com", + **auth_header, + ) assert response.status_code == 202 data = response.json() @@ -392,7 +402,8 @@ class TestSubmitDispatch: mock_prepare.assert_called_once() assert mock_prepare.call_args[1]["raw_mime"] == MINIMAL_MIME - # Async task dispatched + # Async task dispatched, and only after the transaction committed. + assert len(callbacks) == 1 mock_task.delay.assert_called_once_with(str(fake_message.id)) @patch(TASK_MOCK) @@ -450,20 +461,28 @@ class TestSubmitIntegration: signing, blob storage) and only mock the final async SMTP task.""" @patch(TASK_MOCK) - def test_full_pipeline(self, mock_task, client, auth_header, mailbox): + def test_full_pipeline( + self, + mock_task, + client, + auth_header, + mailbox, + django_capture_on_commit_callbacks, + ): """Submit creates a Message with thread, recipients, blob, and dispatches delivery.""" mailbox_email = str(mailbox) # X-Rcpt-To matches the To: header in MINIMAL_MIME (attendee@example.com) rcpt_to = "attendee@example.com" - response = client.post( - SUBMIT_URL, - data=MINIMAL_MIME, - content_type="message/rfc822", - HTTP_X_MAIL_FROM=str(mailbox.id), - HTTP_X_RCPT_TO=rcpt_to, - **auth_header, - ) + with django_capture_on_commit_callbacks(execute=True): + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO=rcpt_to, + **auth_header, + ) assert response.status_code == 202 data = response.json() diff --git a/src/backend/core/tests/api/test_threads_list.py b/src/backend/core/tests/api/test_threads_list.py index c88801da..b4dc3c05 100644 --- a/src/backend/core/tests/api/test_threads_list.py +++ b/src/backend/core/tests/api/test_threads_list.py @@ -5,7 +5,7 @@ from datetime import timedelta from unittest import mock from django.db import connection -from django.test.utils import CaptureQueriesContext +from django.test.utils import CaptureQueriesContext, override_settings from django.urls import reverse from django.utils import timezone @@ -1801,6 +1801,50 @@ class TestThreadListAPI: assert response.status_code == status.HTTP_403_FORBIDDEN + @override_settings(OPENSEARCH_HOSTS=["http://opensearch:9200"]) + def test_search_without_mailbox_id_scopes_to_accessible_mailboxes( + self, api_client, url + ): + """A search with no mailbox_id must be scoped to the user's own + mailboxes — never run cluster-wide (which would leak hit-totals / + content-existence across every mailbox).""" + user = UserFactory() + api_client.force_authenticate(user=user) + + mbx_a = MailboxFactory(users_read=[user]) + mbx_b = MailboxFactory(users_read=[user]) + # A mailbox the user cannot access — must not be in the search scope. + MailboxFactory(users_read=[UserFactory()]) + + with mock.patch("core.api.viewsets.thread.search_threads") as mock_search: + mock_search.return_value = {"threads": [], "total": 0} + response = api_client.get(url, {"search": "test query"}) + + assert response.status_code == status.HTTP_200_OK + passed_mailbox_ids = mock_search.call_args.kwargs["mailbox_ids"] + # Scoped to exactly the user's accessible mailboxes, and never None. + assert passed_mailbox_ids is not None + assert set(passed_mailbox_ids) == {str(mbx_a.id), str(mbx_b.id)} + + @override_settings(OPENSEARCH_HOSTS=["http://opensearch:9200"]) + def test_search_without_mailbox_id_and_no_access_passes_empty_scope( + self, api_client, url + ): + """A user with no mailbox access searches an empty scope (not the whole + cluster). The viewset passes [] — which search_threads treats as + 'no results' rather than 'all mailboxes'.""" + user = UserFactory() + api_client.force_authenticate(user=user) + # Some other user's mailbox exists, but ours has none. + MailboxFactory(users_read=[UserFactory()]) + + with mock.patch("core.api.viewsets.thread.search_threads") as mock_search: + mock_search.return_value = {"threads": [], "total": 0} + response = api_client.get(url, {"search": "test query"}) + + assert response.status_code == status.HTTP_200_OK + assert mock_search.call_args.kwargs["mailbox_ids"] == [] + class TestThreadListEventsCount: """Test that ThreadSerializer exposes events_count on the list endpoint. diff --git a/src/backend/core/tests/importer/test_imap_connection.py b/src/backend/core/tests/importer/test_imap_connection.py index 4c786341..8606a7c8 100644 --- a/src/backend/core/tests/importer/test_imap_connection.py +++ b/src/backend/core/tests/importer/test_imap_connection.py @@ -1,6 +1,6 @@ """Tests for IMAP connection manager and security features.""" -# pylint: disable=redefined-outer-name,invalid-name +# pylint: disable=redefined-outer-name,invalid-name,protected-access import imaplib import ssl @@ -8,17 +8,80 @@ from unittest.mock import MagicMock, patch import pytest -from core.services.importer.imap import IMAPConnectionManager, IMAPSecurityError +from core.services.importer.imap import ( + IMAPConnectionManager, + IMAPSecurityError, + _IPPinnedIMAP4, + _IPPinnedIMAP4SSL, + _validate_imap_host, +) +from core.services.ssrf import SSRFValidationError # Store reference to the real error class before any patching # This is needed because patching imaplib.IMAP4 affects the module globally IMAP4_ERROR = imaplib.IMAP4.error +class TestIMAPSSRFPinning: + """The IMAP importer connects to the validated IP, not a re-resolved + hostname — closing the DNS-rebinding (TOCTOU) SSRF window.""" + + def test_validate_imap_host_returns_first_validated_ip(self): + """The first validated IP is the address pinned for the connection.""" + with patch( + "core.services.importer.imap.validate_hostname", + return_value=["203.0.113.5", "203.0.113.6"], + ): + assert _validate_imap_host("imap.example.com") == "203.0.113.5" + + def test_validate_imap_host_rejects_blocked_address(self): + """A host resolving to a blocked address raises ValueError.""" + with patch( + "core.services.importer.imap.validate_hostname", + side_effect=SSRFValidationError("resolves to private IP address"), + ): + with pytest.raises(ValueError, match="not allowed"): + _validate_imap_host("internal.evil.test") + + def test_pinned_imap4_dials_validated_ip(self): + """Plain IMAP4 connects to the pinned IP, never re-resolving the host.""" + inst = _IPPinnedIMAP4.__new__(_IPPinnedIMAP4) + inst._connect_ip = "203.0.113.5" + inst.port = 143 + fake_sock = MagicMock() + with patch( + "core.services.importer.imap.socket.create_connection", + return_value=fake_sock, + ) as mock_conn: + result = inst._create_socket(30) + mock_conn.assert_called_once_with(("203.0.113.5", 143), 30) + assert result is fake_sock + + def test_pinned_imap4ssl_pins_ip_and_verifies_hostname(self): + """SSL: dial the pinned IP but verify the cert against the hostname.""" + inst = _IPPinnedIMAP4SSL.__new__(_IPPinnedIMAP4SSL) + inst._connect_ip = "203.0.113.5" + inst.port = 993 + inst.host = "imap.example.com" + inst.ssl_context = MagicMock() + raw_sock, wrapped = MagicMock(), MagicMock() + inst.ssl_context.wrap_socket.return_value = wrapped + with patch( + "core.services.importer.imap.socket.create_connection", + return_value=raw_sock, + ) as mock_conn: + result = inst._create_socket(30) + mock_conn.assert_called_once_with(("203.0.113.5", 993), 30) + inst.ssl_context.wrap_socket.assert_called_once_with( + raw_sock, server_hostname="imap.example.com" + ) + assert result is wrapped + + class TestIMAPConnectionManagerSSLDirect: """Tests for SSL direct connections (typically port 993).""" - @patch("core.services.importer.imap.imaplib.IMAP4_SSL") + @patch("core.services.importer.imap._IPPinnedIMAP4SSL") def test_ssl_direct_success(self, mock_imap4_ssl): """Test successful SSL direct connection on port 993.""" mock_conn = MagicMock() @@ -35,7 +98,7 @@ class TestIMAPConnectionManagerSSLDirect: mock_imap4_ssl.assert_called_once() mock_conn.login.assert_called_once_with("user@example.com", "password") - @patch("core.services.importer.imap.imaplib.IMAP4_SSL") + @patch("core.services.importer.imap._IPPinnedIMAP4SSL") def test_ssl_direct_handshake_failure(self, mock_imap4_ssl): """Test SSL handshake failure raises IMAPSecurityError.""" mock_imap4_ssl.side_effect = ssl.SSLError("handshake failed") @@ -57,7 +120,7 @@ class TestIMAPConnectionManagerSSLDirect: class TestIMAPConnectionManagerSTARTTLS: """Tests for STARTTLS connections (typically port 143 with use_ssl=True).""" - @patch("core.services.importer.imap.imaplib.IMAP4") + @patch("core.services.importer.imap._IPPinnedIMAP4") def test_starttls_success(self, mock_imap4): """Test successful STARTTLS upgrade on port 143.""" mock_conn = MagicMock() @@ -77,7 +140,7 @@ class TestIMAPConnectionManagerSTARTTLS: mock_conn.starttls.assert_called_once() mock_conn.login.assert_called_once_with("user@example.com", "password") - @patch("core.services.importer.imap.imaplib.IMAP4") + @patch("core.services.importer.imap._IPPinnedIMAP4") def test_starttls_not_supported(self, mock_imap4): """Test STARTTLS not supported raises IMAPSecurityError.""" mock_conn = MagicMock() @@ -98,7 +161,7 @@ class TestIMAPConnectionManagerSTARTTLS: assert "does not support STARTTLS" in str(exc_info.value) mock_conn.logout.assert_called_once() - @patch("core.services.importer.imap.imaplib.IMAP4") + @patch("core.services.importer.imap._IPPinnedIMAP4") def test_starttls_negotiation_failure(self, mock_imap4): """Test STARTTLS negotiation failure raises IMAPSecurityError.""" mock_conn = MagicMock() @@ -119,7 +182,7 @@ class TestIMAPConnectionManagerSTARTTLS: assert "STARTTLS failed" in str(exc_info.value) mock_conn.logout.assert_called_once() - @patch("core.services.importer.imap.imaplib.IMAP4") + @patch("core.services.importer.imap._IPPinnedIMAP4") def test_starttls_capability_empty_response(self, mock_imap4): """Test STARTTLS with empty capability response raises IMAPSecurityError.""" mock_conn = MagicMock() @@ -139,7 +202,7 @@ class TestIMAPConnectionManagerSTARTTLS: assert "does not support STARTTLS" in str(exc_info.value) - @patch("core.services.importer.imap.imaplib.IMAP4") + @patch("core.services.importer.imap._IPPinnedIMAP4") def test_starttls_capability_none_response(self, mock_imap4): """Test STARTTLS with None capability response raises IMAPSecurityError.""" mock_conn = MagicMock() @@ -163,7 +226,7 @@ class TestIMAPConnectionManagerSTARTTLS: class TestIMAPConnectionManagerUnencrypted: """Tests for unencrypted connections (use_ssl=False).""" - @patch("core.services.importer.imap.imaplib.IMAP4") + @patch("core.services.importer.imap._IPPinnedIMAP4") def test_unencrypted_connection(self, mock_imap4): """Test unencrypted connection when use_ssl=False.""" mock_conn = MagicMock() @@ -185,7 +248,7 @@ class TestIMAPConnectionManagerUnencrypted: class TestIMAPConnectionManagerAuthentication: """Tests for authentication handling.""" - @patch("core.services.importer.imap.imaplib.IMAP4_SSL") + @patch("core.services.importer.imap._IPPinnedIMAP4SSL") def test_authentication_failure_cleanup(self, mock_imap4_ssl): """Test connection is cleaned up after authentication failure.""" mock_conn = MagicMock() @@ -205,7 +268,7 @@ class TestIMAPConnectionManagerAuthentication: # Connection should be cleaned up via logout mock_conn.logout.assert_called_once() - @patch("core.services.importer.imap.imaplib.IMAP4") + @patch("core.services.importer.imap._IPPinnedIMAP4") def test_authentication_failure_after_starttls(self, mock_imap4): """Test auth failure after successful STARTTLS still cleans up.""" mock_conn = MagicMock() diff --git a/src/backend/core/tests/importer/test_imap_import.py b/src/backend/core/tests/importer/test_imap_import.py index aabb184e..a6b345ba 100644 --- a/src/backend/core/tests/importer/test_imap_import.py +++ b/src/backend/core/tests/importer/test_imap_import.py @@ -173,7 +173,7 @@ def test_imap_import_form_view(admin_client, mailbox): mock_task.assert_called_once() -@patch("imaplib.IMAP4_SSL") +@patch("core.services.importer.imap._IPPinnedIMAP4SSL") @patch.object(celery_app.backend, "store_result") def test_imap_import_task_success( mock_store_result, mock_imap4_ssl, mailbox, mock_imap_connection, sample_email @@ -267,7 +267,7 @@ def test_imap_import_task_login_failure(mailbox): # Mock IMAP connection to raise an error on login with ( patch.object(import_imap_messages_task, "update_state", mock_task.update_state), - patch("core.services.importer.imap.imaplib.IMAP4_SSL") as mock_imap, + patch("core.services.importer.imap._IPPinnedIMAP4SSL") as mock_imap, ): mock_imap_instance = MagicMock() mock_imap.return_value = mock_imap_instance @@ -299,7 +299,7 @@ def test_imap_import_task_login_failure(mailbox): assert Message.objects.count() == 0 -@patch("imaplib.IMAP4_SSL") +@patch("core.services.importer.imap._IPPinnedIMAP4SSL") @patch.object(celery_app.backend, "store_result") def test_imap_import_task_message_fetch_failure( mock_store_result, mock_imap4_ssl, mailbox @@ -374,7 +374,7 @@ def test_imap_import_task_message_fetch_failure( @patch("core.mda.inbound.logger") -@patch("imaplib.IMAP4_SSL") +@patch("core.services.importer.imap._IPPinnedIMAP4SSL") @patch.object(celery_app.backend, "store_result") def test_imap_import_task_duplicate_recipients( mock_store_result, diff --git a/src/backend/core/tests/importer/test_import_service.py b/src/backend/core/tests/importer/test_import_service.py index 48c03100..003dbab0 100644 --- a/src/backend/core/tests/importer/test_import_service.py +++ b/src/backend/core/tests/importer/test_import_service.py @@ -632,7 +632,7 @@ def test_import_imap_messages_by_superuser(admin_user, mailbox, mock_request): """Test importing messages from IMAP server by superuser.""" # Mock IMAP connection and responses - with patch("imaplib.IMAP4_SSL") as mock_imap: + with patch("core.services.importer.imap._IPPinnedIMAP4SSL") as mock_imap: mock_imap_instance = mock_imap.return_value # Mock login @@ -719,7 +719,7 @@ def test_import_imap_messages_user_with_access(user, mailbox, mock_request): mailbox.accesses.create(user=user, role=MailboxRoleChoices.ADMIN) # Mock IMAP connection and responses - with patch("imaplib.IMAP4_SSL") as mock_imap: + with patch("core.services.importer.imap._IPPinnedIMAP4SSL") as mock_imap: mock_imap_instance = mock_imap.return_value # Mock login @@ -816,7 +816,7 @@ def test_import_messages_do_not_trigger_ai_features( mailbox.accesses.create(user=user, role=MailboxRoleChoices.ADMIN) # Mock IMAP connection and responses - with patch("imaplib.IMAP4_SSL") as mock_imap: + with patch("core.services.importer.imap._IPPinnedIMAP4SSL") as mock_imap: mock_imap_instance = mock_imap.return_value # Mock login diff --git a/src/backend/core/tests/mda/test_inbound.py b/src/backend/core/tests/mda/test_inbound.py index 1914e22e..7195a03f 100644 --- a/src/backend/core/tests/mda/test_inbound.py +++ b/src/backend/core/tests/mda/test_inbound.py @@ -9,7 +9,10 @@ import pytest from core import enums, factories, models from core.mda.inbound import deliver_inbound_message -from core.mda.inbound_create import find_thread_for_inbound_message +from core.mda.inbound_create import ( + _create_message_from_inbound, + find_thread_for_inbound_message, +) @pytest.mark.django_db @@ -939,3 +942,57 @@ class TestInboundAutoreplyIntegration: assert result is False mock_try_autoreply.assert_not_called() + + +@pytest.mark.django_db +class TestInboundDedupIdempotency: + """Inbound message creation is idempotent per (mailbox, mime_id). + + The advisory-locked critical section in ``_create_message_from_inbound`` + rechecks for an existing message before inserting, so two deliveries that + race (or an async retry) with the same Message-ID can't produce duplicate + Message rows or two parallel Threads. + """ + + def _parsed(self, mime_id): + return { + "subject": "Dedup Test", + "from": [{"name": "Sender", "email": "sender@test.com"}], + "to": [{"name": "Rcpt", "email": "recipient@deliver.test"}], + "textBody": [{"content": "Body."}], + "messageId": [mime_id], + "sentAt": timezone.now().isoformat(), + } + + def test_same_mime_id_creates_single_message(self): + """The same MIME Message-ID delivered twice yields a single message.""" + mailbox = factories.MailboxFactory() + parsed = self._parsed("dup.race.1@example.com") + raw = b"raw mime bytes" + + first = _create_message_from_inbound( + recipient_email=str(mailbox), + parsed_email=parsed, + raw_data=raw, + mailbox=mailbox, + ) + second = _create_message_from_inbound( + recipient_email=str(mailbox), + parsed_email=parsed, + raw_data=raw, + mailbox=mailbox, + ) + + assert first is not None + # Second call is deduped to the existing message, not a new row. + assert second is not None + assert second.id == first.id + assert ( + models.Message.objects.filter( + mime_id="dup.race.1@example.com", + thread__accesses__mailbox=mailbox, + ).count() + == 1 + ) + # And no second (parallel) thread was created. + assert models.Thread.objects.filter(accesses__mailbox=mailbox).count() == 1 diff --git a/src/backend/core/tests/mda/test_inbound_auth.py b/src/backend/core/tests/mda/test_inbound_auth.py index 3b7a3076..d7bdecd1 100644 --- a/src/backend/core/tests/mda/test_inbound_auth.py +++ b/src/backend/core/tests/mda/test_inbound_auth.py @@ -49,33 +49,86 @@ class TestCheckInboundAuthenticationDisabled: class TestCheckInboundAuthenticationNative: - """Native mode verifies DKIM locally and ignores DMARC.""" + """Native mode verifies DKIM locally, requires From/DKIM alignment, and + ignores DMARC.""" + + # parsed_email carrying a From: whose domain is ``example.com``. + PARSED_FROM = {"from": [{"email": "sender@example.com"}]} @patch("core.mda.inbound_auth.verify_message_dkim") - def test_dkim_pass(self, mock_verify): - mock_verify.return_value = True + def test_dkim_pass_aligned(self, mock_verify): + """Valid signature whose d= matches the From domain -> verified.""" + mock_verify.return_value = "example.com" config = {"inbound_auth": "native"} - assert check_inbound_authentication(RAW_EMAIL, {}, config) is None + assert check_inbound_authentication(RAW_EMAIL, self.PARSED_FROM, config) is None + + @patch("core.mda.inbound_auth.verify_message_dkim") + def test_dkim_pass_unaligned_unverified(self, mock_verify): + """Valid signature from an unrelated domain (spoofed From) -> 'none'. + + This is the spoofing case: From: sender@example.com but the message is + DKIM-signed with d=attacker.com. Raw DKIM passes, but the signer is not + the From domain, so it must NOT be shown as verified. + """ + mock_verify.return_value = "attacker.com" + config = {"inbound_auth": "native"} + assert ( + check_inbound_authentication(RAW_EMAIL, self.PARSED_FROM, config) + == VERDICT_UNVERIFIED + ) + + @patch("core.mda.inbound_auth.verify_message_dkim") + def test_dkim_subdomain_not_strictly_aligned(self, mock_verify): + """Strict alignment: d=mail.example.com does NOT match From example.com.""" + mock_verify.return_value = "mail.example.com" + config = {"inbound_auth": "native"} + assert ( + check_inbound_authentication(RAW_EMAIL, self.PARSED_FROM, config) + == VERDICT_UNVERIFIED + ) + + @patch("core.mda.inbound_auth.verify_message_dkim") + def test_alignment_is_case_insensitive(self, mock_verify): + """From: Sender@Example.COM aligns with d=example.com.""" + mock_verify.return_value = "example.com" + config = {"inbound_auth": "native"} + parsed = {"from": [{"email": "Sender@Example.COM"}]} + assert check_inbound_authentication(RAW_EMAIL, parsed, config) is None + + @patch("core.mda.inbound_auth.verify_message_dkim") + def test_no_from_header_unverified(self, mock_verify): + """A valid signature with no From: to align against -> can't verify.""" + mock_verify.return_value = "example.com" + config = {"inbound_auth": "native"} + assert check_inbound_authentication(RAW_EMAIL, {}, config) == VERDICT_UNVERIFIED @patch("core.mda.inbound_auth.verify_message_dkim") def test_dkim_fail(self, mock_verify): - mock_verify.return_value = False + """No valid signature (verify returns None) -> 'none'.""" + mock_verify.return_value = None config = {"inbound_auth": "native"} - assert check_inbound_authentication(RAW_EMAIL, {}, config) == VERDICT_UNVERIFIED + assert ( + check_inbound_authentication(RAW_EMAIL, self.PARSED_FROM, config) + == VERDICT_UNVERIFIED + ) @patch("core.mda.inbound_auth.verify_message_dkim") def test_dkim_error_unverified(self, mock_verify): """Transient errors -> cannot verify -> "none" (not forgery).""" mock_verify.side_effect = RuntimeError("dns broken") config = {"inbound_auth": "native"} - assert check_inbound_authentication(RAW_EMAIL, {}, config) == VERDICT_UNVERIFIED + assert ( + check_inbound_authentication(RAW_EMAIL, self.PARSED_FROM, config) + == VERDICT_UNVERIFIED + ) @patch("core.mda.inbound_auth.verify_message_dkim") def test_dmarc_header_ignored(self, mock_verify): - """Native doesn't look at DMARC; passing DKIM alone is enough.""" - mock_verify.return_value = True + """Native doesn't look at DMARC; an aligned passing DKIM is enough.""" + mock_verify.return_value = "example.com" parsed = { - "ext": {"headersBlocks": [{"authentication-results": ["mx; dmarc=fail"]}]} + "from": [{"email": "sender@example.com"}], + "ext": {"headersBlocks": [{"authentication-results": ["mx; dmarc=fail"]}]}, } config = {"inbound_auth": "native"} assert check_inbound_authentication(RAW_EMAIL, parsed, config) is None @@ -83,10 +136,13 @@ class TestCheckInboundAuthenticationNative: @patch("core.mda.inbound_auth.verify_message_dkim") def test_dmarc_rspamd_ignored(self, mock_verify): """Native ignores any rspamd_result that was passed in.""" - mock_verify.return_value = True + mock_verify.return_value = "example.com" rspamd = {"symbols": {"DMARC_POLICY_REJECT": {"score": 5}}} config = {"inbound_auth": "native"} - assert check_inbound_authentication(RAW_EMAIL, {}, config, rspamd) is None + assert ( + check_inbound_authentication(RAW_EMAIL, self.PARSED_FROM, config, rspamd) + is None + ) class TestCheckInboundAuthenticationRspamd: @@ -293,13 +349,31 @@ class TestCheckInboundAuthenticationResults: assert check_inbound_authentication(b"", parsed, config) == VERDICT_UNVERIFIED def test_trusted_block_used(self): - """Default trusted_relays=1 -> block 1 is trusted. + """trusted_relays=1 -> block 1 (one configured relay hop) is trusted. Block 0 (our own prepend) has no AR; block 1 (the first upstream relay) carries ``dkim=pass``. ``headers_blocks`` groups them by walking the ``headers`` list in order, closing each block at the next ``Received``. """ + config = {"inbound_auth": "authentication-results", "trusted_relays": 1} + parsed = { + "headers": [ + {"name": "Received", "value": "from our-mta"}, + {"name": "Authentication-Results", "value": "mx; dkim=pass"}, + {"name": "Received", "value": "from upstream"}, + ] + } + assert check_inbound_authentication(b"", parsed, config) is None + + def test_default_trusts_only_our_block(self): + """Default (no trusted_relays) -> only block 0 is trusted. + + The same payload as ``test_trusted_block_used`` but without an + explicit ``trusted_relays`` must NOT honour block 1's ``dkim=pass``: + a sender can forge that block, so the secure default of 0 ignores it + and the verdict collapses to "unverified". + """ config = {"inbound_auth": "authentication-results"} parsed = { "headers": [ @@ -308,7 +382,7 @@ class TestCheckInboundAuthenticationResults: {"name": "Received", "value": "from upstream"}, ] } - assert check_inbound_authentication(b"", parsed, config) is None + assert check_inbound_authentication(b"", parsed, config) == VERDICT_UNVERIFIED def test_dkim_fail_dominates_pass_across_values(self): """Multiple AR values in one block: fail wins -> 'none'.""" diff --git a/src/backend/core/tests/mda/test_outbound.py b/src/backend/core/tests/mda/test_outbound.py index f7376462..a63246fd 100644 --- a/src/backend/core/tests/mda/test_outbound.py +++ b/src/backend/core/tests/mda/test_outbound.py @@ -15,6 +15,7 @@ import rest_framework as drf from core import enums, factories, models from core.mda import outbound +from core.mda.outbound_direct import resolve_hostname_ip, send_message_via_mx from core.mda.signing import generate_dkim_key, sign_message_dkim from core.mda.smtp import SmtpProxy @@ -1422,3 +1423,55 @@ class TestPrepareOutboundMessageBase64Images: assert len(cid_refs) >= 2, ( f"Expected at least 2 CID references (text + HTML), got {len(cid_refs)}" ) + + +class TestOutboundDirectSSRF: + """Resolved MX/A IPs are SSRF-validated before the SMTP worker dials them. + + A recipient domain (and therefore its MX records) is attacker-controlled, + so a domain whose MX points at internal infrastructure must never make the + direct-delivery worker connect there. + """ + + @patch("core.mda.outbound_direct.dns.resolver.resolve") + def test_resolve_hostname_ip_rejects_internal(self, mock_resolve): + """An MX host whose only A record is internal yields no IP (skipped).""" + mock_resolve.return_value = ["10.0.0.5"] + assert resolve_hostname_ip("mx.evil.test") is None + + @patch("core.mda.outbound_direct.dns.resolver.resolve") + def test_resolve_hostname_ip_allows_public(self, mock_resolve): + """A public A record is accepted and returned.""" + mock_resolve.return_value = ["93.184.216.34"] + assert resolve_hostname_ip("mx.good.test") == "93.184.216.34" + + @patch("core.mda.outbound_direct.dns.resolver.resolve") + def test_resolve_hostname_ip_skips_internal_returns_public(self, mock_resolve): + """Multi-A: the internal record is skipped, the public one is used.""" + mock_resolve.return_value = ["10.0.0.5", "93.184.216.34"] + assert resolve_hostname_ip("mx.mixed.test") == "93.184.216.34" + + @patch("core.mda.outbound_direct.send_smtp_mail") + @patch("core.mda.outbound_direct.dns.resolver.resolve") + def test_send_via_mx_never_dials_internal_mx(self, mock_resolve, mock_smtp_send): + """End to end: a domain whose MX → internal IP triggers no SMTP connect.""" + + def resolve_side_effect(name, record_type, **kwargs): + data = { + ("evil.test", "MX"): [ + MagicMock(preference=10, exchange="mx.evil.test"), + ], + ("mx.evil.test", "A"): ["10.0.0.5"], + } + return data.get((name, record_type)) + + mock_resolve.side_effect = resolve_side_effect + + statuses = send_message_via_mx( + "from@ours.test", ["victim@evil.test"], b"raw mime" + ) + + # The worker was never asked to open an SMTP connection. + mock_smtp_send.assert_not_called() + # And the recipient was not delivered. + assert statuses["victim@evil.test"]["delivered"] is False diff --git a/src/backend/core/tests/mda/test_outbound_e2e.py b/src/backend/core/tests/mda/test_outbound_e2e.py index bda36d04..6dc3ec78 100644 --- a/src/backend/core/tests/mda/test_outbound_e2e.py +++ b/src/backend/core/tests/mda/test_outbound_e2e.py @@ -78,9 +78,19 @@ class TestE2EMessageOutboundFlow: """Test the outbound flow: API -> MDA -> Mailcatcher -> Verification.""" @override_settings(MTA_OUT_MODE="direct", MTA_OUT_DIRECT_PORT=1025) + # The e2e MX (mailcatcher) resolves to a private compose-network IP, which + # the outbound SSRF guard (assert_public_ip) rejects. Bypass it here so the + # direct path can deliver to the local catcher. + @patch("core.mda.outbound_direct.assert_public_ip") @patch("core.mda.outbound_direct.dns.resolver.resolve") def test_draft_send_receive_verify_direct( - self, mock_resolve, mailbox, sender_contact, authenticated_user + self, + mock_resolve, + _mock_assert_public_ip, + mailbox, + sender_contact, + authenticated_user, + django_capture_on_commit_callbacks, ): """Test sending with mailcatcher as a MX server using direct SMTP""" mailcatcher_ip = socket.gethostbyname("mailcatcher") @@ -97,19 +107,39 @@ class TestE2EMessageOutboundFlow: mock_resolve.side_effect = resolve_return_value - self._test(mailbox, sender_contact, authenticated_user) + self._test( + mailbox, + sender_contact, + authenticated_user, + django_capture_on_commit_callbacks, + ) @override_settings( MTA_OUT_MODE="relay", MTA_OUT_RELAY_HOST="mailcatcher:1025", ) def test_draft_send_receive_verify_relay( - self, mailbox, sender_contact, authenticated_user + self, + mailbox, + sender_contact, + authenticated_user, + django_capture_on_commit_callbacks, ): """Test sending with mailcatcher as an SMTP relay""" - self._test(mailbox, sender_contact, authenticated_user) + self._test( + mailbox, + sender_contact, + authenticated_user, + django_capture_on_commit_callbacks, + ) - def _test(self, mailbox, sender_contact, authenticated_user): + def _test( + self, + mailbox, + sender_contact, + authenticated_user, + django_capture_on_commit_callbacks, + ): """Test creating a draft, sending it, receiving via mailcatcher, and verifying content/DKIM.""" # --- Setup --- # # Create and configure DKIM key for the domain @@ -176,9 +206,13 @@ class TestE2EMessageOutboundFlow: "textBody": "This is the E2E test body.", "htmlBody": "

This is the E2E test body.

", } - send_response = client.post( - reverse("send-message"), send_payload, format="json" - ) + # Delivery is dispatched via ``transaction.on_commit`` (see + # SendMessageView), so capture and execute the callbacks to run the + # deferred ``send_message_task`` inline (CELERY_TASK_ALWAYS_EAGER). + with django_capture_on_commit_callbacks(execute=True): + send_response = client.post( + reverse("send-message"), send_payload, format="json" + ) assert send_response.status_code == status.HTTP_200_OK, send_response.content assert ( diff --git a/src/backend/core/tests/mda/test_signing.py b/src/backend/core/tests/mda/test_signing.py index b689f89e..9c9e1516 100644 --- a/src/backend/core/tests/mda/test_signing.py +++ b/src/backend/core/tests/mda/test_signing.py @@ -1,10 +1,12 @@ """Tests for DKIM signing functionality.""" +from unittest.mock import Mock, patch + import pytest from dkim import verify as dkim_verify from core.enums import DKIMAlgorithmChoices -from core.mda.signing import generate_dkim_key, sign_message_dkim +from core.mda.signing import generate_dkim_key, sign_message_dkim, verify_message_dkim from core.models import DKIMKey, Mailbox, MailDomain @@ -77,6 +79,47 @@ def test_sign_message_dkim_success(): assert dkim_verify(full_message_signed, dnsfunc=get_dns_txt) +@pytest.mark.django_db +def test_verify_message_dkim_returns_signing_domain(): + """verify_message_dkim returns the validated signature's d= domain. + + Callers rely on the returned domain (not a bare bool) to enforce + From/DKIM alignment, so the contract is exercised end to end here with a + mocked DNS resolver serving the public key. + """ + private_key_pem_str, public_key_str = generate_dkim_key(key_size=1024) + mail_domain = MailDomain.objects.create(name="example.com") + Mailbox.objects.create(local_part="test", domain=mail_domain) + DKIMKey.objects.create( + selector="testselector", + private_key=private_key_pem_str, + public_key=public_key_str, + key_size=1024, + is_active=True, + domain=mail_domain, + ) + + raw_message = ( + b"From: test@example.com\r\nTo: recipient@other.com\r\n" + b"Subject: Test DKIM\r\n\r\nHello World!\r\n" + ) + signature_header_bytes = sign_message_dkim(raw_message, mail_domain) + full_message_signed = signature_header_bytes + b"\r\n" + raw_message + + answer = Mock() + answer.strings = [f"v=DKIM1; k=rsa; p={public_key_str}".encode()] + with patch("core.mda.signing.dns.resolver.resolve", return_value=[answer]): + assert verify_message_dkim(full_message_signed) == "example.com" + + +@pytest.mark.django_db +def test_verify_message_dkim_invalid_returns_none(): + """A message whose signature does not validate yields None (falsy).""" + # Unsigned message -> no DKIM-Signature header -> cannot verify. + raw_message = b"From: test@example.com\r\nSubject: Test\r\n\r\nBody\r\n" + assert verify_message_dkim(raw_message) is None + + @pytest.mark.django_db def test_sign_message_dkim_no_dkim_key(): """Test that signing is skipped if domain has no DKIM key configured.""" diff --git a/src/backend/core/tests/mda/test_spam_processing.py b/src/backend/core/tests/mda/test_spam_processing.py index af0a089a..74e5dc62 100644 --- a/src/backend/core/tests/mda/test_spam_processing.py +++ b/src/backend/core/tests/mda/test_spam_processing.py @@ -226,7 +226,7 @@ class TestRspamdSpamCheck: @pytest.mark.django_db -class TestHardcodedSpamRules: +class TestHardcodedSpamRules: # pylint: disable=too-many-public-methods """Test hardcoded spam rules functionality.""" def test_check_spam_with_hardcoded_rules_spam(self): @@ -692,6 +692,40 @@ This is a test email body. result = _check_spam_with_hardcoded_rules(parsed_email, spam_config) assert result is expected_result + def test_default_ignores_sender_injected_ham_header(self): + """By default (no trusted_relays) a sender cannot whitelist itself. + + The attacker prepends one fake ``Received`` to lift a forged + ``X-Spam: Ham`` (action="ham") into what used to be the trusted slice. + Block 0 is our own MTA's Received (no X-Spam); the forged ham sits in + block 1. With the secure default of trusted_relays=0 only block 0 is + trusted, so the forged ham rule does NOT match and the result is None + (falls through to real spam scanning) — not False (ham bypass). + """ + raw_email = b"""Received: from our_mta.example.com (our_mta.example.com [10.0.0.1]) + by mail.example.com with SMTP id our_mta_id; + Mon, 1 Jan 2024 12:02:00 +0000 +X-Spam: Ham +Received: from forged.attacker.example (forged [6.6.6.6]) + by mail.example.com with SMTP id forged_id; + Mon, 1 Jan 2024 12:01:00 +0000 +From: sender@example.com +To: recipient@example.com +Subject: Test Email + +This is a test email body. +""" + parsed_email = parse_email(raw_email) + # No trusted_relays key -> defaults to 0 (only our own block trusted). + spam_config = { + "rules": [ + {"header_match": "X-Spam:Ham", "action": "ham"}, + ], + } + + result = _check_spam_with_hardcoded_rules(parsed_email, spam_config) + assert result is None # forged ham not honoured + @pytest.mark.django_db class TestProcessInboundMessageTask: diff --git a/src/backend/core/tests/search/test_e2e.py b/src/backend/core/tests/search/test_e2e.py index 831e1634..2c571685 100644 --- a/src/backend/core/tests/search/test_e2e.py +++ b/src/backend/core/tests/search/test_e2e.py @@ -142,6 +142,12 @@ def fixture_create_test_thread(test_mailbox, wait_for_indexing): message=message, contact=contact2, type=enums.MessageRecipientTypeChoices.TO ) + # Recompute denormalized thread stats the way production does after a + # message lands. Without this ``messaged_at`` stays None, so the thread + # is never considered unread (see _compute_unread_starred_from_accesses) + # and is:unread search filters can't match it. + thread.update_stats() + # Wait for indexing to complete wait_for_indexing() @@ -317,6 +323,9 @@ class TestSearchE2E: contact=contact, type=enums.MessageRecipientTypeChoices.TO, ) + # Keep the thread's denormalized stats consistent with its messages, + # the way production does after a message lands. + thread.update_stats() wait_for_indexing() es = get_opensearch_client() @@ -387,6 +396,9 @@ class TestSearchE2E: contact=contact, type=enums.MessageRecipientTypeChoices.TO, ) + # Keep the thread's denormalized stats consistent with its messages, + # the way production does after a message lands. + thread.update_stats() wait_for_indexing() es = get_opensearch_client() diff --git a/src/backend/core/tests/search/test_search.py b/src/backend/core/tests/search/test_search.py index 12101c5f..772dc407 100644 --- a/src/backend/core/tests/search/test_search.py +++ b/src/backend/core/tests/search/test_search.py @@ -331,7 +331,7 @@ def test_search_threads_pagination(mock_es_client_search): } # Call with from_offset=10, size=10 (page 2) - result = search_threads("test", from_offset=10, size=10) + result = search_threads("test", mailbox_ids=["mbx-1"], from_offset=10, size=10) # Verify results assert len(result["threads"]) == 10 @@ -360,6 +360,18 @@ def test_search_threads_disabled(mock_es_client_search): mock_es_client_search.search.assert_not_called() +@override_settings(OPENSEARCH_INDEX_THREADS=True) +@pytest.mark.parametrize("mailbox_ids", [None, []]) +def test_search_threads_requires_mailbox_ids(mock_es_client_search, mailbox_ids): + """Without a mailbox scope, search must return empty and never hit the + cluster — an unscoped query would leak hit-totals/existence across every + mailbox. ``None`` and ``[]`` are both treated as "no scope -> no results".""" + result = search_threads("test query", mailbox_ids=mailbox_ids) + + assert result == {"threads": [], "total": 0, "from": 0, "size": 20} + mock_es_client_search.search.assert_not_called() + + @pytest.mark.django_db def test_update_thread_mailbox_flags(mock_es_client_index): """Test that update_thread_mailbox_flags re-indexes the thread document.""" diff --git a/src/backend/core/tests/search/test_search_modifiers.py b/src/backend/core/tests/search/test_search_modifiers.py index 705cf1ed..3b08c547 100644 --- a/src/backend/core/tests/search/test_search_modifiers.py +++ b/src/backend/core/tests/search/test_search_modifiers.py @@ -176,17 +176,6 @@ def test_search_threads_is_unread_filter(mock_es_client): assert has_parent_found, "has_parent terms filter for is:unread was not found" -def test_search_threads_is_unread_without_mailbox_ids(mock_es_client): - """Test that is:unread without mailbox_ids does not add a filter.""" - search_threads("is:unread") - - call_args = mock_es_client.search.call_args[1] - for filter_item in call_args["body"]["query"]["bool"]["filter"]: - assert "has_parent" not in filter_item, ( - "has_parent filter should not be present without mailbox_ids" - ) - - def test_search_threads_filters_is_starred_true(mock_es_client): """Test that filters={'is_starred': True} uses has_parent on starred_mailboxes.""" search_threads("some text", mailbox_ids=["mbx-1"], filters={"is_starred": True}) @@ -283,26 +272,6 @@ def test_search_threads_filters_is_unread_false(mock_es_client): assert has_parent_found, "has_parent must_not unread_mailboxes filter was not found" -def test_search_threads_filters_starred_without_mailbox_ids(mock_es_client): - """Test that filters={'is_starred': True} without mailbox_ids does not add a filter.""" - search_threads("some text", filters={"is_starred": True}) - - call_args = mock_es_client.search.call_args[1] - filters = call_args["body"]["query"]["bool"]["filter"] - - for filter_item in filters: - if "has_parent" in filter_item: - hp = filter_item["has_parent"] - query = hp.get("query", {}) - assert "starred_mailboxes" not in query.get("terms", {}), ( - "starred_mailboxes filter should not be present without mailbox_ids" - ) - if "term" in filter_item: - assert "is_starred" not in filter_item["term"], ( - "Legacy is_starred term filter should not be emitted" - ) - - def test_search_threads_filters_other_fields_still_use_term(mock_es_client): """Test that non-mailbox-scoped filters still use the generic term filter.""" search_threads( diff --git a/src/backend/core/tests/services/test_ssrf.py b/src/backend/core/tests/services/test_ssrf.py index a745ef1c..689f42f1 100644 --- a/src/backend/core/tests/services/test_ssrf.py +++ b/src/backend/core/tests/services/test_ssrf.py @@ -15,12 +15,47 @@ from core.services.ssrf import ( MAX_REDIRECTS, SSRFSafeSession, SSRFValidationError, + assert_public_ip, ) PUBLIC_IP = "93.184.216.34" PRIVATE_IP = "192.168.1.1" +class TestAssertPublicIP: + """``assert_public_ip`` — the IP guard reused by the outbound SMTP path.""" + + def test_public_ip_passes(self): + """A routable public address passes (returns None, does not raise).""" + assert assert_public_ip(PUBLIC_IP) is None + + @pytest.mark.parametrize( + "ip, match", + [ + ("10.0.0.5", "private"), + ("192.168.1.1", "private"), + ("172.16.0.1", "private"), + ("127.0.0.1", "loopback"), + ("::1", "loopback"), + ("169.254.169.254", "cloud metadata"), + ("169.254.0.1", "link-local"), + ("224.0.0.1", "multicast"), + # Shared address space / CGNAT (RFC 6598): not is_private nor + # is_reserved in Python's ipaddress, caught by the is_global guard. + ("100.64.0.1", "non-global"), + ], + ) + def test_non_public_ip_raises(self, ip, match): + """Private, reserved, loopback, metadata and CGNAT addresses are rejected.""" + with pytest.raises(SSRFValidationError, match=match): + assert_public_ip(ip, "mx.evil.test") + + def test_invalid_ip_raises(self): + """A non-parseable IP string raises an Invalid IP error.""" + with pytest.raises(SSRFValidationError, match="Invalid IP"): + assert_public_ip("not-an-ip") + + def _addrinfo(ip: str): return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 0))] diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index 70ff539a..157c2e80 100644 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -360,8 +360,14 @@ class Base(Configuration): }, } # MDA settings + # No default on purpose: the MTA-to-MDA channel is authenticated solely by + # an HS256 JWT signed with this shared secret, so a hardcoded fallback would + # be internet-spoofable in production. The development value is supplied via + # env.d/development/backend.defaults (and the matching mta-in.defaults), the + # same way DJANGO_SECRET_KEY is handled. Unset → None → all MTA auth fails + # closed. MDA_API_SECRET = values.Value( - "my-shared-secret-mda", environ_name="MDA_API_SECRET", environ_prefix=None + None, environ_name="MDA_API_SECRET", environ_prefix=None ) # Default CalDAV server settings (optional). Enables calendar features @@ -410,8 +416,12 @@ class Base(Configuration): # Default spam configuration for all mail domains, overrideable per mail # domain in custom_settings. Recognised keys include: # rspamd_url / rspamd_auth : rspamd /checkv2 endpoint + optional auth header - # trusted_relays : int, how many upstream Received blocks to trust - # for header-based rules (default 1) + # trusted_relays : int, how many sender-side upstream Received + # blocks to trust for header-based rules + # (default 0 — trust only the Received block our + # own MTA prepends; a sender can forge any block + # above that, so only raise this to the number of + # relay hops you actually operate) # rules : list of hardcoded header-match spam rules # inbound_auth : sender authentication backend — one of # "native", "rspamd", "authentication-results", @@ -799,6 +809,19 @@ class Base(Configuration): environ_name="API_CALDAV_CONFLICTS_THROTTLE_RATE", environ_prefix=None, ), + # Public widget deliver endpoint. The channel id is a public embed + # value, so these caps bound abuse: a per-channel ceiling on total + # inbound volume and a per-IP burst limit under it. + "widget_inbound_channel": values.Value( + default="30/minute", + environ_name="API_WIDGET_INBOUND_CHANNEL_THROTTLE_RATE", + environ_prefix=None, + ), + "widget_inbound_ip": values.Value( + default="10/minute", + environ_name="API_WIDGET_INBOUND_IP_THROTTLE_RATE", + environ_prefix=None, + ), }, } diff --git a/src/e2e/caddy/Caddyfile b/src/e2e/caddy/Caddyfile index c29996ff..9ca20d85 100644 --- a/src/e2e/caddy/Caddyfile +++ b/src/e2e/caddy/Caddyfile @@ -1,4 +1,13 @@ :80 { + # Mirror the production frontend's security headers so e2e exercises the + # same posture (and can assert on them). See src/frontend/caddy/Caddyfile. + header { + X-Frame-Options "DENY" + Content-Security-Policy "frame-ancestors 'none'; base-uri 'self'; object-src 'none'" + X-Content-Type-Options "nosniff" + Referrer-Policy "same-origin" + } + reverse_proxy /api/* backend:8000 reverse_proxy /oidc/* backend:8000 reverse_proxy /admin/* backend:8000 diff --git a/src/frontend/caddy/Caddyfile b/src/frontend/caddy/Caddyfile index 6265c5d7..0dbf139b 100644 --- a/src/frontend/caddy/Caddyfile +++ b/src/frontend/caddy/Caddyfile @@ -5,7 +5,19 @@ :{$PORT} { root * {$MESSAGES_FRONTEND_ROOT:/app} - header -Server + + # Security headers for everything this site serves (SPA shell, static + # assets, and proxied API/admin — `header` replaces, so it does not + # duplicate Django's own X-Frame-Options). + header { + -Server + # Clickjacking: the webmail UI must never be framable. frame-ancestors + # is the modern control; X-Frame-Options covers older browsers. + X-Frame-Options "DENY" + Content-Security-Policy "frame-ancestors 'none'; base-uri 'self'; object-src 'none'" + X-Content-Type-Options "nosniff" + Referrer-Policy "same-origin" + } route { # Health checks diff --git a/src/frontend/index.html b/src/frontend/index.html index 6d4311bd..923d27cb 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -2,6 +2,12 @@ + + diff --git a/src/frontend/src/features/api/gen/messages/messages.ts b/src/frontend/src/features/api/gen/messages/messages.ts index 28b1b465..c5f6a013 100644 --- a/src/frontend/src/features/api/gen/messages/messages.ts +++ b/src/frontend/src/features/api/gen/messages/messages.ts @@ -42,7 +42,7 @@ import type { MessagesDeliveryStatusesPartialUpdateBodyOne, SendCreate400, SendCreate403, - SendCreate503, + SendCreate500, SendMessageRequest, SendMessageResponse, } from ".././models"; @@ -1623,9 +1623,9 @@ export type sendCreateResponse403 = { status: 403; }; -export type sendCreateResponse503 = { - data: SendCreate503; - status: 503; +export type sendCreateResponse500 = { + data: SendCreate500; + status: 500; }; export type sendCreateResponseSuccess = sendCreateResponse200 & { @@ -1634,7 +1634,7 @@ export type sendCreateResponseSuccess = sendCreateResponse200 & { export type sendCreateResponseError = ( | sendCreateResponse400 | sendCreateResponse403 - | sendCreateResponse503 + | sendCreateResponse500 ) & { headers: Headers; }; @@ -1660,7 +1660,7 @@ export const sendCreate = async ( }; export const getSendCreateMutationOptions = < - TError = ErrorType, + TError = ErrorType, TContext = unknown, >(options?: { mutation?: UseMutationOptions< @@ -1702,11 +1702,11 @@ export type SendCreateMutationResult = NonNullable< >; export type SendCreateMutationBody = SendMessageRequest; export type SendCreateMutationError = ErrorType< - SendCreate400 | SendCreate403 | SendCreate503 + SendCreate400 | SendCreate403 | SendCreate500 >; export const useSendCreate = < - TError = ErrorType, + TError = ErrorType, TContext = unknown, >( options?: { diff --git a/src/frontend/src/features/api/gen/models/index.ts b/src/frontend/src/features/api/gen/models/index.ts index 9e1fed85..b9d1d5d6 100644 --- a/src/frontend/src/features/api/gen/models/index.ts +++ b/src/frontend/src/features/api/gen/models/index.ts @@ -150,7 +150,7 @@ export * from "./response_enum"; export * from "./scope_level_enum"; export * from "./send_create400"; export * from "./send_create403"; -export * from "./send_create503"; +export * from "./send_create500"; export * from "./send_message_request"; export * from "./send_message_response"; export * from "./status_enum"; diff --git a/src/frontend/src/features/api/gen/models/send_create503.ts b/src/frontend/src/features/api/gen/models/send_create500.ts similarity index 77% rename from src/frontend/src/features/api/gen/models/send_create503.ts rename to src/frontend/src/features/api/gen/models/send_create500.ts index 3b62e186..241d9ec9 100644 --- a/src/frontend/src/features/api/gen/models/send_create503.ts +++ b/src/frontend/src/features/api/gen/models/send_create500.ts @@ -9,4 +9,4 @@ /** * Unspecified response body */ -export type SendCreate503 = { [key: string]: unknown }; +export type SendCreate500 = { [key: string]: unknown }; diff --git a/src/frontend/src/features/api/gen/models/send_message_response.ts b/src/frontend/src/features/api/gen/models/send_message_response.ts index 29f8cab3..93303db8 100644 --- a/src/frontend/src/features/api/gen/models/send_message_response.ts +++ b/src/frontend/src/features/api/gen/models/send_message_response.ts @@ -5,10 +5,8 @@ * This is the messages API schema. * OpenAPI spec version: 1.0.0 (v1.0) */ -import type { Message } from "./message"; export interface SendMessageResponse { - message: Message; /** Task ID for tracking */ task_id: string; } diff --git a/src/frontend/src/features/blocknote/signature-block/index.tsx b/src/frontend/src/features/blocknote/signature-block/index.tsx index 2a330f8c..93b3fd11 100644 --- a/src/frontend/src/features/blocknote/signature-block/index.tsx +++ b/src/frontend/src/features/blocknote/signature-block/index.tsx @@ -232,7 +232,15 @@ export const BlockSignature = createReactBlockSpec( const sanitized = domPurify.sanitize(html); // Replace layout tables with flex divs to prevent BlockNote from // parsing them as table blocks (which causes a crash). - return replaceLayoutTablesWithDivs(sanitized); + const transformed = replaceLayoutTablesWithDivs(sanitized); + // Re-sanitize after the rewrite: replaceLayoutTablesWithDivs + // reparses and re-serializes via innerHTML, and the result is + // injected into the app origin with dangerouslySetInnerHTML (no + // iframe boundary). A second pass guarantees the DOM rewrite + // can't reintroduce anything unsafe. cid: image refs survive + // here; the cid->blob: object-URL swap runs afterwards on + // already-clean HTML. + return domPurify.sanitize(transformed); }, [template?.html_body, placeholders, isLoading]); // eslint-disable-next-line react-hooks/rules-of-hooks diff --git a/src/frontend/src/features/message/use-print.tsx b/src/frontend/src/features/message/use-print.tsx index e9847e32..e816eaf6 100644 --- a/src/frontend/src/features/message/use-print.tsx +++ b/src/frontend/src/features/message/use-print.tsx @@ -36,6 +36,17 @@ const usePrint = () => { const html = '' + renderToStaticMarkup( + {/* + The body is already DOMPurify-sanitized, but this is a bare + same-origin `window.open('')` with no other containment. + This CSP is the backstop: scripts/objects cannot run even if + sanitization ever regresses, while the images and inline + styles the print layout needs are still allowed. + */} + {message.subject ?? ''}