schon/engine/vibes_auth/docs/drf/emailing.py
Egor fureunoir Gorbunov 20473818a9 feat(emailing): add OpenAPI schemas for unsubscribe and tracking endpoints
Includes detailed OpenAPI schemas for unsubscribe (GET and POST) and tracking pixel (GET) endpoints, supporting email compatibility and event tracking. Added support for RFC 8058-compliant one-click unsubscribe functionality and transparent image-based email tracking.
2026-02-05 19:30:53 +03:00

253 lines
9.4 KiB
Python

from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
OpenApiExample,
OpenApiParameter,
OpenApiResponse,
extend_schema,
inline_serializer,
)
from rest_framework import serializers, status
from engine.core.docs.drf import error
_unsubscribe_token_param = OpenApiParameter(
name="token",
location=OpenApiParameter.QUERY,
description=_(
"UUID token for unsubscribing. This token is unique per user and is included "
"in the unsubscribe link of every campaign email. The token remains constant "
"for each user unless regenerated."
),
required=True,
type=str,
examples=[
OpenApiExample(
name="Valid token",
value="550e8400-e29b-41d4-a716-446655440000",
description="A valid UUID v4 unsubscribe token",
),
],
)
_unsubscribe_success_response = inline_serializer(
name="UnsubscribeSuccessResponse",
fields={
"detail": serializers.CharField(
default=_("You have been successfully unsubscribed from our emails.")
),
},
)
_unsubscribe_already_response = inline_serializer(
name="UnsubscribeAlreadyResponse",
fields={
"detail": serializers.CharField(default=_("You are already unsubscribed.")),
},
)
UNSUBSCRIBE_GET_SCHEMA = extend_schema(
tags=["emailing"],
operation_id="emailing_unsubscribe_get",
summary=_("Unsubscribe from email campaigns"),
description=_(
"Unsubscribe a user from all marketing email campaigns using their unique "
"unsubscribe token.\n\n"
"This endpoint is designed for email client compatibility where clicking a link "
"triggers a GET request. The user will no longer receive promotional emails "
"after successful unsubscription.\n\n"
"**Note:** Transactional emails (order confirmations, password resets, etc.) "
"are not affected by this setting."
),
parameters=[_unsubscribe_token_param],
responses={
status.HTTP_200_OK: OpenApiResponse(
response=_unsubscribe_success_response,
description=_("Successfully unsubscribed from email campaigns."),
examples=[
OpenApiExample(
name="Unsubscribed",
value={
"detail": "You have been successfully unsubscribed from our emails."
},
),
OpenApiExample(
name="Already unsubscribed",
value={"detail": "You are already unsubscribed."},
),
],
),
status.HTTP_400_BAD_REQUEST: OpenApiResponse(
response=error,
description=_("Invalid or missing unsubscribe token."),
examples=[
OpenApiExample(
name="Missing token",
value={"detail": "Unsubscribe token is required."},
),
OpenApiExample(
name="Invalid format",
value={"detail": "Invalid unsubscribe token format."},
),
],
),
status.HTTP_404_NOT_FOUND: OpenApiResponse(
response=error,
description=_("User associated with the token was not found."),
examples=[
OpenApiExample(
name="User not found",
value={"detail": "User not found."},
),
],
),
},
examples=[
OpenApiExample(
name="Unsubscribe request",
description="Example unsubscribe request with token",
value=None,
request_only=True,
),
],
)
UNSUBSCRIBE_POST_SCHEMA = extend_schema(
tags=["emailing"],
operation_id="emailing_unsubscribe_post",
summary=_("One-Click Unsubscribe (RFC 8058)"),
description=_(
"RFC 8058 compliant one-click unsubscribe endpoint for email campaigns.\n\n"
"This endpoint supports the List-Unsubscribe-Post header mechanism defined in "
"RFC 8058, which allows email clients to unsubscribe users with a single click "
"without leaving the email application.\n\n"
"The token can be provided either as a query parameter or in the request body.\n\n"
"**Standards Compliance:**\n"
"- RFC 8058: Signaling One-Click Functionality for List Email Headers\n"
"- RFC 2369: The Use of URLs as Meta-Syntax for Core Mail List Commands\n\n"
"**Note:** Transactional emails are not affected by this setting."
),
parameters=[_unsubscribe_token_param],
request=inline_serializer(
name="UnsubscribeRequest",
fields={
"token": serializers.UUIDField(
required=False,
help_text=_(
"Unsubscribe token (alternative to query parameter). "
"Can be omitted if token is provided in URL."
),
),
},
),
responses={
status.HTTP_200_OK: OpenApiResponse(
response=_unsubscribe_success_response,
description=_("Successfully unsubscribed from email campaigns."),
examples=[
OpenApiExample(
name="Unsubscribed",
value={
"detail": "You have been successfully unsubscribed from our emails."
},
),
OpenApiExample(
name="Already unsubscribed",
value={"detail": "You are already unsubscribed."},
),
],
),
status.HTTP_400_BAD_REQUEST: OpenApiResponse(
response=error,
description=_("Invalid or missing unsubscribe token."),
examples=[
OpenApiExample(
name="Missing token",
value={"detail": "Unsubscribe token is required."},
),
OpenApiExample(
name="Invalid format",
value={"detail": "Invalid unsubscribe token format."},
),
],
),
status.HTTP_404_NOT_FOUND: OpenApiResponse(
response=error,
description=_("User associated with the token was not found."),
examples=[
OpenApiExample(
name="User not found",
value={"detail": "User not found."},
),
],
),
},
external_docs={
"description": "RFC 8058 - Signaling One-Click Functionality",
"url": "https://datatracker.ietf.org/doc/html/rfc8058",
},
)
UNSUBSCRIBE_SCHEMA = {
"get": UNSUBSCRIBE_GET_SCHEMA,
"post": UNSUBSCRIBE_POST_SCHEMA,
}
TRACKING_SCHEMA = {
"get": extend_schema(
tags=["emailing"],
operation_id="emailing_tracking_pixel",
summary=_("Track email open event"),
description=_(
"Records when a campaign email is opened by the recipient.\n\n"
"This endpoint is called automatically when the tracking pixel (1x1 transparent GIF) "
"embedded in the email is loaded by the recipient's email client.\n\n"
"**How it works:**\n"
"1. Each campaign email contains a unique tracking pixel URL with a `tid` parameter\n"
"2. When the email is opened and images are loaded, this endpoint is called\n"
"3. The recipient's status is updated to 'opened' and the timestamp is recorded\n"
"4. The campaign's aggregate opened count is updated\n\n"
"**Privacy considerations:**\n"
"- Only the first open is recorded (subsequent opens are ignored)\n"
"- No personal information beyond the tracking ID is logged\n"
"- Users who disable image loading will not trigger this event\n\n"
"**Response:**\n"
"Returns a 1x1 transparent GIF image regardless of whether tracking succeeded, "
"to ensure consistent behavior and prevent information leakage."
),
parameters=[
OpenApiParameter(
name="tid",
location=OpenApiParameter.QUERY,
description=_(
"Tracking ID (UUID) unique to each campaign-recipient combination. "
"This ID links the open event to a specific recipient and campaign."
),
required=True,
type=str,
examples=[
OpenApiExample(
name="Valid tracking ID",
value="123e4567-e89b-12d3-a456-426614174000",
description="A valid UUID v4 tracking identifier",
),
],
),
],
responses={
status.HTTP_200_OK: OpenApiResponse(
response=OpenApiTypes.BINARY,
description=_(
"1x1 transparent GIF image. Always returned regardless of tracking status "
"to maintain consistent behavior."
),
),
status.HTTP_404_NOT_FOUND: OpenApiResponse(
description=_(
"Returned when no tracking ID is provided. Note: Invalid tracking IDs "
"still return 200 with the GIF to prevent enumeration attacks."
),
),
},
),
}