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.
253 lines
9.4 KiB
Python
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."
|
|
),
|
|
),
|
|
},
|
|
),
|
|
}
|