Repository navigation
feat(messaging): read a transactional email's status by message id #123932
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
+791
−18
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
19b66db
feat(messaging): read a transactional email's status by message id
mayteio cd99353
fix(messaging): check workflow access and report skips from the worke…
mayteio 29353cf
fix(messaging): only let email events refine a send the worker recorded
mayteio 41f4cbe
fix(messaging): allow read-only keys for email status and throttle se…
mayteio 6451324
refactor(messaging): report a transactional email as queued, sent or …
mayteio File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| """The latest lifecycle state of one hog flow or hog function run, read from hog_invocation_results.""" | ||
|
|
||
| from collections.abc import Sequence | ||
| from datetime import UTC, datetime | ||
| from typing import Literal, Optional, cast | ||
|
|
||
| from posthog.clickhouse.client.connection import Workload | ||
| from posthog.clickhouse.client.execute import sync_execute | ||
| from posthog.dataclasses import frozen | ||
|
|
||
| InvocationLifecycleStatus = Literal["running", "succeeded", "failed", "canceled"] | ||
|
|
||
|
|
||
| @frozen | ||
| class InvocationLifecycle: | ||
| invocation_id: str | ||
| function_id: str | ||
| distinct_id: str | ||
| status: InvocationLifecycleStatus | ||
| scheduled_at: datetime | ||
| # None while the run waits on the queue. The producer writes a running row with no start when it queues a run. | ||
| started_at: Optional[datetime] | ||
| finished_at: Optional[datetime] | ||
| error_kind: str | ||
|
|
||
|
|
||
| def fetch_invocation_lifecycle( | ||
| team_id: int, | ||
| function_kind: str, | ||
| function_ids: Sequence[str], | ||
| invocation_id: str, | ||
| scheduled_after: datetime, | ||
| ) -> Optional[InvocationLifecycle]: | ||
| """The run's latest state, or None. Only searches the given functions, so the read stays on the sort key.""" | ||
| if not function_ids: | ||
| return None | ||
| kwargs = { | ||
| "team_id": team_id, | ||
| "function_kind": function_kind, | ||
| "function_ids": list(function_ids), | ||
| "invocation_id": invocation_id, | ||
| "scheduled_after": scheduled_after.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S"), | ||
| } | ||
| # Lifecycle rows collapse to the latest version. The aliases differ from the column names, because ClickHouse | ||
| # would resolve a same-named alias in WHERE to the aggregate. | ||
| query = """ | ||
| SELECT | ||
| invocation_id, latest_function_id, latest_distinct_id, latest_status, latest_scheduled_at, | ||
| latest_started_at, latest_finished_at, latest_error_kind | ||
| FROM ( | ||
| SELECT | ||
| invocation_id, | ||
| argMax(function_id, version) AS latest_function_id, | ||
| argMax(distinct_id, version) AS latest_distinct_id, | ||
| argMax(status, version) AS latest_status, | ||
| min(scheduled_at) AS latest_scheduled_at, | ||
| argMax(started_at, version) AS latest_started_at, | ||
| argMax(finished_at, version) AS latest_finished_at, | ||
| argMax(error_kind, version) AS latest_error_kind, | ||
| argMax(is_deleted, version) AS latest_is_deleted | ||
| FROM hog_invocation_results | ||
| WHERE team_id = %(team_id)s | ||
| AND function_kind = %(function_kind)s | ||
| AND function_id IN %(function_ids)s | ||
| AND invocation_id = %(invocation_id)s | ||
| AND scheduled_at >= toDateTime64(%(scheduled_after)s, 6) | ||
| GROUP BY invocation_id | ||
| ) | ||
| WHERE latest_is_deleted = 0 | ||
| LIMIT 1 | ||
| """ | ||
| # Called for API-key reads that a server polls. No person waits on screen. | ||
| results = cast(list, sync_execute(query, kwargs, workload=Workload.OFFLINE, team_id=team_id)) | ||
| if not results: | ||
| return None | ||
| row = results[0] | ||
| return InvocationLifecycle( | ||
| invocation_id=row[0], | ||
| function_id=row[1], | ||
| distinct_id=row[2], | ||
| status=row[3], | ||
| scheduled_at=row[4], | ||
| started_at=row[5], | ||
| finished_at=row[6], | ||
| error_kind=row[7], | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
131 changes: 131 additions & 0 deletions
131
products/messaging/backend/presentation/views/transactional_email_status.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| 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 | ||
| from products.workflows.backend.facade.api import WorkflowAccessDenied, WorkflowNotFound | ||
| from products.workflows.backend.facade.workflows import get_workflow | ||
|
|
||
| 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.", | ||
| ) | ||
| 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: the error kind of a run that errored, or `not_sent` when the run " | ||
| "finished without sending, for example because the recipient was suppressed after the send. 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]: | ||
| # The email read buckets only count API keys, so every other caller keeps the default API throttles. | ||
| if not is_authenticated_via_project_secret_api_key(self.request): | ||
| return super().get_throttles() | ||
| return [ | ||
| TransactionalEmailReadBurstThrottle(), | ||
| TransactionalEmailReadSustainedThrottle(), | ||
| TransactionalEmailReadTeamBurstThrottle(), | ||
| TransactionalEmailReadTeamSustainedThrottle(), | ||
| ] | ||
|
mayteio marked this conversation as resolved.
|
||
|
|
||
| @extend_schema( | ||
| parameters=[MESSAGE_ID_PARAMETER], | ||
| responses={ | ||
| 200: EmailStatusSerializer, | ||
| 404: OpenApiResponse( | ||
| description="No email of this project has this ID. 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: `queued` while " | ||
| "it waits to go out, `sent` once PostHog hands it to the email provider, or `failed` when it was not sent." | ||
| ), | ||
| ) | ||
| def retrieve(self, request: Request, *args: Any, pk: str | None = None, **kwargs: Any) -> Response: | ||
| require_transactional_email_api(self.team) | ||
| try: | ||
| message_id = UUID(str(pk)) | ||
| except ValueError: | ||
| raise NotFound() | ||
| email_status = get_email_status(self.team, message_id) | ||
| if email_status is None: | ||
| raise NotFound() | ||
| self._require_workflow_viewer(request, email_status.workflow_id) | ||
| return Response(EmailStatusSerializer(email_status).data) | ||
|
mayteio marked this conversation as resolved.
|
||
|
|
||
| def _require_workflow_viewer(self, request: Request, workflow_id: str | None) -> None: | ||
| # The send route needs editor access to the email's workflow, so a read needs viewer access. A project | ||
| # secret API key has no user to check, and its scope already limits it to transactional email. | ||
| if is_authenticated_via_project_secret_api_key(request): | ||
| return | ||
| if workflow_id is None: | ||
| raise NotFound() | ||
| try: | ||
| get_workflow( | ||
| team_id=self.team_id, | ||
| workflow_id=workflow_id, | ||
| user_access_control=self.user_access_control, | ||
| required_level="viewer", | ||
| ) | ||
| except WorkflowAccessDenied: | ||
| raise exceptions.PermissionDenied("You need viewer access to this email's workflow.") from None | ||
| except WorkflowNotFound: | ||
| # The workflow was deleted since. Its sends keep the project's own access rules. | ||
| return | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.