Files
lasuite-messages/src/backend/core/api/viewsets.py
T

1085 lines
38 KiB
Python

"""API endpoints"""
# pylint: disable=too-many-lines
import logging
import re
import smtplib
import time
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.db import models as db
from django.utils import timezone
import rest_framework as drf
from drf_spectacular.utils import (
OpenApiExample,
extend_schema,
inline_serializer,
)
from rest_framework import mixins, status, viewsets
from rest_framework import serializers as drf_serializers
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from core import enums, models
from core.formats.rfc5322.composer import compose_email
from . import permissions, serializers
logger = logging.getLogger(__name__)
ITEM_FOLDER = "item"
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"(\.[a-zA-Z0-9]+)?$"
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}(?P<key>{ITEM_FOLDER:s}/(?P<pk>{UUID_REGEX:s})/.*{FILE_EXT_REGEX:s})$"
)
# pylint: disable=too-many-ancestors
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
It allows to define all url kwargs and lookup fields to perform the lookup.
"""
lookup_fields: list[str] = ["pk"]
lookup_url_kwargs: list[str] = []
def __getattribute__(self, item):
"""
This method is overridden to allow to get the last lookup field or lookup url kwarg
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
to keep compatibility with all methods used by the parent class `GenericViewSet`.
"""
if item in ["lookup_field", "lookup_url_kwarg"]:
return getattr(self, item + "s", [None])[-1]
return super().__getattribute__(item)
def get_queryset(self):
"""
Get the list of items for this view.
`lookup_fields` attribute is enumerated here to perform the nested lookup.
"""
queryset = super().get_queryset()
# The last lookup field is removed to perform the nested lookup as it corresponds
# to the object pk, it is used within get_object method.
lookup_url_kwargs = (
self.lookup_url_kwargs[:-1]
if self.lookup_url_kwargs
else self.lookup_fields[:-1]
)
filter_kwargs = {}
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
if lookup_url_kwarg not in self.kwargs:
raise KeyError(
f"Expected view {self.__class__.__name__} to be called with a URL "
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
"set the `.lookup_fields` attribute on the view correctly."
)
filter_kwargs.update(
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
)
return queryset.filter(**filter_kwargs)
class SerializerPerActionMixin:
"""
A mixin to allow to define serializer classes for each action.
This mixin is useful to avoid to define a serializer class for each action in the
`get_serializer_class` method.
Example:
```
class MyViewSet(SerializerPerActionMixin, viewsets.GenericViewSet):
serializer_class = MySerializer
list_serializer_class = MyListSerializer
retrieve_serializer_class = MyRetrieveSerializer
```
"""
def get_serializer_class(self):
"""
Return the serializer class to use depending on the action.
"""
if serializer_class := getattr(self, f"{self.action}_serializer_class", None):
return serializer_class
return super().get_serializer_class()
class Pagination(drf.pagination.PageNumberPagination):
"""Pagination to display no more than 100 objects per page sorted by creation date."""
ordering = "-created_on"
max_page_size = 200
page_size_query_param = "page_size"
class ResourceAccessViewsetMixin:
"""Mixin with methods common to all access viewsets."""
def get_permissions(self):
"""User only needs to be authenticated to list resource accesses"""
if self.action == "list":
permission_classes = [permissions.IsAuthenticated]
else:
return super().get_permissions()
return [permission() for permission in permission_classes]
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["resource_id"] = self.kwargs["resource_id"]
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(
**{self.resource_field_name: self.kwargs["resource_id"]}
)
if self.action == "list":
user = self.request.user
teams = user.teams
user_roles_query = (
queryset.filter(
db.Q(user=user) | db.Q(team__in=teams),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.values(self.resource_field_name)
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
# Limit to resource access instances related to a resource THAT also has
# a resource access
# instance for the logged-in user (we don't want to list only the resource
# access instances pointing to the logged-in user)
queryset = (
queryset.filter(
db.Q(**{f"{self.resource_field_name}__accesses__user": user})
| db.Q(
**{f"{self.resource_field_name}__accesses__team__in": teams}
),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.annotate(user_roles=db.Subquery(user_roles_query))
.distinct()
)
return queryset
def destroy(self, request, *args, **kwargs):
"""Forbid deleting the last owner access"""
instance = self.get_object()
resource = getattr(instance, self.resource_field_name)
# Check if the access being deleted is the last owner access for the resource
if (
instance.role == "owner"
and resource.accesses.filter(role="owner").count() == 1
):
return drf.response.Response(
{"detail": "Cannot delete the last owner access for the resource."},
status=drf.status.HTTP_403_FORBIDDEN,
)
return super().destroy(request, *args, **kwargs)
def perform_update(self, serializer):
"""Check that we don't change the role if it leads to losing the last owner."""
instance = serializer.instance
# Check if the role is being updated and the new role is not "owner"
if (
"role" in self.request.data
and self.request.data["role"] != models.RoleChoices.OWNER
):
resource = getattr(instance, self.resource_field_name)
# Check if the access being updated is the last owner access for the resource
if (
instance.role == models.RoleChoices.OWNER
and resource.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
):
message = "Cannot change the role to a non-owner role for the last owner access."
raise drf.exceptions.PermissionDenied({"detail": message})
serializer.save()
class ItemMetadata(drf.metadata.SimpleMetadata):
"""Custom metadata class to add information"""
def determine_metadata(self, request, view):
"""Add language choices only for the list endpoint."""
simple_metadata = super().determine_metadata(request, view)
if request.path.endswith("/items/"):
simple_metadata["actions"]["POST"]["language"] = {
"choices": [
{"value": code, "display_name": name}
for code, name in enums.ALL_LANGUAGES.items()
]
}
return simple_metadata
class ConfigView(drf.views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
def get(self, request):
"""
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
array_settings = [
"ENVIRONMENT",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"POSTHOG_KEY",
"LANGUAGES",
"LANGUAGE_CODE",
"SENTRY_DSN",
]
dict_settings = {}
for setting in array_settings:
if hasattr(settings, setting):
dict_settings[setting] = getattr(settings, setting)
return drf.response.Response(dict_settings)
class UserViewSet(viewsets.ViewSet):
"""ViewSet for User model."""
serializer_class = serializers.UserSerializer
permission_classes = [permissions.IsSelf]
@drf.decorators.action(
detail=False,
methods=["get"],
url_name="me",
url_path="me",
permission_classes=[permissions.IsAuthenticated],
)
def get_me(self, request):
"""
Return information on currently logged user
"""
context = {"request": request}
return drf.response.Response(
self.serializer_class(request.user, context=context).data
)
class MailboxViewSet(viewsets.GenericViewSet, mixins.ListModelMixin):
"""ViewSet for Mailbox model."""
serializer_class = serializers.MailboxSerializer
permission_classes = [permissions.IsAuthenticated]
pagination_class = None
def get_queryset(self):
"""Restrict results to the current user's mailboxes."""
accesses = self.request.user.mailbox_accesses.all()
return models.Mailbox.objects.filter(
id__in=accesses.values_list("mailbox_id", flat=True)
)
class ThreadViewSet(
viewsets.GenericViewSet, mixins.ListModelMixin, mixins.DestroyModelMixin
):
"""ViewSet for Thread model."""
serializer_class = serializers.ThreadSerializer
permission_classes = [
permissions.IsAuthenticated,
permissions.IsAllowedToAccessMailbox,
]
lookup_field = "id"
lookup_url_kwarg = "id"
queryset = models.Thread.objects.all()
def get_queryset(self):
"""Restrict results to threads of the current user's mailboxes."""
mailbox_id = self.request.GET.get("mailbox_id")
accesses = self.request.user.mailbox_accesses.all()
queryset = (
models.Thread.objects.filter(
mailbox_id__in=accesses.values_list("mailbox_id", flat=True)
)
.annotate(latest_message_created_at=db.Max("messages__created_at"))
.order_by("-latest_message_created_at")
)
if mailbox_id:
return queryset.filter(mailbox__id=mailbox_id)
return queryset
@extend_schema(
tags=["threads"],
request=inline_serializer(
name="ThreadBulkDeleteRequest",
fields={
"thread_ids": drf_serializers.ListField(
child=drf_serializers.UUIDField(),
required=True,
help_text="List of thread IDs to delete",
),
},
),
responses={
200: OpenApiExample(
"Success Response",
value={"detail": "Successfully deleted 5 threads", "deleted_count": 5},
),
400: OpenApiExample(
"Validation Error", value={"detail": "thread_ids must be provided"}
),
},
description="Delete multiple threads at once by providing a list of thread IDs.",
)
@drf.decorators.action(
detail=False,
methods=["post"],
url_path="bulk-delete",
url_name="bulk-delete",
)
def bulk_delete(self, request):
"""Delete multiple threads at once."""
thread_ids = request.data.get("thread_ids", [])
if not thread_ids:
return drf.response.Response(
{"detail": "thread_ids must be provided"},
status=drf.status.HTTP_400_BAD_REQUEST,
)
# Get threads the user has access to
# Check if user has delete permission for each thread
threads_to_delete = []
forbidden_threads = []
for thread_id in thread_ids:
try:
thread = models.Thread.objects.get(id=thread_id)
# Check if user has permission to delete this thread
try:
self.check_object_permissions(self.request, thread)
except drf.exceptions.PermissionDenied:
forbidden_threads.append(thread_id)
else:
threads_to_delete.append(thread_id)
except models.Thread.DoesNotExist:
# Skip threads that don't exist
pass
if forbidden_threads and not threads_to_delete:
# If all requested threads are forbidden, return 403
return drf.response.Response(
{"detail": "You don't have permission to delete these threads"},
status=drf.status.HTTP_403_FORBIDDEN,
)
# Update thread_ids to only include those with proper permissions
accessible_threads = self.get_queryset().filter(id__in=threads_to_delete)
# Count before deletion
count = accessible_threads.count()
# Delete the threads
accessible_threads.delete()
return drf.response.Response(
{
"detail": f"Successfully deleted {count} threads",
"deleted_count": count,
}
)
class MessageViewSet(
viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
):
"""ViewSet for Message model."""
serializer_class = serializers.MessageSerializer
permission_classes = [
permissions.IsAuthenticated,
permissions.IsAllowedToAccessMailbox,
]
queryset = models.Message.objects.all()
lookup_field = "id"
lookup_url_kwarg = "id"
def get_queryset(self):
"""Restrict results to messages of the current user's mailbox."""
queryset = super().get_queryset()
if self.action == "list":
thread_id = self.request.GET.get("thread_id")
if thread_id:
queryset = queryset.filter(thread__id=thread_id).order_by("created_at")
else:
return queryset.none()
return queryset
def destroy(self, request, *args, **kwargs):
"""Delete a message."""
# if message is the last of the thread, delete the thread
message = self.get_object()
if message.thread.messages.count() == 1:
message.thread.delete()
message.delete()
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)
class ChangeReadStatusViewSet(APIView):
"""ViewSet for marking messages as read or unread."""
permission_classes = [permissions.IsAllowedToAccessMailbox]
action = "change_read_status"
@extend_schema(
request=inline_serializer(
name="ChangeReadStatusRequest",
fields={
"status": drf_serializers.IntegerField(),
"message_ids": drf_serializers.ListField(
child=drf_serializers.UUIDField()
),
"thread_ids": drf_serializers.ListField(
child=drf_serializers.UUIDField()
),
},
),
)
def post(self, request, *args, **kwargs):
"""
Mark multiple messages or threads as read or unread.
POST /api/v1.0/read/
Expected parameters:
- status: 1 (read) or 0 (unread)
- message_ids: comma-separated list of message IDs (optional)
- thread_ids: comma-separated list of thread IDs (optional)
At least one of message_ids or thread_ids must be provided.
"""
# Get status parameter (1 for read, 0 for unread)
try:
status_param = int(request.data.get("status", 1))
is_read = bool(status_param)
except (ValueError, TypeError):
return drf.response.Response(
{"detail": "Status must be 0 or 1"},
status=drf.status.HTTP_400_BAD_REQUEST,
)
# Get current timestamp for read status
timestamp = timezone.now() if is_read else None
# Process message IDs if provided
message_ids = request.data.get("message_ids", "")
thread_ids = request.data.get("thread_ids", "")
if not message_ids and not thread_ids:
return drf.response.Response(
{"detail": "Either message_ids or thread_ids must be provided"},
status=drf.status.HTTP_400_BAD_REQUEST,
)
updated_messages = []
updated_threads = set()
# Update messages directly specified
if message_ids:
if isinstance(message_ids, str):
message_ids = [
mid.strip() for mid in message_ids.split(",") if mid.strip()
]
accessible_messages = models.Message.objects.filter(
thread__mailbox__in=self.request.user.mailbox_accesses.values_list(
"mailbox_id", flat=True
),
id__in=message_ids,
)
for message in accessible_messages:
message.read_at = timestamp
message.save(update_fields=["read_at", "updated_at"])
updated_messages.append(message)
updated_threads.add(message.thread)
# Update all messages in specified threads
if thread_ids:
if isinstance(thread_ids, str):
thread_ids = [
tid.strip() for tid in thread_ids.split(",") if tid.strip()
]
# Get all threads the user has access to
accessible_threads = models.Thread.objects.filter(
mailbox__in=self.request.user.mailbox_accesses.values_list(
"mailbox_id", flat=True
),
id__in=thread_ids,
)
# Update all messages in these threads
for thread in accessible_threads:
thread_messages = thread.messages.all()
thread_messages.update(read_at=timestamp, updated_at=timezone.now())
updated_threads.add(thread)
updated_messages.extend(list(thread_messages))
# Update thread read status for all affected threads
for thread in updated_threads:
thread.update_read_status()
return drf.response.Response(
{
"detail": (
f"Successfully marked {len(updated_messages)} messages as "
f"{'read' if is_read else 'unread'}"
),
"updated_messages": len(updated_messages),
"updated_threads": len(updated_threads),
}
)
@extend_schema(
tags=["messages"],
request=inline_serializer(
name="DraftMessageRequest",
fields={
"messageId": drf_serializers.UUIDField(
required=False,
allow_null=True,
help_text="Message ID if updating an existing draft",
),
"parentId": drf_serializers.UUIDField(
required=False,
allow_null=True,
help_text="Message ID if replying to an existing message",
),
"senderId": drf_serializers.UUIDField(
required=True,
help_text="Mailbox ID to use as sender",
),
"subject": drf_serializers.CharField(
required=True, help_text="Subject of the message"
),
"htmlBody": drf_serializers.CharField(
required=False,
allow_blank=True,
help_text="HTML content of the message",
),
"textBody": drf_serializers.CharField(
required=False,
allow_blank=True,
help_text="Plain text content of the message",
),
"to": drf_serializers.ListField(
child=drf_serializers.EmailField(),
required=False,
help_text="List of recipient email addresses",
),
"cc": drf_serializers.ListField(
child=drf_serializers.EmailField(),
required=False,
default=list,
help_text="List of CC recipient email addresses",
),
"bcc": drf_serializers.ListField(
child=drf_serializers.EmailField(),
required=False,
default=list,
help_text="List of BCC recipient email addresses",
),
},
),
responses={
201: serializers.MessageSerializer,
200: serializers.MessageSerializer,
400: OpenApiExample(
"Validation Error",
value={"detail": "Message does not exist or is not a draft."},
),
403: OpenApiExample(
"Permission Error",
value={"detail": "You do not have permission to perform this action."},
),
},
description="""
Create or update a draft message.
This endpoint allows you to:
- Create a new draft message in a new thread
- Create a draft reply to an existing message in an existing thread
- Update an existing draft message
For creating a new draft:
- Do not include messageId
- Include parentId if replying to an existing message
For updating an existing draft:
- Include messageId of the draft to update
- Only the fields that are provided will be updated
At least one of htmlBody or textBody must be provided.
""",
examples=[
OpenApiExample(
"New Draft Message",
value={
"subject": "Hello",
"textBody": "This is a draft message",
"to": ["recipient@example.com"],
"cc": ["cc@example.com"],
"bcc": ["bcc@example.com"],
},
),
OpenApiExample(
"Draft Reply",
value={
"parentId": "123e4567-e89b-12d3-a456-426614174000",
"subject": "Re: Hello",
"textBody": "This is a draft reply",
"to": ["recipient@example.com"],
},
),
OpenApiExample(
"Update Draft",
value={
"messageId": "123e4567-e89b-12d3-a456-426614174000",
"subject": "Updated subject",
"textBody": "Updated draft content",
"to": ["new-recipient@example.com"],
},
),
],
)
class DraftMessageView(APIView):
"""Create or update a draft message.
This endpoint is used to create a new draft message, draft reply, or update an existing draft.
POST /api/v1.0/draft/ with expected data:
- parentId: str (optional, message id if reply, None if first message)
- senderId: str (mailbox id of the sender)
- subject: str
- draftBody: str (optional)
- to: list[str] (optional)
- cc: list[str] (optional)
- bcc: list[str] (optional)
Return newly created draft message
PUT /api/v1.0/draft/{message_id}/ with expected data:
- subject: str (optional)
- draftBody: str (optional)
- to: list[str] (optional)
- cc: list[str] (optional)
- bcc: list[str] (optional)
Return updated draft message
"""
permission_classes = [permissions.IsAllowedToCreateMessage]
mailbox = None
def post(self, request):
"""Create a new draft message."""
sender_id = request.data.get("senderId")
self.mailbox = models.Mailbox.objects.get(id=sender_id)
subject = request.data.get("subject")
# Then get the parent message if it's a reply
parent_id = request.data.get("parentId")
reply_to_message = None
if parent_id:
try:
# Reply to an existing message in a thread
reply_to_message = models.Message.objects.get(id=parent_id)
thread = reply_to_message.thread
except models.Message.DoesNotExist as exc:
raise drf.exceptions.ValidationError(
"Parent message does not exist."
) from exc
else:
# Create a new thread
thread = models.Thread.objects.create(
# self.mailbox is set in the permission class
mailbox=self.mailbox,
subject=subject,
# TODO: enhance to use htmlBody if is the only one provided
snippet=request.data.get("textBody", "")[:100],
is_read=True,
)
recipients = {
"to": request.data.get("to") or [],
"cc": request.data.get("cc") or [],
"bcc": request.data.get("bcc") or [],
}
# Create contacts if they don't exist
contacts = {
kind: [
models.Contact.objects.get_or_create(email=email, owner=self.mailbox)[0]
for email in emails
]
for kind, emails in recipients.items()
}
try:
sender_contact = models.Contact.objects.get(
email=str(self.mailbox), owner=self.mailbox
)
except models.Contact.DoesNotExist as exc:
raise drf.exceptions.ValidationError(
"Sender contact does not exist."
) from exc
# Create message instance with all data
message = models.Message(
thread=thread,
sender=sender_contact,
subject=subject,
created_at=timezone.now(),
read_at=timezone.now(),
mta_sent=False,
is_draft=True, # Mark as draft
)
message.mime_id = message.generate_mime_id()
# Note that BCC recipients are not included in the raw mime message
mime_data = {
"from": [
{
"name": sender_contact.name,
"email": sender_contact.email,
}
],
"to": [
{"name": contact.name, "email": contact.email}
for contact in contacts["to"]
],
"cc": [
{"name": contact.name, "email": contact.email}
for contact in contacts["cc"]
],
"subject": subject,
"textBody": [request.data["textBody"]]
if request.data.get("textBody")
else [],
"htmlBody": [request.data["htmlBody"]]
if request.data.get("htmlBody")
else [],
# Generate a MIME message ID based on the message.id
"messageId": message.mime_id,
}
# TODO: add "References" header if replying to a message, with all message ids of the thread
# Assemble the raw mime message
raw_mime = compose_email(
mime_data,
in_reply_to=reply_to_message.mime_id
if reply_to_message and reply_to_message.mime_id
else None,
)
# Save the raw mime message
message.raw_mime = raw_mime
message.save()
for kind, cts in contacts.items():
for contact in cts:
models.MessageRecipient.objects.create(
message=message,
contact=contact,
type=kind,
)
return Response(
serializers.MessageSerializer(message).data, status=status.HTTP_201_CREATED
)
def put(self, request, message_id=None):
"""Update an existing draft message."""
if not message_id:
return Response(
{"detail": "Message ID is required for updating a draft."},
status=status.HTTP_400_BAD_REQUEST,
)
sender_id = request.data.get("senderId")
self.mailbox = models.Mailbox.objects.get(id=sender_id)
try:
message = models.Message.objects.get(id=message_id, is_draft=True)
except models.Message.DoesNotExist as exc:
raise drf.exceptions.ValidationError(
"Message does not exist or is not a draft."
) from exc
# Check if user has permission to update this draft
if message.thread.mailbox != self.mailbox:
raise drf.exceptions.PermissionDenied(
"You do not have permission to update this draft."
)
# Update subject if provided
if "subject" in request.data:
message.subject = request.data["subject"]
# Also update thread subject if this is the first message
if message.thread.messages.count() == 1:
message.thread.subject = request.data["subject"]
message.thread.save(update_fields=["subject", "updated_at"])
# Update recipients if provided
recipient_types = ["to", "cc", "bcc"]
recipients_updated = False
for recipient_type in recipient_types:
if recipient_type in request.data:
# Delete existing recipients of this type
message.recipients.filter(type=recipient_type).delete()
# Create new recipients
emails = request.data.get(recipient_type) or []
for email in emails:
contact = models.Contact.objects.get_or_create(
email=email, owner=self.mailbox
)[0]
models.MessageRecipient.objects.create(
message=message,
contact=contact,
type=recipient_type,
)
recipients_updated = True
# Update message content if provided
content_updated = False
if (
"textBody" in request.data
or "htmlBody" in request.data
or recipients_updated
):
content_updated = True
# Get current sender
sender_contact = message.sender
# Get all recipients for the updated MIME message
recipients = {
kind: [
{"name": recipient.contact.name, "email": recipient.contact.email}
for recipient in message.recipients.filter(type=kind)
]
for kind in ["to", "cc"] # BCC not included in MIME headers
}
# Prepare MIME data
mime_data = {
"from": [
{
"name": sender_contact.name,
"email": sender_contact.email,
}
],
"to": recipients["to"],
"cc": recipients["cc"],
"subject": message.subject,
"textBody": [request.data.get("textBody", "")],
"htmlBody": [request.data.get("htmlBody", "")],
"messageId": message.mime_id,
}
# Get parent message if this is a reply
parent_message = None
thread_messages = message.thread.messages.exclude(id=message.id).order_by(
"created_at"
)
if thread_messages.exists():
parent_message = thread_messages.first()
# Assemble the raw mime message
raw_mime = compose_email(
mime_data,
in_reply_to=parent_message.mime_id
if parent_message and parent_message.mime_id
else None,
)
# Save the raw mime message
# TODO: Do this later in optimized storage (Object Storage), with deduplication hashes.
message.raw_mime = raw_mime
# Update thread snippet if text body was updated
if "textBody" in request.data and message.thread.messages.count() == 1:
message.thread.snippet = request.data.get("textBody", "")[:100]
message.thread.save(update_fields=["snippet", "updated_at"])
# Save message if any changes were made
if content_updated or "subject" in request.data:
message.save()
return Response(serializers.MessageSerializer(message).data)
@extend_schema(
tags=["messages"],
request=inline_serializer(
name="SendMessageRequest",
fields={
"messageId": drf_serializers.UUIDField(
required=True,
help_text="ID of the draft message to send",
),
},
),
responses={
200: serializers.MessageSerializer,
400: OpenApiExample(
"Validation Error",
value={"detail": "Message does not exist or is not a draft."},
),
403: OpenApiExample(
"Permission Error",
value={"detail": "You do not have permission to perform this action."},
),
},
description="""
Send a previously created draft message.
This endpoint allows you to send a message that was previously created as a draft.
""",
examples=[
OpenApiExample(
"Send Draft",
value={
"messageId": "123e4567-e89b-12d3-a456-426614174000",
},
),
],
)
class SendMessageView(APIView):
"""Send a previously created draft message.
This endpoint is used to send a message that was previously created as a draft.
POST /api/v1.0/send/ with expected data:
- messageId: str (ID of the draft message to send)
- senderId: str (ID of the mailbox to use as sender)
- draftBody: str (optional)
- to: list[str] (optional)
- cc: list[str] (optional)
- bcc: list[str] (optional)
Return the sent message
"""
permission_classes = [permissions.IsAllowedToSendMessage]
mailbox = None
def post(self, request):
"""Send a draft message."""
message_id = request.data.get("messageId")
sender_id = request.data.get("senderId")
self.mailbox = models.Mailbox.objects.get(id=sender_id)
try:
message = models.Message.objects.get(id=message_id, is_draft=True)
except models.Message.DoesNotExist as exc:
raise drf.exceptions.ValidationError(
"Message does not exist or is not a draft."
) from exc
# Check if user has permission to send from this mailbox
if message.thread.mailbox != self.mailbox:
raise drf.exceptions.PermissionDenied(
"You do not have permission to send from this mailbox."
)
# TODO: Sending to the MTA should be done asynchronously. Move this to a Celery task
# Prepend the DKIM header to the raw mime message
dkim_signature = message.generate_dkim_signature()
if dkim_signature is None:
raw_mime_signed = message.raw_mime
else:
raw_mime_signed = dkim_signature + message.raw_mime
if not settings.MTA_OUT_HOST:
logger.warning("MTA_OUT_HOST is not set, skipping message sending")
else:
for _ in range(5):
try:
client = smtplib.SMTP(
settings.MTA_OUT_HOST.split(":")[0],
int(settings.MTA_OUT_HOST.split(":")[1]),
)
client.ehlo()
client.starttls()
client.ehlo()
# Authenticate
if (
settings.MTA_OUT_SMTP_USERNAME
and settings.MTA_OUT_SMTP_PASSWORD
):
client.login(
settings.MTA_OUT_SMTP_USERNAME,
settings.MTA_OUT_SMTP_PASSWORD,
)
# Get all recipients
recipients = message.recipients.all()
envelope_to = [recipient.contact.email for recipient in recipients]
envelope_from = message.sender.email
smtp_response = client.sendmail(
envelope_from, envelope_to, raw_mime_signed
)
logger.info("SMTP response: %s", smtp_response)
# Update message status
message.mta_sent = True
message.sent_at = timezone.now()
message.is_draft = False # No longer a draft
message.save(
update_fields=["mta_sent", "sent_at", "is_draft", "updated_at"]
)
break
except Exception as e: # noqa: BLE001 pylint: disable=broad-exception-caught
logger.error("Error sending message to the MTA: %s", e)
time.sleep(1)
else:
# If we get here, all attempts failed
return Response(
{"detail": "Failed to send message after multiple attempts."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response(serializers.MessageSerializer(message).data)