diff --git a/frontend/src/lib/scopes.tsx b/frontend/src/lib/scopes.tsx index 510580043bef..f522dcb17411 100644 --- a/frontend/src/lib/scopes.tsx +++ b/frontend/src/lib/scopes.tsx @@ -380,6 +380,7 @@ export const PROJECT_SECRET_API_KEY_ALLOWED_API_SCOPE_ACTION = [ 'billing:read', 'replay_scanner:write', 'session_recording:read', + 'hog_flow:read', 'hog_flow:write', ] as const diff --git a/posthog/api/test/test_project_secret_api_keys.py b/posthog/api/test/test_project_secret_api_keys.py index b24ad90598cc..7f5ac581e9a0 100644 --- a/posthog/api/test/test_project_secret_api_keys.py +++ b/posthog/api/test/test_project_secret_api_keys.py @@ -81,13 +81,20 @@ def test_create_project_secret_api_key(self): assert data["last_used_at"] is None assert data["value"].startswith("phs_") - def test_create_feature_flag_read_scope(self): + @parameterized.expand( + [ + ("feature_flag_local_evaluation", "feature_flag:read"), + # A service that polls a transactional email's status must not need a key that can also send. + ("transactional_email_status", "hog_flow:read"), + ] + ) + def test_create_read_only_scope(self, _name, scope): response = self.client.post( f"/api/projects/{self.team.id}/project_secret_api_keys", - {"label": "local eval key", "scopes": ["feature_flag:read"]}, + {"label": "read only key", "scopes": [scope]}, ) - assert response.status_code == 201 - assert response.json()["scopes"] == ["feature_flag:read"] + assert response.status_code == 201, response.json() + assert response.json()["scopes"] == [scope] def test_create_too_many_api_keys(self): for i in range(0, MAX_PROJECT_SECRET_API_KEYS_PER_TEAM): diff --git a/posthog/scopes.py b/posthog/scopes.py index dd0405c31dd3..aa63052355a7 100644 --- a/posthog/scopes.py +++ b/posthog/scopes.py @@ -275,8 +275,10 @@ # Both are needed together; every other replay_scanner and session_recording route stays PSAK-denied. ("replay_scanner", "write"), ("session_recording", "read"), - # Lets a service send transactional email through `messaging_templates/:id/send/`. Every other - # hog_flow route stays PSAK-denied, because no other hog_flow viewset lists PSAK actions. + # Lets a service send transactional email (`messaging_templates/:id/send/`, `messaging/send/email/`) and read a + # send's status (`messaging/emails/:id/`). Every other hog_flow route stays PSAK-denied, because no other hog_flow + # viewset lists PSAK actions. + ("hog_flow", "read"), ("hog_flow", "write"), ] diff --git a/products/messaging/backend/facade/contracts.py b/products/messaging/backend/facade/contracts.py index f6ecabcad672..25c3a76cb5c5 100644 --- a/products/messaging/backend/facade/contracts.py +++ b/products/messaging/backend/facade/contracts.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from datetime import datetime from typing import Any from uuid import UUID @@ -158,3 +159,22 @@ class SupabaseHookResult: error_code: str = "" # How many emails went out. Email change can send two. sent_count: int = 0 + + +class EmailDeliveryStatus(LabeledStrEnum): + QUEUED = "queued", "Queued" + FAILED = "failed", "Failed" + SENT = "sent", "Sent" + DELIVERED = "delivered", "Delivered" + BOUNCED = "bounced", "Bounced" + COMPLAINED = "complained", "Complained" + + +@frozen +class EmailStatus: + message_id: str + status: EmailDeliveryStatus + created_at: datetime + status_at: datetime + # Why a send failed, or the bounce type of a bounced email. + reason: str | None = None diff --git a/products/messaging/backend/facade/transactional_email.py b/products/messaging/backend/facade/transactional_email.py index 105a55325fae..4e5fe7ec755d 100644 --- a/products/messaging/backend/facade/transactional_email.py +++ b/products/messaging/backend/facade/transactional_email.py @@ -1,6 +1,12 @@ """Transactional email: a template made sendable over the API, backed by a workflow that runs once per send.""" -from products.messaging.backend.facade.contracts import DeliveryUnavailable, QueuedEmail, TransactionalEmailError +from products.messaging.backend.facade.contracts import ( + DeliveryUnavailable, + EmailDeliveryStatus, + EmailStatus, + QueuedEmail, + TransactionalEmailError, +) from products.messaging.backend.services.transactional_email import ( SENDER_ADDRESS_MAX_LENGTH, check_transactional_update, @@ -10,13 +16,17 @@ sender_address_error, sync_transactional_workflow, ) +from products.messaging.backend.services.transactional_email_status import get_email_status __all__ = [ "SENDER_ADDRESS_MAX_LENGTH", "DeliveryUnavailable", + "EmailDeliveryStatus", + "EmailStatus", "QueuedEmail", "TransactionalEmailError", "check_transactional_update", + "get_email_status", "lock_transactional_workflow_id", "make_transactional", "send_transactional_email", diff --git a/products/messaging/backend/presentation/views/message_templates.py b/products/messaging/backend/presentation/views/message_templates.py index cfc7be24241f..c7000d0069e1 100644 --- a/products/messaging/backend/presentation/views/message_templates.py +++ b/products/messaging/backend/presentation/views/message_templates.py @@ -490,6 +490,17 @@ class TransactionalEmailTeamSustainedThrottle(ProjectSecretApiKeyTeamRateThrottl rate = "20000/hour" +def require_transactional_email_api(team: Any) -> None: + if not posthoganalytics.feature_enabled( + TRANSACTIONAL_EMAIL_API_FLAG, + str(team.uuid), + groups={"organization": str(team.organization_id)}, + group_properties={"organization": {"id": str(team.organization_id)}}, + send_feature_flag_events=False, + ): + raise NotFound() + + def transactional_email_throttles() -> list[BaseThrottle]: # Both send routes share these scopes, so a caller can't double its rate by using both. return [ @@ -528,14 +539,7 @@ class TransactionalEmailSendMixin: user_access_control: Any def _require_transactional_email_flag(self) -> None: - if not posthoganalytics.feature_enabled( - TRANSACTIONAL_EMAIL_API_FLAG, - str(self.team.uuid), - groups={"organization": str(self.team.organization_id)}, - group_properties={"organization": {"id": str(self.team.organization_id)}}, - send_feature_flag_events=False, - ): - raise NotFound() + require_transactional_email_api(self.team) def _queue_transactional_email(self, request: Any, template: MessageTemplateRow, *, route: str) -> Response: if template.transactional_hog_flow_id: diff --git a/products/messaging/backend/presentation/views/transactional_email_status.py b/products/messaging/backend/presentation/views/transactional_email_status.py new file mode 100644 index 000000000000..8a5fcfa8f41b --- /dev/null +++ b/products/messaging/backend/presentation/views/transactional_email_status.py @@ -0,0 +1,118 @@ +from typing import Any +from uuid import UUID + +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +from rest_framework import exceptions, serializers, viewsets +from rest_framework.exceptions import NotFound +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.throttling import BaseThrottle + +from posthog.api.routing import TeamAndOrgViewSetMixin +from posthog.auth import ProjectSecretAPIKeyAuthentication +from posthog.permissions import is_authenticated_via_project_secret_api_key +from posthog.rate_limit import PersonalOrProjectSecretApiKeyRateThrottle, ProjectSecretApiKeyTeamRateThrottle + +from products.messaging.backend.facade.transactional_email import EmailDeliveryStatus, get_email_status +from products.messaging.backend.presentation.views.message_templates import require_transactional_email_api + +MESSAGE_ID_PARAMETER = OpenApiParameter( + name="id", + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + description="The `message_id` that the send returned.", +) + + +class EmailStatusSerializer(serializers.Serializer): + id = serializers.CharField(source="message_id", help_text="The `message_id` that the send returned.") + status = serializers.ChoiceField( + choices=EmailDeliveryStatus.choices, + help_text="What happened to the email. `queued` means it waits to go out. `sent` means PostHog handed it to " + "the email provider. `failed` means it was not sent, and `reason` says why. A rerun of the send can still move it to `sent`. `delivered` means the recipient's " + "mail server accepted it. `bounced` and `complained` come later from the recipient's mail provider.", + ) + created_at = serializers.DateTimeField(help_text="When the send was queued.") + status_at = serializers.DateTimeField(help_text="When the email reached its current status.") + reason = serializers.CharField( + allow_null=True, + help_text="Why a failed email was not sent, or the bounce type of a bounced email. A failed send has " + "`provider_error`, or `provider_rejected` when the provider refused the email after it accepted the send. A skipped send has `recipient_suppressed`, `recipient_unreachable`, `recipient_opted_out`, " + "`missing_recipient`, `sending_paused`, `sending_suspended` or `frequency_capped`. A run that stopped before " + "the send has `canceled`, `workflow_unavailable` or an error kind such as `timeout`, and more error kinds can " + "be added. A bounce has `hard`, `soft` or `unknown`. Null otherwise.", + ) + + +# Reads have their own buckets, so polling for status can't use up the send budget. +class TransactionalEmailReadBurstThrottle(PersonalOrProjectSecretApiKeyRateThrottle): + scope = "transactional_email_read_burst" + rate = "600/minute" + + +class TransactionalEmailReadSustainedThrottle(PersonalOrProjectSecretApiKeyRateThrottle): + scope = "transactional_email_read_sustained" + rate = "20000/hour" + + +class TransactionalEmailReadTeamBurstThrottle(ProjectSecretApiKeyTeamRateThrottle): + scope = "transactional_email_read_psak_team_burst" + rate = "600/minute" + + +class TransactionalEmailReadTeamSustainedThrottle(ProjectSecretApiKeyTeamRateThrottle): + scope = "transactional_email_read_psak_team_sustained" + rate = "20000/hour" + + +class MessagingEmailsViewSet(TeamAndOrgViewSetMixin, viewsets.ViewSet): + scope_object = "hog_flow" + scope_object_read_actions = ["retrieve"] + permission_classes = [IsAuthenticated] + authentication_classes = [ProjectSecretAPIKeyAuthentication] + psak_allowed_actions = ["retrieve"] + + def get_throttles(self) -> list[BaseThrottle]: + # Only a project secret API key can read, and the email read buckets count those. Any other caller gets the + # default API throttles on its way to a 401. + if not is_authenticated_via_project_secret_api_key(self.request): + return super().get_throttles() + return [ + TransactionalEmailReadBurstThrottle(), + TransactionalEmailReadSustainedThrottle(), + TransactionalEmailReadTeamBurstThrottle(), + TransactionalEmailReadTeamSustainedThrottle(), + ] + + @extend_schema( + parameters=[MESSAGE_ID_PARAMETER], + responses={ + 200: EmailStatusSerializer, + 401: OpenApiResponse(description="The request has no project secret API key."), + 404: OpenApiResponse( + description="No email of this project has this ID, or it is older than 30 days. A send can also " + "answer 404 for a few seconds right after it was queued." + ), + }, + summary="Get a transactional email's status", + description=( + "Get the status of one transactional email by the `message_id` that the send returned, for 30 days after " + "the send. Needs a project secret API key with the `hog_flow:read` scope." + ), + ) + def retrieve(self, request: Request, *args: Any, pk: str | None = None, **kwargs: Any) -> Response: + # The routing mixin adds session and personal API key authentication to every route. A status is read by the + # server that sent the email, so only its project secret API key can read it. + if not is_authenticated_via_project_secret_api_key(request): + raise exceptions.NotAuthenticated() + require_transactional_email_api(self.team) + try: + message_id = UUID(str(pk)) + except ValueError: + raise NotFound() + email_status = get_email_status(self.team_id, message_id) + if email_status is None: + raise NotFound() + return Response(EmailStatusSerializer(email_status).data) diff --git a/products/messaging/backend/routes.py b/products/messaging/backend/routes.py index 9b74b67834af..4444f2e15305 100644 --- a/products/messaging/backend/routes.py +++ b/products/messaging/backend/routes.py @@ -14,6 +14,7 @@ SupabaseAuthEmailsViewSet, SupabaseSendEmailHookView, ) +from products.messaging.backend.presentation.views.transactional_email_status import MessagingEmailsViewSet webhook_urlpatterns: list[URLPattern] = [ re_path( @@ -48,3 +49,4 @@ def register_routes(routers: RouterRegistry) -> None: "project_messaging_supabase_auth_emails", ["team_id"], ) + routers.projects.register(r"messaging/emails", MessagingEmailsViewSet, "project_messaging_emails", ["team_id"]) diff --git a/products/messaging/backend/services/transactional_email_status.py b/products/messaging/backend/services/transactional_email_status.py new file mode 100644 index 000000000000..213d3d623bae --- /dev/null +++ b/products/messaging/backend/services/transactional_email_status.py @@ -0,0 +1,76 @@ +"""The status of one transactional email, read from the email_status table that the email path writes. + +The send API writes `queued`, the email worker `sent` or `failed`, and the SES webhook `delivered`, `bounced` or +`complained`. A row's version only grows as an email moves forward, so the row with the highest version is the status. +""" + +from datetime import UTC, datetime, timedelta +from typing import cast +from uuid import UUID + +from posthog.clickhouse.client.connection import Workload +from posthog.clickhouse.client.execute import sync_execute +from posthog.clickhouse.query_tagging import Feature, Product, tag_queries + +from products.messaging.backend.facade.contracts import EmailDeliveryStatus, EmailStatus +from products.messaging.backend.models.email_status_sql import EMAIL_STATUS_RETENTION_DAYS, EMAIL_STATUS_TABLE + +# The Node API stamps the send's ID with its own clock, which can run ahead of this one. +_CLOCK_SKEW = timedelta(minutes=5) +_RETENTION = timedelta(days=EMAIL_STATUS_RETENTION_DAYS) +# Every row of a message carries the message's own created_at, so the window prunes to its partition. +_EMAIL_STATUS_QUERY = f""" +SELECT argMax(status, version), argMax(status_at, version), argMax(reason, version) +FROM {EMAIL_STATUS_TABLE} +WHERE team_id = %(team_id)s + AND message_id = %(message_id)s + AND created_at >= %(start)s + AND created_at < %(end)s +GROUP BY message_id +""" + + +def queued_at(message_id: UUID) -> datetime | None: + """When the send was queued, or None when no send the table still keeps can have this ID.""" + # The first 6 bytes of a UUIDT are the Unix time in milliseconds. They are compared as a number first, because a + # made-up ID can name a time far past the last year a datetime holds. + queued_ms = int.from_bytes(message_id.bytes[:6], "big") + now = datetime.now(UTC) + if not (now - _RETENTION).timestamp() * 1000 <= queued_ms <= (now + _CLOCK_SKEW).timestamp() * 1000: + return None + return datetime.fromtimestamp(queued_ms / 1000, tz=UTC) + + +def get_email_status(team_id: int, message_id: UUID) -> EmailStatus | None: + """The send's status, or None when no send of this team has this ID or its first status has not arrived.""" + created_at = queued_at(message_id) + if created_at is None: + return None + tag_queries(product=Product.MESSAGING, feature=Feature.QUERY) + rows = cast( + list, + sync_execute( + _EMAIL_STATUS_QUERY, + { + "team_id": team_id, + "message_id": str(message_id), + "start": created_at - _CLOCK_SKEW, + "end": created_at + _CLOCK_SKEW, + }, + # A server holding a project secret API key polls this. No person waits on screen. + workload=Workload.OFFLINE, + team_id=team_id, + ), + ) + if not rows: + return None + status, status_at, reason = rows[0] + if status not in EmailDeliveryStatus.values: + return None + return EmailStatus( + message_id=str(message_id), + status=EmailDeliveryStatus(status), + created_at=created_at, + status_at=status_at, + reason=reason or None, + ) diff --git a/products/messaging/backend/tests/api/test_transactional_email_status.py b/products/messaging/backend/tests/api/test_transactional_email_status.py new file mode 100644 index 000000000000..0387dc3f2f0d --- /dev/null +++ b/products/messaging/backend/tests/api/test_transactional_email_status.py @@ -0,0 +1,172 @@ +from datetime import UTC, datetime, timedelta + +import time_machine +from posthog.test.base import APIBaseTest, ClickhouseTestMixin +from unittest.mock import patch + +from parameterized import parameterized +from rest_framework import status +from rest_framework.test import APIClient + +from posthog.clickhouse.client.execute import sync_execute +from posthog.models import ProjectSecretAPIKey, Team +from posthog.models.personal_api_key import hash_key_value +from posthog.models.utils import generate_random_token_secret +from posthog.uuidt import UUIDT + +from products.messaging.backend.models.email_status_sql import EMAIL_STATUS_DATA_TABLE + +# The read only compares versions, so these ranks must keep the writer's order but not its values. A status only +# moves forward, and a later status of the same rank wins by its time. +RANK = {"queued": 0, "failed": 2, "sent": 3, "delivered": 5, "bounced": 6, "complained": 7} + + +@time_machine.travel("2026-06-15T12:00:00Z", tick=False) +@patch( + "products.messaging.backend.presentation.views.message_templates.posthoganalytics.feature_enabled", + return_value=True, +) +class TestTransactionalEmailStatus(ClickhouseTestMixin, APIBaseTest): + def setUp(self): + super().setUp() + self.queued_at = datetime.now(UTC).replace(microsecond=0) - timedelta(hours=1) + self.message_id = self._message_id(self.queued_at) + self.client = self._psak_client(["hog_flow:read"]) + + def _message_id(self, at: datetime) -> str: + return str(UUIDT(unix_time_ms=int(at.timestamp() * 1000))) + + def _psak_client(self, scopes: list[str]) -> APIClient: + raw_token = generate_random_token_secret() + ProjectSecretAPIKey.objects.create( + team=self.team, + label=f"email status {ProjectSecretAPIKey.objects.filter(team=self.team).count()}", + secure_value=hash_key_value(raw_token), + scopes=scopes, + mask_value=f"{raw_token[:4]}...{raw_token[-4:]}", + ) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {raw_token}") + return client + + def _url(self, message_id: str) -> str: + return f"/api/projects/{self.team.id}/messaging/emails/{message_id}/" + + def _at(self, seconds: int) -> datetime: + return self.queued_at + timedelta(seconds=seconds) + + def _status_rows(self, rows: list[tuple[str, int, str]], team: Team | None = None) -> None: + """Insert (status, seconds after the send, reason) rows, in the given order.""" + for email_status, seconds, reason in rows: + status_at = self._at(seconds) + sync_execute( + f""" + INSERT INTO {EMAIL_STATUS_DATA_TABLE} + (team_id, message_id, status, version, created_at, status_at, reason, _timestamp, _offset, _partition) + VALUES + """, + [ + { + "team_id": (team or self.team).id, + "message_id": self.message_id, + "status": email_status, + "version": RANK[email_status] * 2**48 + int(status_at.timestamp() * 1000), + "created_at": self.queued_at, + "status_at": status_at, + "reason": reason, + "_timestamp": status_at, + "_offset": 0, + "_partition": 0, + } + ], + ) + + @parameterized.expand( + [ + ("queued", [("queued", 0, "")], "queued", 0, None), + ("sent", [("queued", 0, ""), ("sent", 2, "")], "sent", 2, None), + ("failed", [("queued", 0, ""), ("failed", 2, "provider_error")], "failed", 2, "provider_error"), + ("delivered", [("queued", 0, ""), ("sent", 2, ""), ("delivered", 5, "")], "delivered", 5, None), + ("bounced", [("queued", 0, ""), ("sent", 2, ""), ("bounced", 5, "hard")], "bounced", 5, "hard"), + ( + "complained_after_delivery", + [("sent", 2, ""), ("delivered", 5, ""), ("complained", 60, "abuse")], + "complained", + 60, + "abuse", + ), + # Kafka does not keep the order of a message's rows, so the highest version wins whatever arrives last. + ("a_late_queued_row_keeps_sent", [("sent", 2, ""), ("queued", 0, "")], "sent", 2, None), + # The worker and the SES webhook run on different clocks, so a status never moves back by its time. + ("a_later_sent_never_replaces_delivered", [("delivered", 5, ""), ("sent", 10, "")], "delivered", 5, None), + # A rerun keeps the message id, so its send replaces the attempt that failed. + ( + "a_rerun_sends_after_a_failed_attempt", + [("failed", 2, "provider_error"), ("sent", 30, "")], + "sent", + 30, + None, + ), + ] + ) + def test_reports_the_latest_status_of_the_send(self, _flag, _name, rows, expected_status, status_offset, reason): + self._status_rows(rows) + + response = self.client.get(self._url(self.message_id)) + + assert response.status_code == status.HTTP_200_OK, response.json() + assert response.json() == { + "id": self.message_id, + "status": expected_status, + "created_at": self.queued_at.isoformat().replace("+00:00", "Z"), + "status_at": self._at(status_offset).isoformat().replace("+00:00", "Z"), + "reason": reason, + } + + @parameterized.expand( + [ + ("not_written_yet", "message_id", False), + ("another_projects_send", "message_id", True), + ("not_a_uuid", "not-a-uuid", False), + ("an_id_from_the_future", "ffffffff-ffff-4fff-bfff-ffffffffffff", False), + ("older_than_the_retention", "old", False), + ] + ) + def test_answers_404_for_an_email_it_cannot_report(self, _flag, _name, message_id, other_project): + if other_project: + self._status_rows([("sent", 2, "")], team=Team.objects.create(organization=self.organization, name="Other")) + ids = {"message_id": self.message_id, "old": self._message_id(datetime.now(UTC) - timedelta(days=31))} + + response = self.client.get(self._url(ids.get(message_id, message_id))) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + @parameterized.expand( + [ + ("read_scope", ["hog_flow:read"], status.HTTP_200_OK), + ("no_workflow_scope", ["feature_flag:read"], status.HTTP_403_FORBIDDEN), + ] + ) + def test_project_secret_api_key_needs_the_read_scope(self, _flag, _name, scopes, expected_status): + self._status_rows([("queued", 0, "")]) + + response = self._psak_client(scopes).get(self._url(self.message_id)) + + assert response.status_code == expected_status + + def test_a_session_cannot_read_a_status(self, _flag): + self._status_rows([("queued", 0, "")]) + session = APIClient() + session.force_login(self.user) + + response = session.get(self._url(self.message_id)) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_is_hidden_while_the_flag_is_off(self, flag): + flag.return_value = False + self._status_rows([("queued", 0, "")]) + + response = self.client.get(self._url(self.message_id)) + + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/products/messaging/frontend/generated/api.schemas.ts b/products/messaging/frontend/generated/api.schemas.ts index 125a6803e445..a22ffbc2a0fd 100644 --- a/products/messaging/frontend/generated/api.schemas.ts +++ b/products/messaging/frontend/generated/api.schemas.ts @@ -7,6 +7,48 @@ * PostHog API - generated * OpenAPI spec version: 1.0.0 */ +/** + * * `queued` - Queued + * * `failed` - Failed + * * `sent` - Sent + * * `delivered` - Delivered + * * `bounced` - Bounced + * * `complained` - Complained + */ +export type EmailDeliveryStatusEnumApi = (typeof EmailDeliveryStatusEnumApi)[keyof typeof EmailDeliveryStatusEnumApi] + +export const EmailDeliveryStatusEnumApi = { + Queued: 'queued', + Failed: 'failed', + Sent: 'sent', + Delivered: 'delivered', + Bounced: 'bounced', + Complained: 'complained', +} as const + +export interface EmailStatusApi { + /** The `message_id` that the send returned. */ + id: string + /** What happened to the email. `queued` means it waits to go out. `sent` means PostHog handed it to the email provider. `failed` means it was not sent, and `reason` says why. A rerun of the send can still move it to `sent`. `delivered` means the recipient's mail server accepted it. `bounced` and `complained` come later from the recipient's mail provider. + * + * * `queued` - Queued + * * `failed` - Failed + * * `sent` - Sent + * * `delivered` - Delivered + * * `bounced` - Bounced + * * `complained` - Complained */ + status: EmailDeliveryStatusEnumApi + /** When the send was queued. */ + created_at: string + /** When the email reached its current status. */ + status_at: string + /** + * Why a failed email was not sent, or the bounce type of a bounced email. A failed send has `provider_error`, or `provider_rejected` when the provider refused the email after it accepted the send. A skipped send has `recipient_suppressed`, `recipient_unreachable`, `recipient_opted_out`, `missing_recipient`, `sending_paused`, `sending_suspended` or `frequency_capped`. A run that stopped before the send has `canceled`, `workflow_unavailable` or an error kind such as `timeout`, and more error kinds can be added. A bounce has `hard`, `soft` or `unknown`. Null otherwise. + * @nullable + */ + reason: string | null +} + /** * Data for the template's Liquid placeholders, available as `variables`. For example `{"reset_link": "https://..."}` fills `{{ variables.reset_link }}`. */ diff --git a/products/messaging/frontend/generated/api.ts b/products/messaging/frontend/generated/api.ts index 524043639bae..86ab98c2f59b 100644 --- a/products/messaging/frontend/generated/api.ts +++ b/products/messaging/frontend/generated/api.ts @@ -13,6 +13,7 @@ import type { AddSuppressionRequestApi, BulkAddOptOutsRequestApi, BulkAddOptOutsResultApi, + EmailStatusApi, GenerateLinkRequestApi, ImportProviderOptionApi, MakeTransactionalApi, @@ -61,6 +62,25 @@ type NonReadonly = [T] extends [UnionToIntersection] } : DistributeReadOnlyOverUnions +export const getMessagingEmailsRetrieveUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/messaging/emails/${id}/` +} + +/** + * Get the status of one transactional email by the `message_id` that the send returned, for 30 days after the send. Needs a project secret API key with the `hog_flow:read` scope. + * @summary Get a transactional email's status + */ +export const messagingEmailsRetrieve = async ( + projectId: string, + id: string, + options?: RequestInit +): Promise => { + return apiMutator(getMessagingEmailsRetrieveUrl(projectId, id), { + ...options, + method: 'GET', + }) +} + export const getMessagingSendEmailCreateUrl = (projectId: string) => { return `/api/projects/${projectId}/messaging/send/email/` } diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index a147892d6fbc..cd772ed15e8d 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -41312,6 +41312,26 @@ export namespace Schemas { name: string; } + /** + * * `queued` - Queued + * * `failed` - Failed + * * `sent` - Sent + * * `delivered` - Delivered + * * `bounced` - Bounced + * * `complained` - Complained + */ + export type EmailDeliveryStatusEnum = typeof EmailDeliveryStatusEnum[keyof typeof EmailDeliveryStatusEnum]; + + + export const EmailDeliveryStatusEnum = { + Queued: 'queued', + Failed: 'failed', + Sent: 'sent', + Delivered: 'delivered', + Bounced: 'bounced', + Complained: 'complained', + } as const; + export interface EmailDomainConnectProvider { /** Domain Connect endpoint of the provider. */ endpoint: string; @@ -41686,6 +41706,29 @@ export namespace Schemas { readonly email_sending_suspension_reason: string; } + export interface EmailStatus { + /** The `message_id` that the send returned. */ + id: string; + /** What happened to the email. `queued` means it waits to go out. `sent` means PostHog handed it to the email provider. `failed` means it was not sent, and `reason` says why. A rerun of the send can still move it to `sent`. `delivered` means the recipient's mail server accepted it. `bounced` and `complained` come later from the recipient's mail provider. + * + * * `queued` - Queued + * * `failed` - Failed + * * `sent` - Sent + * * `delivered` - Delivered + * * `bounced` - Bounced + * * `complained` - Complained */ + status: EmailDeliveryStatusEnum; + /** When the send was queued. */ + created_at: string; + /** When the email reached its current status. */ + status_at: string; + /** + * Why a failed email was not sent, or the bounce type of a bounced email. A failed send has `provider_error`, or `provider_rejected` when the provider refused the email after it accepted the send. A skipped send has `recipient_suppressed`, `recipient_unreachable`, `recipient_opted_out`, `missing_recipient`, `sending_paused`, `sending_suspended` or `frequency_capped`. A run that stopped before the send has `canceled`, `workflow_unavailable` or an error kind such as `timeout`, and more error kinds can be added. A bounce has `hard`, `soft` or `unknown`. Null otherwise. + * @nullable + */ + reason: string | null; + } + /** * Highest htmlID suffix per element type, e.g. {"u_row": 1, "u_content_text": 2}. */