diff --git a/products/messaging/backend/presentation/views/message_templates.py b/products/messaging/backend/presentation/views/message_templates.py index 81d76a8562d5..cfc7be24241f 100644 --- a/products/messaging/backend/presentation/views/message_templates.py +++ b/products/messaging/backend/presentation/views/message_templates.py @@ -21,9 +21,10 @@ 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.forbid_destroy_model import ForbidDestroyModel -from posthog.api.mixins import validated_request +from posthog.api.mixins import ResponseDeclaration, validated_request from posthog.api.routing import TeamAndOrgViewSetMixin from posthog.api.shared import UserBasicSerializer from posthog.auth import ProjectSecretAPIKeyAuthentication @@ -414,6 +415,16 @@ class SendTransactionalEmailSerializer(serializers.Serializer): ) +class SendEmailTemplateSerializer(serializers.Serializer): + id = serializers.UUIDField( + help_text="ID of the email template to send. The template must be made transactional first." + ) + + +class SendEmailSerializer(SendTransactionalEmailSerializer): + template = SendEmailTemplateSerializer(help_text="The transactional email template to send.") + + class MakeTransactionalSerializer(serializers.Serializer): from_integration_id = serializers.IntegerField( required=False, @@ -479,6 +490,127 @@ class TransactionalEmailTeamSustainedThrottle(ProjectSecretApiKeyTeamRateThrottl rate = "20000/hour" +def transactional_email_throttles() -> list[BaseThrottle]: + # Both send routes share these scopes, so a caller can't double its rate by using both. + return [ + TransactionalEmailBurstThrottle(), + TransactionalEmailSustainedThrottle(), + TransactionalEmailTeamBurstThrottle(), + TransactionalEmailTeamSustainedThrottle(), + ] + + +SEND_RESPONSES: dict[int, ResponseDeclaration] = { + 202: OpenApiResponse(response=SendTransactionalEmailResponseSerializer, description="Email queued"), + 400: OpenApiResponse( + description="The template isn't transactional or its email is paused, the recipient can't be used, " + "or the Idempotency-Key is invalid" + ), + 404: OpenApiResponse(description="Template not found"), + 409: OpenApiResponse(description="A request with the same Idempotency-Key is still in progress"), + 422: OpenApiResponse( + description="The email can't go out: the recipient is suppressed or can't receive email, sending is " + "suspended for the project or paused for this transactional email, or the Idempotency-Key was already " + "used for a different request" + ), + 429: OpenApiResponse(description="Rate limited, or the project is over its email quota"), + 503: OpenApiResponse(description="The email pipeline did not confirm the send"), +} + + +# The send path shared by the template-scoped route and the single send route. A comment, not a docstring: +# drf-spectacular would publish a docstring as the description of every operation on both viewsets. +class TransactionalEmailSendMixin: + request: Any + team: Any + team_id: int + organization: Any + 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() + + def _queue_transactional_email(self, request: Any, template: MessageTemplateRow, *, route: str) -> Response: + if template.transactional_hog_flow_id: + self._require_workflow_editor(template.transactional_hog_flow_id) + data = request.validated_data + idempotency_key = request.headers.get("Idempotency-Key") + outcome: dict[str, Any] = { + "outcome": "error", + "has_idempotency_key": idempotency_key is not None, + "route": route, + } + try: + queued = send_transactional_email( + self.team, + template.id, + to=data["to"], + distinct_id=(data.get("identifiers") or {}).get("distinct_id") or None, + message_data=data["message_data"], + idempotency_key=idempotency_key, + ) + outcome["outcome"] = "replayed" if queued.replayed else "queued" + except TransactionalEmailError as e: + outcome.update({"outcome": "rejected", "error_code": e.code}) + raise TransactionalEmailRejected(e) + except DeliveryUnavailable: + outcome["outcome"] = "unavailable" + raise TransactionalEmailUnavailable() from None + finally: + self._report_transactional_email_send(request, template, outcome) + return Response( + SendTransactionalEmailResponseSerializer(queued).data, + status=status.HTTP_202_ACCEPTED, + ) + + def _require_workflow_editor(self, hog_flow_id: Any) -> None: + # The template's own access rules don't cover the workflow it sends through, which can carry its own. + # A project secret API key has no user to check, and its scope already limits it to sending. + if is_authenticated_via_project_secret_api_key(self.request): + return + try: + get_workflow( + team_id=self.team_id, + workflow_id=str(hog_flow_id), + user_access_control=self.user_access_control, + required_level="editor", + ) + except WorkflowAccessDenied: + raise exceptions.PermissionDenied( + "You need editor access to this template's transactional email workflow." + ) from None + except WorkflowNotFound: + return + + def _report_transactional_email_send( + self, request: Any, template: MessageTemplateRow, outcome: dict[str, Any] + ) -> None: + # report_user_action drops the synthetic user behind a project secret API key, so capture directly. + try: + posthoganalytics.capture( + distinct_id=request.user.distinct_id, + event="transactional email api send", + properties={ + "template_id": str(template.id), + "team_id": self.team_id, + "auth_method": "project_secret_api_key" + if is_authenticated_via_project_secret_api_key(request) + else "user", + **outcome, + }, + groups=groups(self.organization, self.team), + ) + except Exception as e: + logger.warning("Failed to capture transactional email usage event", error=str(e)) + + @extend_schema_view( retrieve=extend_schema(parameters=[TEMPLATE_ID_PARAMETER]), update=extend_schema(parameters=[TEMPLATE_ID_PARAMETER]), @@ -486,6 +618,7 @@ class TransactionalEmailTeamSustainedThrottle(ProjectSecretApiKeyTeamRateThrottl destroy=extend_schema(parameters=[TEMPLATE_ID_PARAMETER]), ) class MessageTemplatesViewSet( + TransactionalEmailSendMixin, TeamAndOrgViewSetMixin, ForbidDestroyModel, viewsets.ModelViewSet, @@ -512,12 +645,7 @@ class MessageTemplatesViewSet( def get_throttles(self): if self.action == "send": - return [ - TransactionalEmailBurstThrottle(), - TransactionalEmailSustainedThrottle(), - TransactionalEmailTeamBurstThrottle(), - TransactionalEmailTeamSustainedThrottle(), - ] + return transactional_email_throttles() return super().get_throttles() def dangerously_get_object(self) -> MessageTemplateRow: @@ -628,25 +756,6 @@ def _sync_transactional_workflow(self, template_id: Any) -> None: except TransactionalEmailError as e: raise TransactionalEmailRejected(e) from None - def _require_workflow_editor(self, hog_flow_id: Any) -> None: - # The template's own access rules don't cover the workflow it sends through, which can carry its own. - # A project secret API key has no user to check, and its scope already limits it to sending. - if is_authenticated_via_project_secret_api_key(self.request): - return - try: - get_workflow( - team_id=self.team_id, - workflow_id=str(hog_flow_id), - user_access_control=self.user_access_control, - required_level="editor", - ) - except WorkflowAccessDenied: - raise exceptions.PermissionDenied( - "You need editor access to this template's transactional email workflow." - ) from None - except WorkflowNotFound: - return - def _actor_id(self) -> int | None: return self.request.user.id if self.request.user.is_authenticated else None @@ -725,22 +834,7 @@ def apply_operations(content: dict[str, Any]) -> dict[str, Any]: @validated_request( SendTransactionalEmailSerializer, - responses={ - 202: OpenApiResponse(response=SendTransactionalEmailResponseSerializer, description="Email queued"), - 400: OpenApiResponse( - description="The template isn't transactional or its email is paused, the recipient can't be used, " - "or the Idempotency-Key is invalid" - ), - 404: OpenApiResponse(description="Template not found"), - 409: OpenApiResponse(description="A request with the same Idempotency-Key is still in progress"), - 422: OpenApiResponse( - description="The email can't go out: the recipient is suppressed or can't receive email, sending is " - "suspended for the project or paused for this transactional email, or the Idempotency-Key was already " - "used for a different request" - ), - 429: OpenApiResponse(description="Rate limited, or the project is over its email quota"), - 503: OpenApiResponse(description="The email pipeline did not confirm the send"), - }, + responses=SEND_RESPONSES, summary="Send a transactional email", description=( "Send one transactional email from this template to one recipient. The template must be made " @@ -753,45 +847,7 @@ def apply_operations(content: dict[str, Any]) -> dict[str, Any]: @action(detail=True, methods=["POST"], pagination_class=None) def send(self, request: Any, *args: Any, **kwargs: Any) -> Response: self._require_transactional_email_flag() - - template = self.get_object() - if template.transactional_hog_flow_id: - self._require_workflow_editor(template.transactional_hog_flow_id) - data = request.validated_data - idempotency_key = request.headers.get("Idempotency-Key") - outcome: dict[str, Any] = {"outcome": "error", "has_idempotency_key": idempotency_key is not None} - try: - queued = send_transactional_email( - self.team, - template.id, - to=data["to"], - distinct_id=(data.get("identifiers") or {}).get("distinct_id") or None, - message_data=data["message_data"], - idempotency_key=idempotency_key, - ) - outcome["outcome"] = "replayed" if queued.replayed else "queued" - except TransactionalEmailError as e: - outcome.update({"outcome": "rejected", "error_code": e.code}) - raise TransactionalEmailRejected(e) - except DeliveryUnavailable: - outcome["outcome"] = "unavailable" - raise TransactionalEmailUnavailable() from None - finally: - self._report_transactional_email_send(request, template, outcome) - return Response( - SendTransactionalEmailResponseSerializer(queued).data, - status=status.HTTP_202_ACCEPTED, - ) - - 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() + return self._queue_transactional_email(request, self.get_object(), route="template") @validated_request( MakeTransactionalSerializer, @@ -843,23 +899,35 @@ def make_transactional(self, request: Any, *args: Any, **kwargs: Any) -> Respons logger.warning("Failed to capture message template usage event", error=str(e)) return Response(self._serialize(updated)) - def _report_transactional_email_send( - self, request: Any, template: MessageTemplateRow, outcome: dict[str, Any] - ) -> None: - # report_user_action drops the synthetic user behind a project secret API key, so capture directly. + +class MessagingSendViewSet(TransactionalEmailSendMixin, TeamAndOrgViewSetMixin, viewsets.ViewSet): + scope_object = "hog_flow" + scope_object_write_actions = ["email"] + permission_classes = [IsAuthenticated] + authentication_classes = [ProjectSecretAPIKeyAuthentication] + psak_allowed_actions = ["email"] + + def get_throttles(self): + return transactional_email_throttles() + + @validated_request( + SendEmailSerializer, + responses=SEND_RESPONSES, + summary="Send a transactional email", + description=( + "Send one transactional email to one recipient from the template named in `template.id`. The template " + "must be made transactional first. `message_data` fills the template's `{{ variables.* }}` placeholders. " + "Transactional emails skip opt-outs and frequency caps. Send an `Idempotency-Key` header to retry " + "safely without sending the email twice." + ), + parameters=[IDEMPOTENCY_KEY_PARAMETER], + ) + @action(detail=False, methods=["POST"]) + def email(self, request: Any, *args: Any, **kwargs: Any) -> Response: + self._require_transactional_email_flag() try: - posthoganalytics.capture( - distinct_id=request.user.distinct_id, - event="transactional email api send", - properties={ - "template_id": str(template.id), - "team_id": self.team_id, - "auth_method": "project_secret_api_key" - if is_authenticated_via_project_secret_api_key(request) - else "user", - **outcome, - }, - groups=groups(self.organization, self.team), - ) - except Exception as e: - logger.warning("Failed to capture transactional email usage event", error=str(e)) + template = get_template(self.team_id, request.validated_data["template"]["id"]) + except MessageTemplateMissing: + raise NotFound() + self.check_object_permissions(request, template) + return self._queue_transactional_email(request, template, route="send_email") diff --git a/products/messaging/backend/routes.py b/products/messaging/backend/routes.py index 2e64fcaf697d..05ddff7799e0 100644 --- a/products/messaging/backend/routes.py +++ b/products/messaging/backend/routes.py @@ -3,7 +3,10 @@ from products.messaging.backend.presentation.views.message_categories import MessageCategoryViewSet from products.messaging.backend.presentation.views.message_preferences import MessagePreferencesViewSet from products.messaging.backend.presentation.views.message_suppression import MessageSuppressionViewSet -from products.messaging.backend.presentation.views.message_templates import MessageTemplatesViewSet +from products.messaging.backend.presentation.views.message_templates import ( + MessageTemplatesViewSet, + MessagingSendViewSet, +) def register_routes(routers: RouterRegistry) -> None: @@ -20,3 +23,4 @@ def register_routes(routers: RouterRegistry) -> None: routers.projects.register( r"messaging_suppressions", MessageSuppressionViewSet, "project_messaging_suppressions", ["team_id"] ) + routers.projects.register(r"messaging/send", MessagingSendViewSet, "project_messaging_send", ["team_id"]) diff --git a/products/messaging/backend/tests/api/test_transactional_email.py b/products/messaging/backend/tests/api/test_transactional_email.py index 2b6766bb4c24..786ad7893242 100644 --- a/products/messaging/backend/tests/api/test_transactional_email.py +++ b/products/messaging/backend/tests/api/test_transactional_email.py @@ -11,7 +11,7 @@ from rest_framework.test import APIClient from posthog.constants import AvailableFeature -from posthog.models import OrganizationMembership, ProjectSecretAPIKey, User +from posthog.models import OrganizationMembership, ProjectSecretAPIKey, Team, User from posthog.models.integration import Integration from posthog.models.personal_api_key import hash_key_value from posthog.models.utils import generate_random_token_secret @@ -41,6 +41,7 @@ def setUp(self): self.team.id, self.template.id, user_id=self.user.id, from_integration_id=None ) self.url = f"/api/projects/{self.team.id}/messaging_templates/{self.template.id}/send/" + self.send_email_url = f"/api/projects/{self.team.id}/messaging/send/email/" self.body = { "to": "user@example.com", "identifiers": {"distinct_id": "user-1"}, @@ -64,6 +65,12 @@ def _workflow(self, hog_flow_id) -> dict: def _cdp_accepts(self, mock_post: MagicMock, invocation_id: str = "invocation-1") -> None: mock_post.return_value = MagicMock(status_code=202, json=lambda: {"invocation_id": invocation_id}) + def _send(self, client: APIClient, route: str, body: dict, **kwargs: Any) -> Any: + if route == "send_email": + body = {**body, "template": {"id": str(self.template.id)}} + return client.post(self.send_email_url, body, format="json", **kwargs) + return client.post(self.url, body, format="json", **kwargs) + def _psak_client(self, scopes: list[str]) -> APIClient: raw_token = generate_random_token_secret() ProjectSecretAPIKey.objects.create( @@ -79,13 +86,15 @@ def _psak_client(self, scopes: list[str]) -> APIClient: @parameterized.expand( [ - ("with_distinct_id", {"distinct_id": "user-1"}, "user-1"), + ("with_distinct_id", "template", {"distinct_id": "user-1"}, "user-1"), # Without a distinct_id the recipient's address is the person the send is tracked against. - ("email_only", None, "user@example.com"), + ("email_only", "template", None, "user@example.com"), + ("with_distinct_id_on_send_email", "send_email", {"distinct_id": "user-1"}, "user-1"), + ("email_only_on_send_email", "send_email", None, "user@example.com"), ] ) def test_runs_the_templates_workflow_for_the_recipient( - self, mock_post, _flag, _capture, _name, identifiers, expected_distinct_id + self, mock_post, _flag, capture, _name, route, identifiers, expected_distinct_id ): self._cdp_accepts(mock_post) body = ( @@ -94,10 +103,11 @@ def test_runs_the_templates_workflow_for_the_recipient( else {k: v for k, v in self.body.items() if k != "identifiers"} ) - response = self._psak_client(["hog_flow:write"]).post(self.url, body, format="json") + response = self._send(self._psak_client(["hog_flow:write"]), route, body) assert response.status_code == status.HTTP_202_ACCEPTED, response.json() assert response.json() == {"message_id": "invocation-1", "replayed": False} + assert capture.call_args.kwargs["properties"]["route"] == route assert mock_post.call_args.args[0].endswith( f"/api/projects/{self.team.id}/hog_flows/{self.hog_flow_id}/transactional_email" ) @@ -124,6 +134,8 @@ def test_passes_the_idempotency_key_to_the_pipeline_and_reports_a_replay(self, m [ ("send_with_write_scope", "post", "send/", ["hog_flow:write"], status.HTTP_202_ACCEPTED), ("send_with_read_scope", "post", "send/", ["hog_flow:read"], status.HTTP_403_FORBIDDEN), + ("send_email_with_write_scope", "post", "send_email", ["hog_flow:write"], status.HTTP_202_ACCEPTED), + ("send_email_with_read_scope", "post", "send_email", ["hog_flow:read"], status.HTTP_403_FORBIDDEN), ("retrieve_is_not_a_psak_action", "get", "", ["hog_flow:write"], status.HTTP_403_FORBIDDEN), ( "make_transactional_is_not_a_psak_action", @@ -138,11 +150,12 @@ def test_project_secret_api_key_can_only_send( self, mock_post, _flag, _capture, _name, method, suffix, scopes, expected_status ): self._cdp_accepts(mock_post) - url = f"/api/projects/{self.team.id}/messaging_templates/{self.template.id}/{suffix}" + if suffix == "send_email": + url, body = self.send_email_url, {**self.body, "template": {"id": str(self.template.id)}} + else: + url, body = f"/api/projects/{self.team.id}/messaging_templates/{self.template.id}/{suffix}", self.body - response = getattr(self._psak_client(scopes), method)( - url, self.body if method == "post" else None, format="json" - ) + response = getattr(self._psak_client(scopes), method)(url, body if method == "post" else None, format="json") assert response.status_code == expected_status, response.json() @@ -275,17 +288,63 @@ def test_reports_a_send_the_pipeline_refuses( assert response.status_code == expected_status assert response.json()["code"] == expected_code - @parameterized.expand([("send", "send/"), ("make_transactional", "make_transactional/")]) - def test_is_hidden_while_the_flag_is_off(self, mock_post, flag, _capture, _name, suffix): + @parameterized.expand( + [ + ("send", "messaging_templates/{template_id}/send/"), + ("make_transactional", "messaging_templates/{template_id}/make_transactional/"), + ("send_email", "messaging/send/email/"), + ] + ) + def test_is_hidden_while_the_flag_is_off(self, mock_post, flag, _capture, _name, path): flag.return_value = False response = self.client.post( - f"/api/projects/{self.team.id}/messaging_templates/{self.template.id}/{suffix}", self.body, format="json" + f"/api/projects/{self.team.id}/{path.format(template_id=self.template.id)}", + {**self.body, "template": {"id": str(self.template.id)}}, + format="json", ) assert response.status_code == status.HTTP_404_NOT_FOUND mock_post.assert_not_called() + @parameterized.expand( + [ + ("unknown_template", lambda test: {"id": str(uuid4())}, status.HTTP_404_NOT_FOUND), + ( + "another_projects_template", + lambda test: {"id": str(test._other_team_template().id)}, + status.HTTP_404_NOT_FOUND, + ), + ("no_template", lambda test: None, status.HTTP_400_BAD_REQUEST), + ] + ) + def test_send_email_sends_only_a_template_of_this_project( + self, mock_post, _flag, _capture, _name, template, expected_status + ): + self._cdp_accepts(mock_post) + ref = template(self) + body = {**self.body, "template": ref} if ref else self.body + + response = self._psak_client(["hog_flow:write"]).post(self.send_email_url, body, format="json") + + assert response.status_code == expected_status, response.json() + mock_post.assert_not_called() + + def _other_team_template(self) -> MessageTemplate: + other_team = Team.objects.create(organization=self.organization, name="Other project") + template = MessageTemplate.objects.create( + team=other_team, + name="Other reset", + content={"email": {"subject": "Reset", "html": "

Reset

"}}, + type="email", + ) + sender = Integration.objects.create( + team=other_team, kind="email", integration_id="other@example.com", config={"verified": True} + ) + set_default_email_sender_if_unset(other_team.id, sender.id) + make_transactional(other_team.id, template.id, user_id=self.user.id, from_integration_id=None) + return template + def test_making_a_template_transactional_creates_its_workflow_once(self, mock_post, _flag, _capture): template = self._template() url = f"/api/projects/{self.team.id}/messaging_templates/{template.id}/make_transactional/" diff --git a/products/messaging/frontend/generated/api.schemas.ts b/products/messaging/frontend/generated/api.schemas.ts index 1926edc940eb..79210791e13f 100644 --- a/products/messaging/frontend/generated/api.schemas.ts +++ b/products/messaging/frontend/generated/api.schemas.ts @@ -7,6 +7,43 @@ * PostHog API - generated * OpenAPI spec version: 1.0.0 */ +/** + * Data for the template's Liquid placeholders, available as `variables`. For example `{"reset_link": "https://..."}` fills `{{ variables.reset_link }}`. + */ +export type SendEmailApiMessageData = { [key: string]: unknown } + +export interface TransactionalEmailIdentifiersApi { + /** + * Distinct ID of the recipient's person. The email's engagement events (sent, delivered, opened, clicked, bounced) and the send itself are recorded against that person. Defaults to the recipient's email address. The template can't read the person's properties, so pass everything it needs in `message_data`. + * @maxLength 400 + * @nullable + */ + distinct_id?: string | null +} + +export interface SendEmailTemplateApi { + /** ID of the email template to send. The template must be made transactional first. */ + id: string +} + +export interface SendEmailApi { + /** Email address of the recipient. */ + to: string + /** Who the email is for, used to attribute engagement events. */ + identifiers?: TransactionalEmailIdentifiersApi + /** Data for the template's Liquid placeholders, available as `variables`. For example `{"reset_link": "https://..."}` fills `{{ variables.reset_link }}`. */ + message_data?: SendEmailApiMessageData + /** The transactional email template to send. */ + template: SendEmailTemplateApi +} + +export interface SendTransactionalEmailResponseApi { + /** ID of the queued send. Delivery and engagement are reported against the template asynchronously. */ + message_id: string + /** True when an earlier request with the same `Idempotency-Key` already queued this email. No new email was sent, and `message_id` is the earlier send's ID. */ + replayed: boolean +} + /** * * `marketing` - Marketing * * `transactional` - Transactional @@ -591,15 +628,6 @@ export interface MakeTransactionalApi { */ export type SendTransactionalEmailApiMessageData = { [key: string]: unknown } -export interface TransactionalEmailIdentifiersApi { - /** - * Distinct ID of the recipient's person. The email's engagement events (sent, delivered, opened, clicked, bounced) and the send itself are recorded against that person. Defaults to the recipient's email address. The template can't read the person's properties, so pass everything it needs in `message_data`. - * @maxLength 400 - * @nullable - */ - distinct_id?: string | null -} - export interface SendTransactionalEmailApi { /** Email address of the recipient. */ to: string @@ -609,13 +637,6 @@ export interface SendTransactionalEmailApi { message_data?: SendTransactionalEmailApiMessageData } -export interface SendTransactionalEmailResponseApi { - /** ID of the queued send. Delivery and engagement are reported against the template asynchronously. */ - message_id: string - /** True when an earlier request with the same `Idempotency-Key` already queued this email. No new email was sent, and `message_id` is the earlier send's ID. */ - replayed: boolean -} - export type MessagingCategoriesListParams = { /** * Number of results to return per page. diff --git a/products/messaging/frontend/generated/api.ts b/products/messaging/frontend/generated/api.ts index 3ebd3092e7f4..e867bbf69f03 100644 --- a/products/messaging/frontend/generated/api.ts +++ b/products/messaging/frontend/generated/api.ts @@ -33,6 +33,7 @@ import type { PatchedMessageTemplateApi, PreferencesLinkApi, RemoveOptOutRequestApi, + SendEmailApi, SendTransactionalEmailApi, SendTransactionalEmailResponseApi, WebhookUrlApi, @@ -55,6 +56,27 @@ type NonReadonly = [T] extends [UnionToIntersection] } : DistributeReadOnlyOverUnions +export const getMessagingSendEmailCreateUrl = (projectId: string) => { + return `/api/projects/${projectId}/messaging/send/email/` +} + +/** + * Send one transactional email to one recipient from the template named in `template.id`. The template must be made transactional first. `message_data` fills the template's `{{ variables.* }}` placeholders. Transactional emails skip opt-outs and frequency caps. Send an `Idempotency-Key` header to retry safely without sending the email twice. + * @summary Send a transactional email + */ +export const messagingSendEmailCreate = async ( + projectId: string, + sendEmailApi: SendEmailApi, + options?: RequestInit +): Promise => { + return apiMutator(getMessagingSendEmailCreateUrl(projectId), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(sendEmailApi), + }) +} + export const getMessagingCategoriesListUrl = (projectId: string, params?: MessagingCategoriesListParams) => { const normalizedParams = new URLSearchParams() diff --git a/products/messaging/frontend/generated/api.zod.ts b/products/messaging/frontend/generated/api.zod.ts index 8bebf94f5fae..c0a43b752048 100644 --- a/products/messaging/frontend/generated/api.zod.ts +++ b/products/messaging/frontend/generated/api.zod.ts @@ -9,6 +9,39 @@ */ import * as zod from 'zod' +/** + * Send one transactional email to one recipient from the template named in `template.id`. The template must be made transactional first. `message_data` fills the template's `{{ variables.* }}` placeholders. Transactional emails skip opt-outs and frequency caps. Send an `Idempotency-Key` header to retry safely without sending the email twice. + * @summary Send a transactional email + */ +export const messagingSendEmailCreateBodyIdentifiersOneDistinctIdMax = 400 + +export const MessagingSendEmailCreateBody = /* @__PURE__ */ zod.object({ + to: zod.email().describe('Email address of the recipient.'), + identifiers: zod + .object({ + distinct_id: zod + .string() + .max(messagingSendEmailCreateBodyIdentifiersOneDistinctIdMax) + .nullish() + .describe( + "Distinct ID of the recipient's person. The email's engagement events (sent, delivered, opened, clicked, bounced) and the send itself are recorded against that person. Defaults to the recipient's email address. The template can't read the person's properties, so pass everything it needs in `message_data`." + ), + }) + .optional() + .describe('Who the email is for, used to attribute engagement events.'), + message_data: zod + .record(zod.string(), zod.unknown()) + .optional() + .describe( + 'Data for the template\'s Liquid placeholders, available as `variables`. For example `{\"reset_link\": \"https:\/\/...\"}` fills `{{ variables.reset_link }}`.' + ), + template: zod + .object({ + id: zod.uuid().describe('ID of the email template to send. The template must be made transactional first.'), + }) + .describe('The transactional email template to send.'), +}) + export const messagingCategoriesCreateBodyKeyMax = 64 export const messagingCategoriesCreateBodyNameMax = 128 diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index fccaf380c037..4e5e8aa390e6 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -108760,7 +108760,7 @@ export namespace Schemas { /** * Data for the template's Liquid placeholders, available as `variables`. For example `{"reset_link": "https://..."}` fills `{{ variables.reset_link }}`. */ - export type SendTransactionalEmailMessageData = {[key: string]: unknown}; + export type SendEmailMessageData = {[key: string]: unknown}; export interface TransactionalEmailIdentifiers { /** @@ -108771,6 +108771,27 @@ export namespace Schemas { distinct_id?: string | null; } + export interface SendEmailTemplate { + /** ID of the email template to send. The template must be made transactional first. */ + id: string; + } + + export interface SendEmail { + /** Email address of the recipient. */ + to: string; + /** Who the email is for, used to attribute engagement events. */ + identifiers?: TransactionalEmailIdentifiers; + /** Data for the template's Liquid placeholders, available as `variables`. For example `{"reset_link": "https://..."}` fills `{{ variables.reset_link }}`. */ + message_data?: SendEmailMessageData; + /** The transactional email template to send. */ + template: SendEmailTemplate; + } + + /** + * Data for the template's Liquid placeholders, available as `variables`. For example `{"reset_link": "https://..."}` fills `{{ variables.reset_link }}`. + */ + export type SendTransactionalEmailMessageData = {[key: string]: unknown}; + export interface SendTransactionalEmail { /** Email address of the recipient. */ to: string;