diff --git a/frontend/src/lib/oauthScopes.generated.ts b/frontend/src/lib/oauthScopes.generated.ts index 410147d3a305..91973e3b6711 100644 --- a/frontend/src/lib/oauthScopes.generated.ts +++ b/frontend/src/lib/oauthScopes.generated.ts @@ -214,6 +214,8 @@ export const OAUTH_SCOPES_SUPPORTED = [ 'toolbar:write', 'tracing:read', 'tracing:write', + 'transactional_email:read', + 'transactional_email:write', 'field_note:read', 'field_note:write', 'uploaded_media:read', @@ -268,4 +270,5 @@ export const OAUTH_SCOPES_HIDDEN = [ export const LEGACY_SCOPE_FALLBACKS: ReadonlyMap = new Map([ ['inbox', 'task'], ['messaging_preference', 'hog_flow'], + ['transactional_email', 'hog_flow'], ]) diff --git a/frontend/src/lib/scopes.tsx b/frontend/src/lib/scopes.tsx index bc98134977b3..2616a41a5d43 100644 --- a/frontend/src/lib/scopes.tsx +++ b/frontend/src/lib/scopes.tsx @@ -312,6 +312,12 @@ export const API_SCOPES: APIScope[] = [ { key: 'ticket', objectName: 'Ticket', objectPlural: 'tickets' }, { key: 'toolbar', objectName: 'Toolbar', objectPlural: 'toolbar' }, { key: 'tracing', objectName: 'Tracing', objectPlural: 'tracing' }, + { + key: 'transactional_email', + objectName: 'Transactional email', + objectPlural: 'transactional emails', + info: "Write sends transactional email from a template. Read gets a sent email's status and works only with a project secret API key. This scope can't change templates or workflows.", + }, { key: 'field_note', objectName: 'Field note', objectPlural: 'field notes' }, { key: 'uploaded_media', objectName: 'Uploaded media', objectPlural: 'uploaded media' }, { key: 'usage_metric', objectName: 'Usage metric', objectPlural: 'usage metrics' }, @@ -391,6 +397,8 @@ export const PROJECT_SECRET_API_KEY_ALLOWED_API_SCOPE_ACTION = [ 'hog_flow:read', 'hog_flow:write', 'messaging_preference:write', + 'transactional_email:read', + 'transactional_email:write', ] as const export type ProjectSecretAPIKeyAllowedScope = (typeof PROJECT_SECRET_API_KEY_ALLOWED_API_SCOPE_ACTION)[number] @@ -506,6 +514,7 @@ export const PROJECT_SECRET_API_KEY_SCOPE_PRESETS: ProjectSecretAPIKeyScopePrese { value: 'local_evaluation', label: 'Local feature flag evaluation', scopes: ['feature_flag:read'] }, { value: 'llm_gateway', label: 'AI gateway access', scopes: ['llm_gateway:read'] }, { value: 'recipient_preferences', label: 'Set recipient preferences', scopes: ['messaging_preference:write'] }, + { value: 'transactional_email', label: 'Send transactional email', scopes: ['transactional_email:write'] }, ] // The product areas that the scope pickers use to group objects, in display order. Each scope object @@ -612,6 +621,10 @@ export const API_SCOPE_GROUPS: APIScopeGroup[] = [ 'plugin', ], }, + { + label: 'Messaging', + objects: ['transactional_email'], + }, { label: 'AI & context management tools', objects: [ diff --git a/posthog/scopes.py b/posthog/scopes.py index 2bb759b4b6f9..a28d05f2766b 100644 --- a/posthog/scopes.py +++ b/posthog/scopes.py @@ -138,6 +138,7 @@ "today", "toolbar", "tracing", + "transactional_email", "field_note", "uploaded_media", "usage_metric", @@ -285,6 +286,8 @@ ("hog_flow", "read"), ("hog_flow", "write"), ("messaging_preference", "write"), + ("transactional_email", "read"), + ("transactional_email", "write"), ] # Server-side scope assignment string-set constants (see RFC: server-side scope @@ -496,6 +499,8 @@ def scopes_not_covered(held_scopes: Iterable[str], required_scopes: Iterable[str "inbox": "task", # Permanent: `hog_flow:write` stays a supported way to set recipient preferences. Keep it on low use. "messaging_preference": "hog_flow", + # Permanent: `hog_flow` stays a supported way to send transactional email and read a send's status. + "transactional_email": "hog_flow", } diff --git a/products/access_control/frontend/generated/api.schemas.ts b/products/access_control/frontend/generated/api.schemas.ts index 155fe10b73d9..d124402ab30c 100644 --- a/products/access_control/frontend/generated/api.schemas.ts +++ b/products/access_control/frontend/generated/api.schemas.ts @@ -149,6 +149,7 @@ export const ResolvedAccessSourceSubjectEnumApi = { * * `today` - today * * `toolbar` - toolbar * * `tracing` - tracing + * * `transactional_email` - transactional_email * * `field_note` - field_note * * `uploaded_media` - uploaded_media * * `usage_metric` - usage_metric @@ -272,6 +273,7 @@ export const ScopeObjectEnumApi = { Today: 'today', Toolbar: 'toolbar', Tracing: 'tracing', + TransactionalEmail: 'transactional_email', FieldNote: 'field_note', UploadedMedia: 'uploaded_media', UsageMetric: 'usage_metric', @@ -418,6 +420,7 @@ export interface ProjectAccessSourceApi { * * `today` - today * * `toolbar` - toolbar * * `tracing` - tracing + * * `transactional_email` - transactional_email * * `field_note` - field_note * * `uploaded_media` - uploaded_media * * `usage_metric` - usage_metric @@ -584,6 +587,7 @@ export interface AccessControlObjectRuleApi { * * `today` - today * * `toolbar` - toolbar * * `tracing` - tracing + * * `transactional_email` - transactional_email * * `field_note` - field_note * * `uploaded_media` - uploaded_media * * `usage_metric` - usage_metric @@ -975,6 +979,7 @@ export interface AccessControlObjectRuleResourceApi { * * `today` - today * * `toolbar` - toolbar * * `tracing` - tracing + * * `transactional_email` - transactional_email * * `field_note` - field_note * * `uploaded_media` - uploaded_media * * `usage_metric` - usage_metric @@ -1263,6 +1268,7 @@ export interface ResolvedAccessApi { * * `today` - today * * `toolbar` - toolbar * * `tracing` - tracing + * * `transactional_email` - transactional_email * * `field_note` - field_note * * `uploaded_media` - uploaded_media * * `usage_metric` - usage_metric diff --git a/products/messaging/backend/presentation/views/message_templates.py b/products/messaging/backend/presentation/views/message_templates.py index 448af4a0c235..4c4281df4467 100644 --- a/products/messaging/backend/presentation/views/message_templates.py +++ b/products/messaging/backend/presentation/views/message_templates.py @@ -34,7 +34,7 @@ from posthog.integrations import IntegrationManager, IntegrationNotFound from posthog.models import User from posthog.models.tag import tagify -from posthog.permissions import is_authenticated_via_project_secret_api_key +from posthog.permissions import is_authenticated_via_project_secret_api_key, legacy_scope_object_used from posthog.rate_limit import PersonalOrProjectSecretApiKeyRateThrottle, ProjectSecretApiKeyTeamRateThrottle from products.cdp.backend.presentation.hog_functions import build_html_wrap_design @@ -513,6 +513,15 @@ class TransactionalEmailTeamSustainedThrottle(ProjectSecretApiKeyTeamRateThrottl rate = "20000/hour" +# LEGACY_SCOPE_FALLBACKS lets a `hog_flow` key satisfy these scopes too. +TRANSACTIONAL_EMAIL_SEND_SCOPE = "transactional_email:write" +TRANSACTIONAL_EMAIL_STATUS_SCOPE = "transactional_email:read" + + +def transactional_email_authorized_by(request: Any, required_scope: str) -> str: + return legacy_scope_object_used(request, required_scope) or "transactional_email" + + def require_transactional_email_api(team: Any) -> None: if not posthoganalytics.feature_enabled( TRANSACTIONAL_EMAIL_API_FLAG, @@ -674,6 +683,7 @@ def _report_transactional_email_send( "auth_method": "project_secret_api_key" if is_authenticated_via_project_secret_api_key(request) else "user", + "authorized_by": transactional_email_authorized_by(request, TRANSACTIONAL_EMAIL_SEND_SCOPE), **outcome, }, groups=groups(self.organization, self.team), @@ -696,8 +706,8 @@ class MessageTemplatesViewSet( ): scope_object = "hog_flow" permission_classes = [IsAuthenticated] - # `design` and `send` are custom write actions; list them so programmatic callers (MCP/personal API key) - # get hog_flow:write checked instead of being rejected as an action with no declared scope. + # `design` and `make_transactional` are custom write actions; list them so programmatic callers (MCP/personal + # API key) get hog_flow:write checked instead of being rejected as an action with no declared scope. scope_object_write_actions = [ "create", "update", @@ -705,7 +715,6 @@ class MessageTemplatesViewSet( "patch", "destroy", "design", - "send", "make_transactional", ] authentication_classes = [ProjectSecretAPIKeyAuthentication] @@ -912,11 +921,12 @@ def apply_operations(content: dict[str, Any]) -> dict[str, Any]: "Send one transactional email from this template to one recipient. 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." + "safely without sending the email twice. Needs the `transactional_email:write` scope. A key with " + "`hog_flow:write` also works, but that scope reaches the rest of Workflows too." ), parameters=[TEMPLATE_ID_PARAMETER, IDEMPOTENCY_KEY_PARAMETER], ) - @action(detail=True, methods=["POST"], pagination_class=None) + @action(detail=True, methods=["POST"], pagination_class=None, required_scopes=[TRANSACTIONAL_EMAIL_SEND_SCOPE]) def send(self, request: Any, *args: Any, **kwargs: Any) -> Response: self._end_request_validation() with transactional_email_stage("feature_flag"): @@ -978,7 +988,6 @@ def make_transactional(self, request: Any, *args: Any, **kwargs: Any) -> Respons 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"] @@ -995,11 +1004,12 @@ def get_throttles(self): "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." + "safely without sending the email twice. Needs the `transactional_email:write` scope. A key with " + "`hog_flow:write` also works, but that scope reaches the rest of Workflows too." ), parameters=[IDEMPOTENCY_KEY_PARAMETER], ) - @action(detail=False, methods=["POST"]) + @action(detail=False, methods=["POST"], required_scopes=[TRANSACTIONAL_EMAIL_SEND_SCOPE]) def email(self, request: Any, *args: Any, **kwargs: Any) -> Response: self._end_request_validation() with transactional_email_stage("feature_flag"): diff --git a/products/messaging/backend/presentation/views/transactional_email_status.py b/products/messaging/backend/presentation/views/transactional_email_status.py index e31444b14792..683b144ae879 100644 --- a/products/messaging/backend/presentation/views/transactional_email_status.py +++ b/products/messaging/backend/presentation/views/transactional_email_status.py @@ -19,7 +19,11 @@ from posthog.rate_limit import PersonalOrProjectSecretApiKeyRateThrottle, ProjectSecretApiKeyTeamRateThrottle from products.messaging.backend.facade.transactional_email import EmailDeliveryStatus, EmailStatus, get_email_status -from products.messaging.backend.presentation.views.message_templates import require_transactional_email_api +from products.messaging.backend.presentation.views.message_templates import ( + TRANSACTIONAL_EMAIL_STATUS_SCOPE, + require_transactional_email_api, + transactional_email_authorized_by, +) logger = structlog.get_logger(__name__) @@ -74,7 +78,7 @@ class TransactionalEmailReadTeamSustainedThrottle(ProjectSecretApiKeyTeamRateThr class MessagingEmailsViewSet(TeamAndOrgViewSetMixin, viewsets.ViewSet): scope_object = "hog_flow" - scope_object_read_actions = ["retrieve"] + required_scopes = [TRANSACTIONAL_EMAIL_STATUS_SCOPE] permission_classes = [IsAuthenticated] authentication_classes = [ProjectSecretAPIKeyAuthentication] psak_allowed_actions = ["retrieve"] @@ -104,7 +108,8 @@ def get_throttles(self) -> list[BaseThrottle]: 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." + "the send. Needs a project secret API key with the `transactional_email:read` scope. A key with " + "`hog_flow:read` also works, but that scope reaches the rest of Workflows too." ), ) def retrieve(self, request: Request, *args: Any, pk: str | None = None, **kwargs: Any) -> Response: @@ -140,6 +145,8 @@ def _report_status_read(self, request: Request, email_status: EmailStatus | None "team_id": self.team_id, "outcome": "not_found" if email_status is None else "found", "status": email_status.status if email_status else None, + "auth_method": "project_secret_api_key", + "authorized_by": transactional_email_authorized_by(request, TRANSACTIONAL_EMAIL_STATUS_SCOPE), }, groups=groups(self.organization, self.team), ) diff --git a/products/messaging/backend/tests/api/test_transactional_email.py b/products/messaging/backend/tests/api/test_transactional_email.py index 9aa3d4780166..69f8df326e7d 100644 --- a/products/messaging/backend/tests/api/test_transactional_email.py +++ b/products/messaging/backend/tests/api/test_transactional_email.py @@ -161,6 +161,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_with_preference_scope", "post", "send/", ["messaging_preference:write"], status.HTTP_403_FORBIDDEN), + ("send_with_send_scope", "post", "send/", ["transactional_email:write"], status.HTTP_202_ACCEPTED), + ("send_with_status_scope", "post", "send/", ["transactional_email: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), ( @@ -170,6 +172,20 @@ def test_passes_the_idempotency_key_to_the_pipeline_and_reports_a_replay(self, m ["messaging_preference:write"], status.HTTP_403_FORBIDDEN, ), + ( + "send_email_with_send_scope", + "post", + "send_email", + ["transactional_email:write"], + status.HTTP_202_ACCEPTED, + ), + ( + "send_email_with_status_scope", + "post", + "send_email", + ["transactional_email: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", @@ -193,6 +209,49 @@ def test_project_secret_api_key_can_only_send( assert response.status_code == expected_status, response.json() + @parameterized.expand( + [ + ("send_scope", "template", ["transactional_email:write"], "transactional_email"), + ("workflow_scope", "template", ["hog_flow:write"], "hog_flow"), + ( + "send_scope_on_send_email", + "send_email", + ["transactional_email:write"], + "transactional_email", + ), + ("workflow_scope_on_send_email", "send_email", ["hog_flow:write"], "hog_flow"), + ( + "both_scopes", + "template", + ["hog_flow:write", "transactional_email:write"], + "transactional_email", + ), + ] + ) + def test_records_the_scope_that_authorized_the_send( + self, mock_post, _flag, capture, _name, route, scopes, expected_authorized_by + ): + self._cdp_accepts(mock_post) + + response = self._send(self._psak_client(scopes), route, self.body) + + assert response.status_code == status.HTTP_202_ACCEPTED, response.json() + assert capture.call_args.kwargs["properties"]["authorized_by"] == expected_authorized_by + + def test_a_personal_api_key_with_the_send_scope_cannot_change_the_template(self, mock_post, _flag, _capture): + self._cdp_accepts(mock_post) + client = APIClient() + client.credentials( + HTTP_AUTHORIZATION=f"Bearer {self.create_personal_api_key_with_scopes(['transactional_email:write'])}" + ) + template_url = f"/api/projects/{self.team.id}/messaging_templates/{self.template.id}/" + + send = client.post(self.url, self.body, format="json") + edit = client.patch(template_url, {"name": "Renamed"}, format="json") + + assert send.status_code == status.HTTP_202_ACCEPTED, send.json() + assert edit.status_code == status.HTTP_403_FORBIDDEN, edit.json() + @parameterized.expand( [ ("not_transactional", "template_not_transactional"), diff --git a/products/messaging/backend/tests/api/test_transactional_email_status.py b/products/messaging/backend/tests/api/test_transactional_email_status.py index 194280995d63..c53839eee9d4 100644 --- a/products/messaging/backend/tests/api/test_transactional_email_status.py +++ b/products/messaging/backend/tests/api/test_transactional_email_status.py @@ -122,6 +122,8 @@ def test_reports_the_latest_status_of_the_send(self, _flag, _name, rows, expecte "team_id": self.team.id, "outcome": "found", "status": expected_status, + "auth_method": "project_secret_api_key", + "authorized_by": "hog_flow", } assert response.json() == { "id": self.message_id, @@ -155,17 +157,26 @@ def test_answers_404_for_an_email_it_cannot_report(self, _flag, _name, message_i @parameterized.expand( [ - ("read_scope", ["hog_flow:read"], status.HTTP_200_OK), - ("no_workflow_scope", ["feature_flag:read"], status.HTTP_403_FORBIDDEN), - ("preference_scope", ["messaging_preference:write"], status.HTTP_403_FORBIDDEN), + ("read_scope", ["hog_flow:read"], status.HTTP_200_OK, "hog_flow"), + ("status_scope", ["transactional_email:read"], status.HTTP_200_OK, "transactional_email"), + ("send_scope", ["transactional_email:write"], status.HTTP_200_OK, "transactional_email"), + ("no_workflow_scope", ["feature_flag:read"], status.HTTP_403_FORBIDDEN, None), + ("preference_scope", ["messaging_preference:write"], status.HTTP_403_FORBIDDEN, None), ] ) - def test_project_secret_api_key_needs_the_read_scope(self, _flag, _name, scopes, expected_status): + def test_project_secret_api_key_needs_the_read_scope( + self, _flag, _name, scopes, expected_status, expected_authorized_by + ): self._status_rows([("queued", 0, "")]) - response = self._psak_client(scopes).get(self._url(self.message_id)) + with patch( + "products.messaging.backend.presentation.views.transactional_email_status.posthoganalytics.capture" + ) as capture: + response = self._psak_client(scopes).get(self._url(self.message_id)) assert response.status_code == expected_status + if expected_authorized_by: + assert capture.call_args.kwargs["properties"]["authorized_by"] == expected_authorized_by def test_a_session_cannot_read_a_status(self, _flag): self._status_rows([("queued", 0, "")]) diff --git a/products/messaging/frontend/generated/api.ts b/products/messaging/frontend/generated/api.ts index a1cab217b7d3..fa4ab62a0374 100644 --- a/products/messaging/frontend/generated/api.ts +++ b/products/messaging/frontend/generated/api.ts @@ -69,7 +69,7 @@ export const getMessagingEmailsRetrieveUrl = (projectId: string, id: string) => } /** - * 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. + * 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 `transactional_email:read` scope. A key with `hog_flow:read` also works, but that scope reaches the rest of Workflows too. * @summary Get a transactional email's status */ export const messagingEmailsRetrieve = async ( @@ -109,7 +109,7 @@ export const getMessagingSendEmailCreateUrl = (projectId: string) => { } /** - * 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. + * 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. Needs the `transactional_email:write` scope. A key with `hog_flow:write` also works, but that scope reaches the rest of Workflows too. * @summary Send a transactional email */ export const messagingSendEmailCreate = async ( @@ -984,7 +984,7 @@ export const getMessagingTemplatesSendCreateUrl = (projectId: string, id: string } /** - * Send one transactional email from this template to one recipient. 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. + * Send one transactional email from this template to one recipient. 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. Needs the `transactional_email:write` scope. A key with `hog_flow:write` also works, but that scope reaches the rest of Workflows too. * @summary Send a transactional email */ export const messagingTemplatesSendCreate = async ( diff --git a/products/messaging/frontend/generated/api.zod.ts b/products/messaging/frontend/generated/api.zod.ts index 9afc4f67e4ab..253eb055b837 100644 --- a/products/messaging/frontend/generated/api.zod.ts +++ b/products/messaging/frontend/generated/api.zod.ts @@ -37,7 +37,7 @@ export const MessagingPreferencesCreateBody = /* @__PURE__ */ zod.object({ }) /** - * 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. + * 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. Needs the `transactional_email:write` scope. A key with `hog_flow:write` also works, but that scope reaches the rest of Workflows too. * @summary Send a transactional email */ export const messagingSendEmailCreateBodyDistinctIdMax = 400 @@ -1347,7 +1347,7 @@ export const MessagingTemplatesMakeTransactionalCreateBody = /* @__PURE__ */ zod }) /** - * Send one transactional email from this template to one recipient. 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. + * Send one transactional email from this template to one recipient. 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. Needs the `transactional_email:write` scope. A key with `hog_flow:write` also works, but that scope reaches the rest of Workflows too. * @summary Send a transactional email */ export const messagingTemplatesSendCreateBodyIdentifiersOneDistinctIdMax = 400 diff --git a/products/messaging/skills/sending-transactional-emails/SKILL.md b/products/messaging/skills/sending-transactional-emails/SKILL.md index ee883d015ec2..cf3bfb2751f8 100644 --- a/products/messaging/skills/sending-transactional-emails/SKILL.md +++ b/products/messaging/skills/sending-transactional-emails/SKILL.md @@ -47,7 +47,7 @@ Work through these in order, and skip the steps that are already done. 4. **No marketing category.** A transactional template can't be in a marketing message category. Check with `message-categories-list` if the template has a category. 5. **Make it transactional.** Call `workflows-make-email-template-transactional-prepare`, show the user the message it returns, and wait for them to type `confirm`. Then call `workflows-make-email-template-transactional-execute` with the `confirmation_hash` from prepare and their `confirm`. This can't be undone. If the template is also used for other emails, duplicate it first and make the copy transactional. For sign-in codes, reset links and other secrets, tell the user to turn on **Protect sensitive data** on the template's **API** tab. 6. **Engagement events.** Call `workflows-sending-rules-get`. If `capture_workflows_engagement_events` is off, which is the default, PostHog records no send, delivery, open, click or bounce events on the person. Offer to turn it on with `workflows-sending-rules-update`, and say that these events count toward the project's event usage. The email's status and its **Sends** tab work either way. -7. **API key.** The user creates a project secret API key in project settings, under **Project secret API keys**, with **Workflow** set to **Write**. You can't create it. Tell them to keep it in an environment variable on the server, and never to paste it into the chat or into client-side code. +7. **API key.** The user creates a project secret API key in project settings, under **Project secret API keys**, with **Transactional email** set to **Write**. The **Send transactional email** preset sets it. You can't create it. Tell them to keep it in an environment variable on the server, and never to paste it into the chat or into client-side code. 8. **Code.** Send from their backend. See below. 9. **Test.** Have them send one email to their own address. Check its status, and share the template's link so they can see the send on its **Sends** tab. diff --git a/products/workflows/frontend/TemplateLibrary/TransactionalEmailApiExample.tsx b/products/workflows/frontend/TemplateLibrary/TransactionalEmailApiExample.tsx index 6c1bd060c354..c47a39465b47 100644 --- a/products/workflows/frontend/TemplateLibrary/TransactionalEmailApiExample.tsx +++ b/products/workflows/frontend/TemplateLibrary/TransactionalEmailApiExample.tsx @@ -118,7 +118,7 @@ print(result.id)`,
  • Authenticate with a{' '} project secret API key that has the{' '} - hog_flow:write scope. Keep it on your server in{' '} + transactional_email:write scope. Keep it on your server in{' '} POSTHOG_PROJECT_SECRET_API_KEY.
  • diff --git a/products/workflows/mcp/email_templates.yaml b/products/workflows/mcp/email_templates.yaml index 85a1305941bf..26dcfca864fd 100644 --- a/products/workflows/mcp/email_templates.yaml +++ b/products/workflows/mcp/email_templates.yaml @@ -95,10 +95,11 @@ tools: subject and a body, and the project needs a verified email sender (pass from_integration_id to pick one, otherwise the project's default sender is used). Requires human typed confirmation. The response has transactional_hog_flow_id, the workflow that sends the email. Share the template's _posthogUrl so the user - can review and publish it. Then tell the user what is left: create a project secret API key with Workflow - set to Write, and send from their backend with posthog.emails.send in the Node.js or Python SDK, or POST - /api/projects/@current/messaging/send/email/ with template.id in the body. Each send returns a message ID, - and posthog.emails.get or GET /api/projects/@current/messaging/emails/{message_id}/ reads its status. + can review and publish it. Then tell the user what is left: create a project secret API key with + Transactional email set to Write, and send from their backend with posthog.emails.send in the Node.js or + Python SDK, or POST /api/projects/@current/messaging/send/email/ with template.id in the body. Each send + returns a message ID, and posthog.emails.get or GET /api/projects/@current/messaging/emails/{message_id}/ + reads its status. feature_flag: workflows-transactional-email-api response: exclude: diff --git a/services/mcp/schema/generated-tool-definitions.json b/services/mcp/schema/generated-tool-definitions.json index e5ed62d9e935..2e4f181660a7 100644 --- a/services/mcp/schema/generated-tool-definitions.json +++ b/services/mcp/schema/generated-tool-definitions.json @@ -17161,7 +17161,7 @@ } }, "workflows-make-email-template-transactional-execute": { - "description": "Step 2 of 2 for make email template transactional. Verifies the confirmation_hash from -prepare and the literal \"confirm\" string typed by the user, then performs the action. ONLY call this after the user has explicitly typed \"confirm\" in chat. Original action: Make an email template sendable from code with one API call, for emails a person triggers such as password resets, receipts and sign-in codes. This is one-way: the template can't be made non-transactional again. Transactional sends ignore marketing opt-outs, so never use this for marketing email. The template needs a subject and a body, and the project needs a verified email sender (pass from_integration_id to pick one, otherwise the project's default sender is used). Requires human typed confirmation. The response has transactional_hog_flow_id, the workflow that sends the email. Share the template's _posthogUrl so the user can review and publish it. Then tell the user what is left: create a project secret API key with Workflow set to Write, and send from their backend with posthog.emails.send in the Node.js or Python SDK, or POST /api/projects/@current/messaging/send/email/ with template.id in the body. Each send returns a message ID, and posthog.emails.get or GET /api/projects/@current/messaging/emails/{message_id}/ reads its status.", + "description": "Step 2 of 2 for make email template transactional. Verifies the confirmation_hash from -prepare and the literal \"confirm\" string typed by the user, then performs the action. ONLY call this after the user has explicitly typed \"confirm\" in chat. Original action: Make an email template sendable from code with one API call, for emails a person triggers such as password resets, receipts and sign-in codes. This is one-way: the template can't be made non-transactional again. Transactional sends ignore marketing opt-outs, so never use this for marketing email. The template needs a subject and a body, and the project needs a verified email sender (pass from_integration_id to pick one, otherwise the project's default sender is used). Requires human typed confirmation. The response has transactional_hog_flow_id, the workflow that sends the email. Share the template's _posthogUrl so the user can review and publish it. Then tell the user what is left: create a project secret API key with Transactional email set to Write, and send from their backend with posthog.emails.send in the Node.js or Python SDK, or POST /api/projects/@current/messaging/send/email/ with template.id in the body. Each send returns a message ID, and posthog.emails.get or GET /api/projects/@current/messaging/emails/{message_id}/ reads its status.", "category": "Workflows", "feature": "workflows", "summary": "Make email template transactional (execute)", @@ -17177,7 +17177,7 @@ "feature_flag": "workflows-transactional-email-api" }, "workflows-make-email-template-transactional-prepare": { - "description": "Step 1 of 2 for make email template transactional. Validates the arguments and returns a signed confirmation_hash plus a message to surface to the user. The user must reply with the literal word \"confirm\" before you call the matching -execute tool with the hash. Original action: Make an email template sendable from code with one API call, for emails a person triggers such as password resets, receipts and sign-in codes. This is one-way: the template can't be made non-transactional again. Transactional sends ignore marketing opt-outs, so never use this for marketing email. The template needs a subject and a body, and the project needs a verified email sender (pass from_integration_id to pick one, otherwise the project's default sender is used). Requires human typed confirmation. The response has transactional_hog_flow_id, the workflow that sends the email. Share the template's _posthogUrl so the user can review and publish it. Then tell the user what is left: create a project secret API key with Workflow set to Write, and send from their backend with posthog.emails.send in the Node.js or Python SDK, or POST /api/projects/@current/messaging/send/email/ with template.id in the body. Each send returns a message ID, and posthog.emails.get or GET /api/projects/@current/messaging/emails/{message_id}/ reads its status.", + "description": "Step 1 of 2 for make email template transactional. Validates the arguments and returns a signed confirmation_hash plus a message to surface to the user. The user must reply with the literal word \"confirm\" before you call the matching -execute tool with the hash. Original action: Make an email template sendable from code with one API call, for emails a person triggers such as password resets, receipts and sign-in codes. This is one-way: the template can't be made non-transactional again. Transactional sends ignore marketing opt-outs, so never use this for marketing email. The template needs a subject and a body, and the project needs a verified email sender (pass from_integration_id to pick one, otherwise the project's default sender is used). Requires human typed confirmation. The response has transactional_hog_flow_id, the workflow that sends the email. Share the template's _posthogUrl so the user can review and publish it. Then tell the user what is left: create a project secret API key with Transactional email set to Write, and send from their backend with posthog.emails.send in the Node.js or Python SDK, or POST /api/projects/@current/messaging/send/email/ with template.id in the body. Each send returns a message ID, and posthog.emails.get or GET /api/projects/@current/messaging/emails/{message_id}/ reads its status.", "category": "Workflows", "feature": "workflows", "summary": "Make email template transactional (prepare)", diff --git a/services/mcp/schema/tool-definitions-all.json b/services/mcp/schema/tool-definitions-all.json index cc6c496382c2..bef66d0a6529 100644 --- a/services/mcp/schema/tool-definitions-all.json +++ b/services/mcp/schema/tool-definitions-all.json @@ -17978,7 +17978,7 @@ } }, "workflows-make-email-template-transactional-execute": { - "description": "Step 2 of 2 for make email template transactional. Verifies the confirmation_hash from -prepare and the literal \"confirm\" string typed by the user, then performs the action. ONLY call this after the user has explicitly typed \"confirm\" in chat. Original action: Make an email template sendable from code with one API call, for emails a person triggers such as password resets, receipts and sign-in codes. This is one-way: the template can't be made non-transactional again. Transactional sends ignore marketing opt-outs, so never use this for marketing email. The template needs a subject and a body, and the project needs a verified email sender (pass from_integration_id to pick one, otherwise the project's default sender is used). Requires human typed confirmation. The response has transactional_hog_flow_id, the workflow that sends the email. Share the template's _posthogUrl so the user can review and publish it. Then tell the user what is left: create a project secret API key with Workflow set to Write, and send from their backend with posthog.emails.send in the Node.js or Python SDK, or POST /api/projects/@current/messaging/send/email/ with template.id in the body. Each send returns a message ID, and posthog.emails.get or GET /api/projects/@current/messaging/emails/{message_id}/ reads its status.", + "description": "Step 2 of 2 for make email template transactional. Verifies the confirmation_hash from -prepare and the literal \"confirm\" string typed by the user, then performs the action. ONLY call this after the user has explicitly typed \"confirm\" in chat. Original action: Make an email template sendable from code with one API call, for emails a person triggers such as password resets, receipts and sign-in codes. This is one-way: the template can't be made non-transactional again. Transactional sends ignore marketing opt-outs, so never use this for marketing email. The template needs a subject and a body, and the project needs a verified email sender (pass from_integration_id to pick one, otherwise the project's default sender is used). Requires human typed confirmation. The response has transactional_hog_flow_id, the workflow that sends the email. Share the template's _posthogUrl so the user can review and publish it. Then tell the user what is left: create a project secret API key with Transactional email set to Write, and send from their backend with posthog.emails.send in the Node.js or Python SDK, or POST /api/projects/@current/messaging/send/email/ with template.id in the body. Each send returns a message ID, and posthog.emails.get or GET /api/projects/@current/messaging/emails/{message_id}/ reads its status.", "category": "Workflows", "feature": "workflows", "summary": "Make email template transactional (execute)", @@ -17994,7 +17994,7 @@ "feature_flag": "workflows-transactional-email-api" }, "workflows-make-email-template-transactional-prepare": { - "description": "Step 1 of 2 for make email template transactional. Validates the arguments and returns a signed confirmation_hash plus a message to surface to the user. The user must reply with the literal word \"confirm\" before you call the matching -execute tool with the hash. Original action: Make an email template sendable from code with one API call, for emails a person triggers such as password resets, receipts and sign-in codes. This is one-way: the template can't be made non-transactional again. Transactional sends ignore marketing opt-outs, so never use this for marketing email. The template needs a subject and a body, and the project needs a verified email sender (pass from_integration_id to pick one, otherwise the project's default sender is used). Requires human typed confirmation. The response has transactional_hog_flow_id, the workflow that sends the email. Share the template's _posthogUrl so the user can review and publish it. Then tell the user what is left: create a project secret API key with Workflow set to Write, and send from their backend with posthog.emails.send in the Node.js or Python SDK, or POST /api/projects/@current/messaging/send/email/ with template.id in the body. Each send returns a message ID, and posthog.emails.get or GET /api/projects/@current/messaging/emails/{message_id}/ reads its status.", + "description": "Step 1 of 2 for make email template transactional. Validates the arguments and returns a signed confirmation_hash plus a message to surface to the user. The user must reply with the literal word \"confirm\" before you call the matching -execute tool with the hash. Original action: Make an email template sendable from code with one API call, for emails a person triggers such as password resets, receipts and sign-in codes. This is one-way: the template can't be made non-transactional again. Transactional sends ignore marketing opt-outs, so never use this for marketing email. The template needs a subject and a body, and the project needs a verified email sender (pass from_integration_id to pick one, otherwise the project's default sender is used). Requires human typed confirmation. The response has transactional_hog_flow_id, the workflow that sends the email. Share the template's _posthogUrl so the user can review and publish it. Then tell the user what is left: create a project secret API key with Transactional email set to Write, and send from their backend with posthog.emails.send in the Node.js or Python SDK, or POST /api/projects/@current/messaging/send/email/ with template.id in the body. Each send returns a message ID, and posthog.emails.get or GET /api/projects/@current/messaging/emails/{message_id}/ reads its status.", "category": "Workflows", "feature": "workflows", "summary": "Make email template transactional (prepare)", diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 668c79bc532f..087821ba1ecf 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -701,6 +701,7 @@ export namespace Schemas { * * `today` - today * * `toolbar` - toolbar * * `tracing` - tracing + * * `transactional_email` - transactional_email * * `field_note` - field_note * * `uploaded_media` - uploaded_media * * `usage_metric` - usage_metric @@ -825,6 +826,7 @@ export namespace Schemas { Today: 'today', Toolbar: 'toolbar', Tracing: 'tracing', + TransactionalEmail: 'transactional_email', FieldNote: 'field_note', UploadedMedia: 'uploaded_media', UsageMetric: 'usage_metric', @@ -949,6 +951,7 @@ export namespace Schemas { * * `today` - today * * `toolbar` - toolbar * * `tracing` - tracing + * * `transactional_email` - transactional_email * * `field_note` - field_note * * `uploaded_media` - uploaded_media * * `usage_metric` - usage_metric @@ -1196,6 +1199,7 @@ export namespace Schemas { * * `today` - today * * `toolbar` - toolbar * * `tracing` - tracing + * * `transactional_email` - transactional_email * * `field_note` - field_note * * `uploaded_media` - uploaded_media * * `usage_metric` - usage_metric @@ -1606,6 +1610,7 @@ export namespace Schemas { * * `today` - today * * `toolbar` - toolbar * * `tracing` - tracing + * * `transactional_email` - transactional_email * * `field_note` - field_note * * `uploaded_media` - uploaded_media * * `usage_metric` - usage_metric @@ -73164,6 +73169,7 @@ export namespace Schemas { * * `today` - today * * `toolbar` - toolbar * * `tracing` - tracing + * * `transactional_email` - transactional_email * * `field_note` - field_note * * `uploaded_media` - uploaded_media * * `usage_metric` - usage_metric diff --git a/services/mcp/src/lib/oauth-scopes.generated.ts b/services/mcp/src/lib/oauth-scopes.generated.ts index 410147d3a305..91973e3b6711 100644 --- a/services/mcp/src/lib/oauth-scopes.generated.ts +++ b/services/mcp/src/lib/oauth-scopes.generated.ts @@ -214,6 +214,8 @@ export const OAUTH_SCOPES_SUPPORTED = [ 'toolbar:write', 'tracing:read', 'tracing:write', + 'transactional_email:read', + 'transactional_email:write', 'field_note:read', 'field_note:write', 'uploaded_media:read', @@ -268,4 +270,5 @@ export const OAUTH_SCOPES_HIDDEN = [ export const LEGACY_SCOPE_FALLBACKS: ReadonlyMap = new Map([ ['inbox', 'task'], ['messaging_preference', 'hog_flow'], + ['transactional_email', 'hog_flow'], ])