From 22ac17c7c53a84bd9f7a3654e72e2531b572e826 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:47:13 +0000 Subject: [PATCH 1/6] feat(messaging): answer a send the checks refuse with why and fix Co-Authored-By: Claude Opus 5.5 --- .../messaging/backend/facade/contracts.py | 6 ++ .../backend/facade/transactional_email.py | 2 + .../presentation/views/messaging_send.py | 37 +++++++--- .../views/transactional_email_api.py | 21 ++++++ .../backend/services/transactional_email.py | 22 +++++- .../tests/api/test_transactional_email.py | 67 ++++++++++++++++++- 6 files changed, 141 insertions(+), 14 deletions(-) diff --git a/products/messaging/backend/facade/contracts.py b/products/messaging/backend/facade/contracts.py index fded374bd3f1..d616b53e0e75 100644 --- a/products/messaging/backend/facade/contracts.py +++ b/products/messaging/backend/facade/contracts.py @@ -64,6 +64,10 @@ class SenderNotVerified(TransactionalEmailError): code = "sender_not_verified" +class SenderNotAllowed(TransactionalEmailError): + code = "sender_not_allowed" + + @frozen class RenderedContent: subject: str @@ -121,6 +125,8 @@ class Rejected: code: str detail: str + why: str | None = None + fix: str | None = None @frozen diff --git a/products/messaging/backend/facade/transactional_email.py b/products/messaging/backend/facade/transactional_email.py index 8afef08376a4..60cc43c3fe8f 100644 --- a/products/messaging/backend/facade/transactional_email.py +++ b/products/messaging/backend/facade/transactional_email.py @@ -8,6 +8,7 @@ Rejected, RenderedContent, RenderedEmail, + SenderNotAllowed, SenderNotVerified, SendFrom, StoredTemplate, @@ -36,6 +37,7 @@ "RenderedContent", "RenderedEmail", "SendFrom", + "SenderNotAllowed", "SenderNotVerified", "StoredTemplate", "TransactionalEmailError", diff --git a/products/messaging/backend/presentation/views/messaging_send.py b/products/messaging/backend/presentation/views/messaging_send.py index f930ac7f10f4..4285c18fcfa2 100644 --- a/products/messaging/backend/presentation/views/messaging_send.py +++ b/products/messaging/backend/presentation/views/messaging_send.py @@ -29,6 +29,7 @@ AcceptedSend, EmailSendCommand, RenderedEmail, + SenderNotAllowed, SenderNotVerified, code_send_from, rendered_content, @@ -350,13 +351,21 @@ def unknown_field_why(attr: str | None) -> str: ) -def sender_not_verified(detail: str) -> TemplateRefusal: +def unusable_sender(error: SenderNotVerified | SenderNotAllowed) -> TemplateRefusal: + if isinstance(error, SenderNotAllowed): + return TemplateRefusal( + code=error.code, + detail=str(error), + attr="from", + why="PostHog only sends from a domain the project verified, so mailbox providers trust the email.", + fix="Send from an address on a verified domain, or verify the domain in the email channel settings first.", + ) return TemplateRefusal( - code="sender_not_verified", - detail=detail, + code=error.code, + detail=str(error), attr="from", - why="PostHog only sends from a domain the project verified, so mailbox providers trust the email.", - fix="Send from an address on a verified domain, or verify the domain in the email channel settings first.", + why="The send names no `from`, and the project has no verified default sender to send from.", + fix="Pass `from` with an address on a verified domain, or set a default sender in the email channel settings.", ) @@ -381,7 +390,9 @@ class MessagingSendViewSet(TransactionalEmailViewSetMixin, viewsets.ViewSet): 202: OpenApiResponse(response=SendEmailResponseSerializer, description="Email queued"), 400: OpenApiResponse( response=TemplateErrorSerializer, - description="A field is invalid, the email is paused, or the recipient can't be used", + description="A field is invalid, or a send check failed: the recipient can't receive email, the " + "sender can't send, or sending is paused or suspended. `why` and `fix` say what to change. Nothing " + "was queued", ), 404: OpenApiResponse( response=TemplateErrorSerializer, @@ -395,7 +406,11 @@ class MessagingSendViewSet(TransactionalEmailViewSetMixin, viewsets.ViewSet): "current content or one of the last 100 different contents it replaced, or no template has this key " "yet. Nothing was sent", ), - 429: OpenApiResponse(description="Rate limited, or the project is over its email quota"), + 429: OpenApiResponse( + response=TemplateErrorSerializer, + description="`throttled`: rate limited, retry later. `quota_limited`: the project is over its email " + "quota, and a retry sends nothing", + ), 503: OpenApiResponse( description="The email pipeline did not confirm the send. The email may still go out, so sending " "it again can deliver it twice" @@ -446,8 +461,8 @@ def _send_template(self, request: Request, data: dict[str, Any]) -> Response: reply_to=tuple(data.get("reply_to", ())), capture_engagement=data["capture_engagement"], ) - except SenderNotVerified as e: - return self._refuse(sender_not_verified(str(e)), usage=usage) + except (SenderNotVerified, SenderNotAllowed) as e: + return self._refuse(unusable_sender(e), usage=usage) queued = self._send_transactional_email(request, target.template, data, usage=usage, send=send) warnings = [TEMPLATE_SUPERSEDED] if target.hash_state == ContentHashState.SUPERSEDED else [] return self._queued(queued, warnings) @@ -476,8 +491,8 @@ def _send_rendered_content(self, request: Request, data: dict[str, Any]) -> Resp reply_to=tuple(data.get("reply_to", ())), capture_engagement=data["capture_engagement"], ) - except SenderNotVerified as e: - return self._refuse(sender_not_verified(str(e)), usage=usage) + except (SenderNotVerified, SenderNotAllowed) as e: + return self._refuse(unusable_sender(e), usage=usage) command = EmailSendCommand( team_id=self.team_id, to=data["to"], diff --git a/products/messaging/backend/presentation/views/transactional_email_api.py b/products/messaging/backend/presentation/views/transactional_email_api.py index b5a6d8ab1804..0524f4fcf623 100644 --- a/products/messaging/backend/presentation/views/transactional_email_api.py +++ b/products/messaging/backend/presentation/views/transactional_email_api.py @@ -6,6 +6,7 @@ from rest_framework import exceptions, serializers, status from rest_framework.exceptions import NotFound from rest_framework.request import Request +from rest_framework.response import Response from rest_framework.settings import api_settings from posthog.api.routing import TeamAndOrgViewSetMixin @@ -156,6 +157,17 @@ def __init__(self, code: str, detail: str) -> None: super().__init__(detail=detail, code=code) +SEND_CHECK_ATTR = {"invalid_recipient": "to", "recipient_suppressed": "to", "sender_not_verified": "from"} + + +class SendCheckFailed(exceptions.APIException): + status_code = status.HTTP_400_BAD_REQUEST + + def __init__(self, refusal: TemplateRefusal) -> None: + self.refusal = refusal + super().__init__(detail=refusal.detail, code=refusal.code) + + class TransactionalEmailUnavailable(exceptions.APIException): status_code = status.HTTP_503_SERVICE_UNAVAILABLE default_detail = "The email pipeline is unavailable. Try again shortly." @@ -229,6 +241,11 @@ def _usage(outcome: EmailSendOutcome) -> dict[str, str]: class TransactionalEmailViewSetMixin(TeamAndOrgViewSetMixin): unconfirmed_send_error: type[TransactionalEmailUnavailable] = TransactionalEmailUnavailable + def handle_exception(self, exc: Exception) -> Response: + if isinstance(exc, SendCheckFailed): + return Response(TemplateErrorSerializer(exc.refusal).data, status=exc.status_code) + return super().handle_exception(exc) + def _require_transactional_email_flag(self) -> None: self._require_organization_flag(TRANSACTIONAL_EMAIL_API_FLAG) @@ -308,6 +325,10 @@ def _send(self, request: Any, command: EmailSendCommand, *, report: dict[str, st match outcome: case Queued(): return outcome + case Rejected(code=code, detail=detail, why=str() as why, fix=str() as fix): + raise SendCheckFailed( + TemplateRefusal(code=code, detail=detail, attr=SEND_CHECK_ATTR.get(code), why=why, fix=fix) + ) case Rejected(code=code, detail=detail): raise TransactionalEmailRejected(code, detail) case Unavailable(): diff --git a/products/messaging/backend/services/transactional_email.py b/products/messaging/backend/services/transactional_email.py index e2c2f17d2053..569e2182b2ed 100644 --- a/products/messaging/backend/services/transactional_email.py +++ b/products/messaging/backend/services/transactional_email.py @@ -28,6 +28,7 @@ Rejected, RenderedContent, RenderedEmail, + SenderNotAllowed, SenderNotVerified, SenderUnavailable, SendFrom, @@ -329,7 +330,7 @@ def code_send_from(team_id: int, address: str | None) -> SendFrom: sender = code_send_sender(team_id=team_id, email=email or None, name=name or None) except TransactionalEmailSenderUnavailable as e: if address: - raise SenderNotVerified(f"No verified sender in this project can send from `{email}`.") from e + raise SenderNotAllowed(f"No verified sender in this project can send from `{email}`.") from e raise SenderNotVerified("The project has no verified default email sender.") from e return SendFrom(integration_id=sender.integration_id, email=sender.email, name=sender.name) @@ -367,6 +368,12 @@ def send_email(command: EmailSendCommand) -> EmailSendOutcome: return Unavailable() if response.status_code != 202: + if ( + response.status_code == 400 + and command.source.send is not None + and (refused := _send_check_refusal(response)) + ): + return refused if response.status_code == 429: return Rejected(code="quota_limited", detail="The project is over its email quota.") if response.status_code == 409: @@ -391,6 +398,19 @@ def send_email(command: EmailSendCommand) -> EmailSendOutcome: return Queued(message_id=str(body["invocation_id"]), replayed=bool(body.get("replayed", False))) +def _send_check_refusal(response: requests.Response) -> Rejected | None: + try: + body = response.json() + except ValueError: + return None + match body: + case {"code": str(code), "detail": str(detail), "why": str(why), "fix": str(fix)} if all( + (code, detail, why, fix) + ): + return Rejected(code=code, detail=detail, why=why, fix=fix) + return None + + def _sendable_workflow_id(team_id: int, source: StoredTemplate | RenderedEmail) -> UUID: match source: case StoredTemplate(template_id=template_id): diff --git a/products/messaging/backend/tests/api/test_transactional_email.py b/products/messaging/backend/tests/api/test_transactional_email.py index c578627e040c..69ac4c72a8d7 100644 --- a/products/messaging/backend/tests/api/test_transactional_email.py +++ b/products/messaging/backend/tests/api/test_transactional_email.py @@ -403,7 +403,7 @@ def test_rejects_a_send_it_cannot_make( "unverified_sender_with_template_key", {"template": {"key": "password-reset"}, "from": "Example "}, status.HTTP_400_BAD_REQUEST, - "sender_not_verified", + "sender_not_allowed", "from", ), ] @@ -471,6 +471,7 @@ def test_both_send_routes_share_one_rate_limit_per_key(self, _rate_limit, mock_p assert by_url_id.status_code == status.HTTP_202_ACCEPTED, by_url_id.json() assert by_key.status_code == status.HTTP_429_TOO_MANY_REQUESTS, by_key.json() + assert by_key.json()["code"] == "throttled" @parameterized.expand( [ @@ -488,6 +489,14 @@ def test_both_send_routes_share_one_rate_limit_per_key(self, _rate_limit, mock_p ("cdp_api_error", MagicMock(status_code=500)), ("cdp_api_without_the_route", MagicMock(status_code=404)), ("cdp_api_without_the_route_behind_an_internal_secret", MagicMock(status_code=401)), + ( + "cdp_api_without_send_checks_refusing_the_request", + MagicMock(status_code=400, json=lambda: {"error": "Workflow is not an active transactional email"}), + ), + ( + "cdp_api_answering_a_bad_request_without_json", + MagicMock(status_code=400, json=MagicMock(side_effect=ValueError)), + ), ] ] ) @@ -597,6 +606,47 @@ def test_reports_a_send_the_pipeline_refuses( reported = capture.call_args.kwargs["properties"] assert (reported["outcome"], reported["error_code"]) == ("rejected", code) + @parameterized.expand( + [ + ("invalid_recipient", "template_key", "invalid_recipient", "to"), + ("recipient_suppressed", "template_key", "recipient_suppressed", "to"), + ("template_paused", "key", "template_paused", None), + ("sending_suspended", "id", "sending_suspended", None), + ("sender_not_verified", "key", "sender_not_verified", "from"), + ("a_check_this_server_does_not_know_yet", "template_key", "recipient_on_hold", None), + ] + ) + def test_answers_a_send_the_checks_refuse_with_why_and_fix( + self, mock_post, _flag, capture, _name, addressed_by, code, attr + ): + refusal = {"detail": "What the check found.", "why": "Why PostHog refused.", "fix": "What to change."} + mock_post.return_value = MagicMock(status_code=400, json=lambda: {"error": code, "code": code, **refusal}) + url, body = self._send_request(addressed_by, self.body) + + response = self.client.post(url, body, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json() + assert response.json() == { + "type": "invalid_request", + "code": code, + "attr": attr, + "docs_url": f"https://posthog.com/docs/workflows/transactional-email-api#{code}", + **refusal, + } + reported = capture.call_args.kwargs["properties"] + assert (reported["outcome"], reported["error_code"]) == ("rejected", code) + + def test_the_url_id_route_reads_no_send_check_refusal_from_the_pipeline(self, mock_post, _flag, _capture): + mock_post.return_value = MagicMock( + status_code=400, + json=lambda: {"code": "template_paused", "detail": "Paused.", "why": "Paused.", "fix": "Resume it."}, + ) + + response = self.client.post(self.url, self.body, format="json") + + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE, response.json() + assert response.json()["code"] == "delivery_unavailable" + def _send_body(self, addressed_by: str, **fields: Any) -> dict[str, Any]: if addressed_by == "template_key": return self._rendered(**fields) @@ -759,7 +809,7 @@ def test_a_send_carries_what_the_caller_set_over_its_workflow( ("quoted_local_part", {"from": '"security"@example.com'}, "invalid_request", "from"), ("sender_too_long", {"from": f"{'s' * 250}@example.com"}, "invalid_request", "from"), ("empty_reply_to", {"reply_to": []}, "invalid_request", "reply_to"), - ("unverified_domain", {"from": "Example "}, "sender_not_verified", "from"), + ("unverified_domain", {"from": "Example "}, "sender_not_allowed", "from"), ("invalid_key", {"template_key": "welcome email"}, "invalid_template_key", "template_key"), ("field_this_server_lacks", {"scheduled_at": "2026-11-01T09:00:00Z"}, "invalid_request", "scheduled_at"), ( @@ -790,6 +840,19 @@ def test_refuses_a_rendered_send_it_cannot_make_with_what_to_fix( assert error["detail"] == f"to: `{fields['to']}` is not a bare address. Send it as `jane@example.com`." mock_post.assert_not_called() + def test_refuses_a_rendered_send_without_from_when_the_project_has_no_verified_sender( + self, mock_post, _flag, _capture + ): + Integration.objects.filter(id=self.sender.id).update(config={"verified": False}) + + response = self._psak_client(["hog_flow:write"]).post(self.send_email_url, self._rendered(), format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json() + error = response.json() + assert (error["code"], error["attr"]) == ("sender_not_verified", "from") + assert "no verified default sender" in error["why"] + mock_post.assert_not_called() + def test_the_url_id_route_refuses_a_recipient_the_pipeline_would_split(self, mock_post, _flag, _capture): response = self.client.post(self.url, {**self.body, "to": '"help,desk"@example.com'}, format="json") From 6439a06e71784ff0b7d54eeb7c29d99b680b967a Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:50:56 +0000 Subject: [PATCH 2/6] fix(workflows): name the send check refusals in the send email tool Co-Authored-By: Claude Opus 5.5 --- products/workflows/mcp/email_templates.yaml | 5 +++-- services/mcp/schema/generated-tool-definitions.json | 2 +- services/mcp/schema/tool-definitions-all.json | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/products/workflows/mcp/email_templates.yaml b/products/workflows/mcp/email_templates.yaml index 55d2fc53bfa4..a92a788f9bd4 100644 --- a/products/workflows/mcp/email_templates.yaml +++ b/products/workflows/mcp/email_templates.yaml @@ -98,8 +98,9 @@ tools: subject and the body as html, text or both, exactly as the recipient should see it: PostHog renders nothing, so {{ }} goes out as typed. When the user points you at a local file, such as an HTML email in their repository, read the file yourself and pass its content as html. This tool can't read files or - URLs. from must be an address on a domain the project verified; sender_not_verified means nothing was - sent. Each call sends an email, so never repeat a call that succeeded. A 503 means PostHog did not confirm + URLs. from must be an address on a domain the project verified; sender_not_allowed means nothing was + sent. A 400 with why and fix means a send check refused the email, such as a suppressed recipient or a + paused email, and nothing was sent: tell the user the why and the fix. Each call sends an email, so never repeat a call that succeeded. A 503 means PostHog did not confirm the send, and the email may still go out: do not try again on your own, tell the user. A 404 with code not_found means sending email from code is not enabled for this project, or the project does not exist: stop and tell the user. diff --git a/services/mcp/schema/generated-tool-definitions.json b/services/mcp/schema/generated-tool-definitions.json index fa0f7dfe564f..b75459d6a1bb 100644 --- a/services/mcp/schema/generated-tool-definitions.json +++ b/services/mcp/schema/generated-tool-definitions.json @@ -16700,7 +16700,7 @@ } }, "workflows-send-email": { - "description": "Send one transactional email to one recipient, from a template or with content you pass. For a template, name it by template.key (as uploaded with workflows-upload-email-template) or template.id (a template made in PostHog), and put every value it needs into message_data, read as {{ variables.* }}; the template can't read person properties. Pass template.hash, the content_hash of the last upload, to check that PostHog holds that content: template_outdated means PostHog does not hold it and nothing was sent, so upload the template and send again; a template_superseded warning means newer content was sent. To pass content instead, such as a password reset, a receipt or a sign-in code, no template has to exist in PostHog first. Name the email with template_key (for example welcome); sends under one key share a workflow, so their metrics and runs group together. Pass the subject and the body as html, text or both, exactly as the recipient should see it: PostHog renders nothing, so {{ }} goes out as typed. When the user points you at a local file, such as an HTML email in their repository, read the file yourself and pass its content as html. This tool can't read files or URLs. from must be an address on a domain the project verified; sender_not_verified means nothing was sent. Each call sends an email, so never repeat a call that succeeded. A 503 means PostHog did not confirm the send, and the email may still go out: do not try again on your own, tell the user. A 404 with code not_found means sending email from code is not enabled for this project, or the project does not exist: stop and tell the user.", + "description": "Send one transactional email to one recipient, from a template or with content you pass. For a template, name it by template.key (as uploaded with workflows-upload-email-template) or template.id (a template made in PostHog), and put every value it needs into message_data, read as {{ variables.* }}; the template can't read person properties. Pass template.hash, the content_hash of the last upload, to check that PostHog holds that content: template_outdated means PostHog does not hold it and nothing was sent, so upload the template and send again; a template_superseded warning means newer content was sent. To pass content instead, such as a password reset, a receipt or a sign-in code, no template has to exist in PostHog first. Name the email with template_key (for example welcome); sends under one key share a workflow, so their metrics and runs group together. Pass the subject and the body as html, text or both, exactly as the recipient should see it: PostHog renders nothing, so {{ }} goes out as typed. When the user points you at a local file, such as an HTML email in their repository, read the file yourself and pass its content as html. This tool can't read files or URLs. from must be an address on a domain the project verified; sender_not_allowed means nothing was sent. A 400 with why and fix means a send check refused the email, such as a suppressed recipient or a paused email, and nothing was sent: tell the user the why and the fix. Each call sends an email, so never repeat a call that succeeded. A 503 means PostHog did not confirm the send, and the email may still go out: do not try again on your own, tell the user. A 404 with code not_found means sending email from code is not enabled for this project, or the project does not exist: stop and tell the user.", "category": "Workflows", "feature": "workflows", "summary": "Send transactional email", diff --git a/services/mcp/schema/tool-definitions-all.json b/services/mcp/schema/tool-definitions-all.json index 5f56b59606e2..8e5c5d652bac 100644 --- a/services/mcp/schema/tool-definitions-all.json +++ b/services/mcp/schema/tool-definitions-all.json @@ -17517,7 +17517,7 @@ } }, "workflows-send-email": { - "description": "Send one transactional email to one recipient, from a template or with content you pass. For a template, name it by template.key (as uploaded with workflows-upload-email-template) or template.id (a template made in PostHog), and put every value it needs into message_data, read as {{ variables.* }}; the template can't read person properties. Pass template.hash, the content_hash of the last upload, to check that PostHog holds that content: template_outdated means PostHog does not hold it and nothing was sent, so upload the template and send again; a template_superseded warning means newer content was sent. To pass content instead, such as a password reset, a receipt or a sign-in code, no template has to exist in PostHog first. Name the email with template_key (for example welcome); sends under one key share a workflow, so their metrics and runs group together. Pass the subject and the body as html, text or both, exactly as the recipient should see it: PostHog renders nothing, so {{ }} goes out as typed. When the user points you at a local file, such as an HTML email in their repository, read the file yourself and pass its content as html. This tool can't read files or URLs. from must be an address on a domain the project verified; sender_not_verified means nothing was sent. Each call sends an email, so never repeat a call that succeeded. A 503 means PostHog did not confirm the send, and the email may still go out: do not try again on your own, tell the user. A 404 with code not_found means sending email from code is not enabled for this project, or the project does not exist: stop and tell the user.", + "description": "Send one transactional email to one recipient, from a template or with content you pass. For a template, name it by template.key (as uploaded with workflows-upload-email-template) or template.id (a template made in PostHog), and put every value it needs into message_data, read as {{ variables.* }}; the template can't read person properties. Pass template.hash, the content_hash of the last upload, to check that PostHog holds that content: template_outdated means PostHog does not hold it and nothing was sent, so upload the template and send again; a template_superseded warning means newer content was sent. To pass content instead, such as a password reset, a receipt or a sign-in code, no template has to exist in PostHog first. Name the email with template_key (for example welcome); sends under one key share a workflow, so their metrics and runs group together. Pass the subject and the body as html, text or both, exactly as the recipient should see it: PostHog renders nothing, so {{ }} goes out as typed. When the user points you at a local file, such as an HTML email in their repository, read the file yourself and pass its content as html. This tool can't read files or URLs. from must be an address on a domain the project verified; sender_not_allowed means nothing was sent. A 400 with why and fix means a send check refused the email, such as a suppressed recipient or a paused email, and nothing was sent: tell the user the why and the fix. Each call sends an email, so never repeat a call that succeeded. A 503 means PostHog did not confirm the send, and the email may still go out: do not try again on your own, tell the user. A 404 with code not_found means sending email from code is not enabled for this project, or the project does not exist: stop and tell the user.", "category": "Workflows", "feature": "workflows", "summary": "Send transactional email", From bd753e3620943bbf9151518288b70db370501068 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:24:40 +0000 Subject: [PATCH 3/6] fix(messaging): point sender_not_verified at from only on a code send A template send can't name `from`, so a sender_not_verified refusal on it answers with no attr. Co-Authored-By: Claude Opus 5.5 --- .../presentation/views/transactional_email_api.py | 13 +++++++++++-- .../backend/tests/api/test_transactional_email.py | 3 ++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/products/messaging/backend/presentation/views/transactional_email_api.py b/products/messaging/backend/presentation/views/transactional_email_api.py index 0524f4fcf623..a796611228c2 100644 --- a/products/messaging/backend/presentation/views/transactional_email_api.py +++ b/products/messaging/backend/presentation/views/transactional_email_api.py @@ -23,6 +23,7 @@ EmailSendOutcome, Queued, Rejected, + RenderedEmail, StoredTemplate, Unavailable, send_email, @@ -157,7 +158,13 @@ def __init__(self, code: str, detail: str) -> None: super().__init__(detail=detail, code=code) -SEND_CHECK_ATTR = {"invalid_recipient": "to", "recipient_suppressed": "to", "sender_not_verified": "from"} +SEND_CHECK_ATTR = {"invalid_recipient": "to", "recipient_suppressed": "to"} +CODE_SEND_CHECK_ATTR = {**SEND_CHECK_ATTR, "sender_not_verified": "from"} + + +def send_check_attr(code: str, source: StoredTemplate | RenderedEmail) -> str | None: + attrs = CODE_SEND_CHECK_ATTR if isinstance(source, RenderedEmail) else SEND_CHECK_ATTR + return attrs.get(code) class SendCheckFailed(exceptions.APIException): @@ -327,7 +334,9 @@ def _send(self, request: Any, command: EmailSendCommand, *, report: dict[str, st return outcome case Rejected(code=code, detail=detail, why=str() as why, fix=str() as fix): raise SendCheckFailed( - TemplateRefusal(code=code, detail=detail, attr=SEND_CHECK_ATTR.get(code), why=why, fix=fix) + TemplateRefusal( + code=code, detail=detail, attr=send_check_attr(code, command.source), why=why, fix=fix + ) ) case Rejected(code=code, detail=detail): raise TransactionalEmailRejected(code, detail) diff --git a/products/messaging/backend/tests/api/test_transactional_email.py b/products/messaging/backend/tests/api/test_transactional_email.py index 69ac4c72a8d7..2ecd052f8773 100644 --- a/products/messaging/backend/tests/api/test_transactional_email.py +++ b/products/messaging/backend/tests/api/test_transactional_email.py @@ -612,7 +612,8 @@ def test_reports_a_send_the_pipeline_refuses( ("recipient_suppressed", "template_key", "recipient_suppressed", "to"), ("template_paused", "key", "template_paused", None), ("sending_suspended", "id", "sending_suspended", None), - ("sender_not_verified", "key", "sender_not_verified", "from"), + ("sender_not_verified_on_a_template_send", "key", "sender_not_verified", None), + ("sender_not_verified_on_a_code_send", "template_key", "sender_not_verified", "from"), ("a_check_this_server_does_not_know_yet", "template_key", "recipient_on_hold", None), ] ) From 877d2607e1a0815fc8b2d78de35452e5120116da Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:24:45 +0000 Subject: [PATCH 4/6] fix(messaging): say a send refusal carries why and fix only when it has them Django's own refusals on send/email/ answer without why and fix. Co-Authored-By: Claude Opus 5.5 --- .../messaging/backend/presentation/views/messaging_send.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/products/messaging/backend/presentation/views/messaging_send.py b/products/messaging/backend/presentation/views/messaging_send.py index 4285c18fcfa2..c02f6c164170 100644 --- a/products/messaging/backend/presentation/views/messaging_send.py +++ b/products/messaging/backend/presentation/views/messaging_send.py @@ -391,8 +391,8 @@ class MessagingSendViewSet(TransactionalEmailViewSetMixin, viewsets.ViewSet): 400: OpenApiResponse( response=TemplateErrorSerializer, description="A field is invalid, or a send check failed: the recipient can't receive email, the " - "sender can't send, or sending is paused or suspended. `why` and `fix` say what to change. Nothing " - "was queued", + "sender can't send, or sending is paused or suspended. `why` and `fix`, when the answer has them, say " + "what to change. Nothing was queued", ), 404: OpenApiResponse( response=TemplateErrorSerializer, From 4bf5fc7454e98d934844c8c9291d09aa810f8e62 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:35:36 +0000 Subject: [PATCH 5/6] fix(messaging): take a send-check refusal's attr from the pipeline A send refused because the workflow's stored cc or bcc is suppressed answered `attr: "to"`, though the caller sent no cc. When the pipeline's refusal body carries `attr`, null included, the API now answers with it. A body without `attr` comes from an older pipeline and keeps the mapped field. Co-Authored-By: Claude Opus 5.5 --- .../messaging/backend/facade/contracts.py | 1 + .../views/transactional_email_api.py | 18 ++-------------- .../backend/services/transactional_email.py | 18 +++++++++++++--- .../tests/api/test_transactional_email.py | 21 +++++++++++-------- 4 files changed, 30 insertions(+), 28 deletions(-) diff --git a/products/messaging/backend/facade/contracts.py b/products/messaging/backend/facade/contracts.py index d616b53e0e75..7af67fa0b036 100644 --- a/products/messaging/backend/facade/contracts.py +++ b/products/messaging/backend/facade/contracts.py @@ -127,6 +127,7 @@ class Rejected: detail: str why: str | None = None fix: str | None = None + attr: str | None = None @frozen diff --git a/products/messaging/backend/presentation/views/transactional_email_api.py b/products/messaging/backend/presentation/views/transactional_email_api.py index a796611228c2..7c7ca60f3bd1 100644 --- a/products/messaging/backend/presentation/views/transactional_email_api.py +++ b/products/messaging/backend/presentation/views/transactional_email_api.py @@ -23,7 +23,6 @@ EmailSendOutcome, Queued, Rejected, - RenderedEmail, StoredTemplate, Unavailable, send_email, @@ -158,15 +157,6 @@ def __init__(self, code: str, detail: str) -> None: super().__init__(detail=detail, code=code) -SEND_CHECK_ATTR = {"invalid_recipient": "to", "recipient_suppressed": "to"} -CODE_SEND_CHECK_ATTR = {**SEND_CHECK_ATTR, "sender_not_verified": "from"} - - -def send_check_attr(code: str, source: StoredTemplate | RenderedEmail) -> str | None: - attrs = CODE_SEND_CHECK_ATTR if isinstance(source, RenderedEmail) else SEND_CHECK_ATTR - return attrs.get(code) - - class SendCheckFailed(exceptions.APIException): status_code = status.HTTP_400_BAD_REQUEST @@ -332,12 +322,8 @@ def _send(self, request: Any, command: EmailSendCommand, *, report: dict[str, st match outcome: case Queued(): return outcome - case Rejected(code=code, detail=detail, why=str() as why, fix=str() as fix): - raise SendCheckFailed( - TemplateRefusal( - code=code, detail=detail, attr=send_check_attr(code, command.source), why=why, fix=fix - ) - ) + case Rejected(code=code, detail=detail, attr=attr, why=str() as why, fix=str() as fix): + raise SendCheckFailed(TemplateRefusal(code=code, detail=detail, attr=attr, why=why, fix=fix)) case Rejected(code=code, detail=detail): raise TransactionalEmailRejected(code, detail) case Unavailable(): diff --git a/products/messaging/backend/services/transactional_email.py b/products/messaging/backend/services/transactional_email.py index 569e2182b2ed..25156b48ea84 100644 --- a/products/messaging/backend/services/transactional_email.py +++ b/products/messaging/backend/services/transactional_email.py @@ -371,7 +371,7 @@ def send_email(command: EmailSendCommand) -> EmailSendOutcome: if ( response.status_code == 400 and command.source.send is not None - and (refused := _send_check_refusal(response)) + and (refused := _send_check_refusal(response, command.source)) ): return refused if response.status_code == 429: @@ -398,7 +398,11 @@ def send_email(command: EmailSendCommand) -> EmailSendOutcome: return Queued(message_id=str(body["invocation_id"]), replayed=bool(body.get("replayed", False))) -def _send_check_refusal(response: requests.Response) -> Rejected | None: +SEND_CHECK_ATTR = {"invalid_recipient": "to", "recipient_suppressed": "to"} +CODE_SEND_CHECK_ATTR = {**SEND_CHECK_ATTR, "sender_not_verified": "from"} + + +def _send_check_refusal(response: requests.Response, source: StoredTemplate | RenderedEmail) -> Rejected | None: try: body = response.json() except ValueError: @@ -407,10 +411,18 @@ def _send_check_refusal(response: requests.Response) -> Rejected | None: case {"code": str(code), "detail": str(detail), "why": str(why), "fix": str(fix)} if all( (code, detail, why, fix) ): - return Rejected(code=code, detail=detail, why=why, fix=fix) + return Rejected(code=code, detail=detail, why=why, fix=fix, attr=_send_check_attr(body, code, source)) return None +def _send_check_attr(body: dict[str, Any], code: str, source: StoredTemplate | RenderedEmail) -> str | None: + match body: + case {"attr": str() | None as attr}: + return attr + attrs = CODE_SEND_CHECK_ATTR if isinstance(source, RenderedEmail) else SEND_CHECK_ATTR + return attrs.get(code) + + def _sendable_workflow_id(team_id: int, source: StoredTemplate | RenderedEmail) -> UUID: match source: case StoredTemplate(template_id=template_id): diff --git a/products/messaging/backend/tests/api/test_transactional_email.py b/products/messaging/backend/tests/api/test_transactional_email.py index 2ecd052f8773..e5ae6c0eb9c4 100644 --- a/products/messaging/backend/tests/api/test_transactional_email.py +++ b/products/messaging/backend/tests/api/test_transactional_email.py @@ -608,20 +608,23 @@ def test_reports_a_send_the_pipeline_refuses( @parameterized.expand( [ - ("invalid_recipient", "template_key", "invalid_recipient", "to"), - ("recipient_suppressed", "template_key", "recipient_suppressed", "to"), - ("template_paused", "key", "template_paused", None), - ("sending_suspended", "id", "sending_suspended", None), - ("sender_not_verified_on_a_template_send", "key", "sender_not_verified", None), - ("sender_not_verified_on_a_code_send", "template_key", "sender_not_verified", "from"), - ("a_check_this_server_does_not_know_yet", "template_key", "recipient_on_hold", None), + ("invalid_recipient", "template_key", "invalid_recipient", {}, "to"), + ("recipient_suppressed", "template_key", "recipient_suppressed", {}, "to"), + ("template_paused", "key", "template_paused", {}, None), + ("sending_suspended", "id", "sending_suspended", {}, None), + ("sender_not_verified_on_a_template_send", "key", "sender_not_verified", {}, None), + ("sender_not_verified_on_a_code_send", "template_key", "sender_not_verified", {}, "from"), + ("a_check_this_server_does_not_know_yet", "template_key", "recipient_on_hold", {}, None), + ("a_suppressed_stored_copy", "template_key", "recipient_suppressed", {"attr": None}, None), ] ) def test_answers_a_send_the_checks_refuse_with_why_and_fix( - self, mock_post, _flag, capture, _name, addressed_by, code, attr + self, mock_post, _flag, capture, _name, addressed_by, code, pipeline_attr, attr ): refusal = {"detail": "What the check found.", "why": "Why PostHog refused.", "fix": "What to change."} - mock_post.return_value = MagicMock(status_code=400, json=lambda: {"error": code, "code": code, **refusal}) + mock_post.return_value = MagicMock( + status_code=400, json=lambda: {"error": code, "code": code, **pipeline_attr, **refusal} + ) url, body = self._send_request(addressed_by, self.body) response = self.client.post(url, body, format="json") From 0f434a33e9d120fcd0c28da1cf112bb993d56e70 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:52:35 +0000 Subject: [PATCH 6/6] fix(messaging): point a key send's sender refusal at the from it names A send by `template.key` can now name `from`. When an older pipeline refuses it with `sender_not_verified` and no `attr`, the answer now points at `from`, as it does for content your code rendered and as the pipeline does for any send that names a sender. Co-Authored-By: Claude Opus 5.5 --- .../backend/services/transactional_email.py | 6 ++-- .../tests/api/test_transactional_email.py | 28 ++++++++++++------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/products/messaging/backend/services/transactional_email.py b/products/messaging/backend/services/transactional_email.py index 25156b48ea84..62c657c318f0 100644 --- a/products/messaging/backend/services/transactional_email.py +++ b/products/messaging/backend/services/transactional_email.py @@ -399,7 +399,7 @@ def send_email(command: EmailSendCommand) -> EmailSendOutcome: SEND_CHECK_ATTR = {"invalid_recipient": "to", "recipient_suppressed": "to"} -CODE_SEND_CHECK_ATTR = {**SEND_CHECK_ATTR, "sender_not_verified": "from"} +NAMED_SENDER_CHECK_ATTR = {**SEND_CHECK_ATTR, "sender_not_verified": "from"} def _send_check_refusal(response: requests.Response, source: StoredTemplate | RenderedEmail) -> Rejected | None: @@ -419,8 +419,8 @@ def _send_check_attr(body: dict[str, Any], code: str, source: StoredTemplate | R match body: case {"attr": str() | None as attr}: return attr - attrs = CODE_SEND_CHECK_ATTR if isinstance(source, RenderedEmail) else SEND_CHECK_ATTR - return attrs.get(code) + names_sender = source.send is not None and source.send.sender is not None + return (NAMED_SENDER_CHECK_ATTR if names_sender else SEND_CHECK_ATTR).get(code) def _sendable_workflow_id(team_id: int, source: StoredTemplate | RenderedEmail) -> UUID: diff --git a/products/messaging/backend/tests/api/test_transactional_email.py b/products/messaging/backend/tests/api/test_transactional_email.py index e5ae6c0eb9c4..d52f686a718e 100644 --- a/products/messaging/backend/tests/api/test_transactional_email.py +++ b/products/messaging/backend/tests/api/test_transactional_email.py @@ -608,18 +608,26 @@ def test_reports_a_send_the_pipeline_refuses( @parameterized.expand( [ - ("invalid_recipient", "template_key", "invalid_recipient", {}, "to"), - ("recipient_suppressed", "template_key", "recipient_suppressed", {}, "to"), - ("template_paused", "key", "template_paused", {}, None), - ("sending_suspended", "id", "sending_suspended", {}, None), - ("sender_not_verified_on_a_template_send", "key", "sender_not_verified", {}, None), - ("sender_not_verified_on_a_code_send", "template_key", "sender_not_verified", {}, "from"), - ("a_check_this_server_does_not_know_yet", "template_key", "recipient_on_hold", {}, None), - ("a_suppressed_stored_copy", "template_key", "recipient_suppressed", {"attr": None}, None), + ("invalid_recipient", "template_key", {}, "invalid_recipient", {}, "to"), + ("recipient_suppressed", "template_key", {}, "recipient_suppressed", {}, "to"), + ("template_paused", "key", {}, "template_paused", {}, None), + ("sending_suspended", "id", {}, "sending_suspended", {}, None), + ("sender_not_verified_on_a_template_send", "key", {}, "sender_not_verified", {}, None), + ( + "sender_not_verified_on_a_key_send_that_names_from", + "key", + {"from": "security@example.com"}, + "sender_not_verified", + {}, + "from", + ), + ("sender_not_verified_on_a_code_send", "template_key", {}, "sender_not_verified", {}, "from"), + ("a_check_this_server_does_not_know_yet", "template_key", {}, "recipient_on_hold", {}, None), + ("a_suppressed_stored_copy", "template_key", {}, "recipient_suppressed", {"attr": None}, None), ] ) def test_answers_a_send_the_checks_refuse_with_why_and_fix( - self, mock_post, _flag, capture, _name, addressed_by, code, pipeline_attr, attr + self, mock_post, _flag, capture, _name, addressed_by, fields, code, pipeline_attr, attr ): refusal = {"detail": "What the check found.", "why": "Why PostHog refused.", "fix": "What to change."} mock_post.return_value = MagicMock( @@ -627,7 +635,7 @@ def test_answers_a_send_the_checks_refuse_with_why_and_fix( ) url, body = self._send_request(addressed_by, self.body) - response = self.client.post(url, body, format="json") + response = self.client.post(url, {**body, **fields}, format="json") assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json() assert response.json() == {