Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions products/messaging/backend/facade/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ class SenderNotVerified(TransactionalEmailError):
code = "sender_not_verified"


class SenderNotAllowed(TransactionalEmailError):
code = "sender_not_allowed"


@frozen
class RenderedContent:
subject: str
Expand Down Expand Up @@ -121,6 +125,9 @@ class Rejected:

code: str
detail: str
why: str | None = None
fix: str | None = None
attr: str | None = None


@frozen
Expand Down
2 changes: 2 additions & 0 deletions products/messaging/backend/facade/transactional_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
Rejected,
RenderedContent,
RenderedEmail,
SenderNotAllowed,
SenderNotVerified,
SendFrom,
StoredTemplate,
Expand Down Expand Up @@ -36,6 +37,7 @@
"RenderedContent",
"RenderedEmail",
"SendFrom",
"SenderNotAllowed",
"SenderNotVerified",
"StoredTemplate",
"TransactionalEmailError",
Expand Down
37 changes: 26 additions & 11 deletions products/messaging/backend/presentation/views/messaging_send.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
AcceptedSend,
EmailSendCommand,
RenderedEmail,
SenderNotAllowed,
SenderNotVerified,
code_send_from,
rendered_content,
Expand Down Expand Up @@ -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.",
)


Expand All @@ -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`, when the answer has them, say "
"what to change. Nothing was queued",
),
404: OpenApiResponse(
response=TemplateErrorSerializer,
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -156,6 +157,14 @@ def __init__(self, code: str, detail: str) -> None:
super().__init__(detail=detail, code=code)


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."
Expand Down Expand Up @@ -229,6 +238,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)

Expand Down Expand Up @@ -308,6 +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, 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():
Expand Down
34 changes: 33 additions & 1 deletion products/messaging/backend/services/transactional_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
Rejected,
RenderedContent,
RenderedEmail,
SenderNotAllowed,
SenderNotVerified,
SenderUnavailable,
SendFrom,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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, command.source))
):
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:
Expand All @@ -391,6 +398,31 @@ def send_email(command: EmailSendCommand) -> EmailSendOutcome:
return Queued(message_id=str(body["invocation_id"]), replayed=bool(body.get("replayed", False)))


SEND_CHECK_ATTR = {"invalid_recipient": "to", "recipient_suppressed": "to"}
NAMED_SENDER_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:
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, 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
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:
match source:
case StoredTemplate(template_id=template_id):
Expand Down
79 changes: 77 additions & 2 deletions products/messaging/backend/tests/api/test_transactional_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ def test_rejects_a_send_it_cannot_make(
"unverified_sender_with_template_key",
{"template": {"key": "password-reset"}, "from": "Example <security@example.org>"},
status.HTTP_400_BAD_REQUEST,
"sender_not_verified",
"sender_not_allowed",
"from",
),
]
Expand Down Expand Up @@ -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(
[
Expand All @@ -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)),
),
]
]
)
Expand Down Expand Up @@ -597,6 +606,59 @@ 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_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, fields, 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, **pipeline_attr, **refusal}
)
url, body = self._send_request(addressed_by, self.body)

response = self.client.post(url, {**body, **fields}, 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)
Expand Down Expand Up @@ -759,7 +821,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 <security@example.org>"}, "sender_not_verified", "from"),
("unverified_domain", {"from": "Example <security@example.org>"}, "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"),
(
Expand Down Expand Up @@ -790,6 +852,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")

Expand Down
5 changes: 3 additions & 2 deletions products/workflows/mcp/email_templates.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading