From 23588b8512b677bd35d392fd25e161abc9a656ab Mon Sep 17 00:00:00 2001 From: Harley Alexander <43975092+mayteio@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:33:44 +0100 Subject: [PATCH 1/2] feat(messaging): store a sender and reply-to on transactional templates - Template content takes `from` (sender, custom name and address) and `replyTo`. Each publish copies them into the transactional workflow, and making a template transactional stores the sender it was made with. - A custom address must be one address of at most 254 characters on the sender's verified domain. The API rejects it on publish, and conversion rejects one left over from another sender. - A sender string or empty value, from blank templates, older templates or a destination's editor, means no sender, on reads and writes. - A sender without an integration ID is a 400. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 90d9a51d-beba-47ba-8f50-b351327676f9 --- .../backend/facade/transactional_email.py | 4 + .../presentation/views/message_templates.py | 72 +++++++ .../backend/services/transactional_email.py | 60 +++++- .../tests/api/test_message_templates.py | 29 ++- .../tests/api/test_transactional_email.py | 90 ++++++-- .../frontend/generated/api.schemas.ts | 16 ++ .../messaging/frontend/generated/api.zod.ts | 195 ++++++++++++++++++ .../workflows/backend/facade/contracts.py | 4 + .../backend/facade/transactional_emails.py | 2 + .../services/transactional_email_workflows.py | 17 +- services/mcp/src/api/generated.ts | 16 ++ .../mcp/src/generated/email_templates/api.ts | 66 ++++++ .../workflows-create-email-template.json | 24 +++ .../workflows-update-email-template.json | 24 +++ 14 files changed, 593 insertions(+), 26 deletions(-) diff --git a/products/messaging/backend/facade/transactional_email.py b/products/messaging/backend/facade/transactional_email.py index c8ee6c56d0ce..105a55325fae 100644 --- a/products/messaging/backend/facade/transactional_email.py +++ b/products/messaging/backend/facade/transactional_email.py @@ -2,14 +2,17 @@ from products.messaging.backend.facade.contracts import DeliveryUnavailable, QueuedEmail, TransactionalEmailError from products.messaging.backend.services.transactional_email import ( + SENDER_ADDRESS_MAX_LENGTH, check_transactional_update, lock_transactional_workflow_id, make_transactional, send_transactional_email, + sender_address_error, sync_transactional_workflow, ) __all__ = [ + "SENDER_ADDRESS_MAX_LENGTH", "DeliveryUnavailable", "QueuedEmail", "TransactionalEmailError", @@ -17,5 +20,6 @@ "lock_transactional_workflow_id", "make_transactional", "send_transactional_email", + "sender_address_error", "sync_transactional_workflow", ] diff --git a/products/messaging/backend/presentation/views/message_templates.py b/products/messaging/backend/presentation/views/message_templates.py index 69592fb4079e..a4c0d5785e39 100644 --- a/products/messaging/backend/presentation/views/message_templates.py +++ b/products/messaging/backend/presentation/views/message_templates.py @@ -29,6 +29,7 @@ from posthog.auth import ProjectSecretAPIKeyAuthentication from posthog.event_usage import groups, report_user_action from posthog.models import User +from posthog.models.integration import Integration from posthog.models.tag import tagify from posthog.permissions import is_authenticated_via_project_secret_api_key from posthog.rate_limit import PersonalOrProjectSecretApiKeyRateThrottle, ProjectSecretApiKeyTeamRateThrottle @@ -54,12 +55,14 @@ update_template, ) from products.messaging.backend.facade.transactional_email import ( + SENDER_ADDRESS_MAX_LENGTH, DeliveryUnavailable, TransactionalEmailError, check_transactional_update, lock_transactional_workflow_id, make_transactional, send_transactional_email, + sender_address_error, sync_transactional_workflow, ) from products.messaging.backend.presentation.views.serializers import DesignOperationSerializer @@ -108,6 +111,51 @@ class UnlayerDesignField(serializers.JSONField): pass +class EmailTemplateSenderSerializer(serializers.Serializer): + email = serializers.CharField( + required=False, + allow_blank=True, + max_length=SENDER_ADDRESS_MAX_LENGTH, + help_text="Address to send from instead of the sender's own. Must be on the sender's verified domain. " + "Supports Liquid templating. Leave out or empty to use the sender's address.", + ) + name = serializers.CharField( + required=False, + allow_blank=True, + help_text="Sender name shown in the inbox instead of the sender's own. Supports Liquid templating. " + "Leave out or empty to use the sender's name.", + ) + + def get_fields(self) -> dict[str, serializers.Field]: + fields = super().get_fields() + # Keys match the workflow email step, which a transactional template's sender is copied into. + fields["integrationId"] = serializers.IntegerField( + help_text="ID of the project's email sender integration that sends this email. Transactional sends " + "need a verified sender." + ) + return fields + + def validate(self, data: dict[str, Any]) -> dict[str, Any]: + # A partial update doesn't enforce required nested fields, so the sender is checked here. + integration_id = data.get("integrationId") + if integration_id is None: + raise serializers.ValidationError({"integrationId": "Choose an email sender."}) + if not Integration.objects.filter(team_id=self.context["team_id"], kind="email", id=integration_id).exists(): + raise serializers.ValidationError({"integrationId": "No email sender with this ID in this project."}) + return data + + +def _without_legacy_sender(email: Any, *, reading: bool) -> Any: + # Only a transactional template stores a sender object. A blank template, a template saved before senders + # existed, and one saved from a destination's email editor hold a string or an empty object instead. + if not isinstance(email, dict) or "from" not in email: + return email + sender = email["from"] + # A write that names a sender but no integration is rejected later, so only reads drop it. + is_legacy = not isinstance(sender, dict) or not (sender.get("integrationId") if reading else any(sender.values())) + return {key: value for key, value in email.items() if key != "from"} if is_legacy else email + + class EmailTemplateSerializer(serializers.Serializer): subject = serializers.CharField( required=False, @@ -138,6 +186,27 @@ class EmailTemplateSerializer(serializers.Serializer): "Full schema in the designing-email-templates skill.", ) + def to_internal_value(self, data: Any) -> Any: + return super().to_internal_value(_without_legacy_sender(data, reading=False)) + + def to_representation(self, instance: Any) -> Any: + return super().to_representation(_without_legacy_sender(instance, reading=True)) + + def get_fields(self) -> dict[str, serializers.Field]: + fields = super().get_fields() + fields["from"] = EmailTemplateSenderSerializer( + required=False, + help_text="The sender a transactional template sends from. A template made transactional without " + "one gets the sender it was made transactional with.", + ) + fields["replyTo"] = serializers.CharField( + required=False, + allow_blank=True, + help_text="Reply-to address for a transactional template. Separate several addresses with commas. " + "Supports Liquid templating. Leave empty to use the sender's default reply-to.", + ) + return fields + class MessageTemplateContentSerializer(serializers.Serializer): templating = serializers.ChoiceField( @@ -255,6 +324,9 @@ def validate(self, data: Any) -> Any: raise serializers.ValidationError( {"content": {"email": {"subject": "Subject is required for email templates."}}} ) + # Checked on publish only, so a draft still saves while someone types the address. + if email and email.get("from") and (error := sender_address_error(self.context["team_id"], email["from"])): + raise serializers.ValidationError({"content": {"email": {"from": {"email": error}}}}) # Programmatically authored templates often supply html without a design, which the # visual editor can't open. Wrap the html in a single custom HTML block; the stored # html stays untouched, so nothing about the sent email changes. diff --git a/products/messaging/backend/services/transactional_email.py b/products/messaging/backend/services/transactional_email.py index 5463d675710a..d8a3943047b6 100644 --- a/products/messaging/backend/services/transactional_email.py +++ b/products/messaging/backend/services/transactional_email.py @@ -9,6 +9,7 @@ import structlog +from posthog.models.integration import Integration from posthog.models.team import Team from posthog.plugins.plugin_server_api import ( reload_hog_flows_on_workers, @@ -37,6 +38,7 @@ TransactionalEmailContentInvalid, TransactionalEmailSenderUnavailable, create_transactional_email_workflow, + resolve_transactional_email_sender, update_transactional_email_workflow, ) @@ -62,8 +64,42 @@ def check_sendable(team_id: int, *, template_type: str, content: dict, message_c ) -def _content(template: MessageTemplate, from_integration_id: int | None) -> TransactionalEmailContent: +# An address longer than this can't be delivered, and the bound keeps the pattern below fast. +SENDER_ADDRESS_MAX_LENGTH = 254 +# Matches the send path, which ignores a custom address that fails these checks and sends from the sender's own. +SENDER_ADDRESS_PATTERN = re.compile(r'^[^\s@"<>,;]+@[^\s@"<>,;]+\.[^\s@"<>,;]+$') + + +def sender_address_error(team_id: int, sender: dict[str, Any]) -> str | None: + """Why a custom sender address can't send, or None. A templated address is only known at send time.""" + address = (sender.get("email") or "").strip() + if not address or "{{" in address or "{%" in address: + return None + if len(address) > SENDER_ADDRESS_MAX_LENGTH: + return f"Use an address of at most {SENDER_ADDRESS_MAX_LENGTH} characters." + if not SENDER_ADDRESS_PATTERN.match(address): + return "Enter one email address, like hello@example.com." + config = ( + Integration.objects.filter(team_id=team_id, kind="email", id=sender.get("integrationId")) + .values_list("config", flat=True) + .first() + or {} + ) + domain = (config.get("domain") or (config.get("email") or "").partition("@")[2]).lower() + if domain and address.partition("@")[2].lower() != domain: + return f"Use an address on {domain}, the domain this sender is verified for." + return None + + +def _template_sender(email: dict[str, Any]) -> dict[str, Any]: + # Templates saved before senders existed hold an empty string here. + sender = email.get("from") + return sender if isinstance(sender, dict) else {} + + +def _content(template: MessageTemplate) -> TransactionalEmailContent: email = template.content["email"] + sender = _template_sender(email) return TransactionalEmailContent( message_template_id=template.id, name=template.name, @@ -71,9 +107,13 @@ def _content(template: MessageTemplate, from_integration_id: int | None) -> Tran preheader=email.get("preheader") or "", html=email.get("html") or "", text=email.get("text") or "", - from_integration_id=from_integration_id, + # Templates made transactional before they stored a sender keep their workflow's sender. + from_integration_id=sender.get("integrationId"), message_category_id=template.message_category_id, sensitive=template.sensitive, + from_email=sender.get("email") or "", + from_name=sender.get("name") or "", + reply_to=email.get("replyTo") or "", ) @@ -93,16 +133,26 @@ def make_transactional( content=template.content, message_category_id=template.message_category_id, ) + email = template.content["email"] + sender = _template_sender(email) try: + # The template keeps the sender it is made transactional with, so the editor shows it and later + # saves send from it. + sender_id = resolve_transactional_email_sender(team_id, from_integration_id or sender.get("integrationId")) + sender = {**sender, "integrationId": sender_id} + # A custom address set for one sender can't send from a sender on another domain. + if address_error := sender_address_error(team_id, sender): + raise TemplateNotSendable(address_error) + template.content = {**template.content, "email": {**email, "from": sender}} hog_flow_id = create_transactional_email_workflow( - team_id=team_id, user_id=user_id, content=_content(template, from_integration_id) + team_id=team_id, user_id=user_id, content=_content(template) ) except TransactionalEmailSenderUnavailable as e: raise SenderUnavailable( "No verified email sender. Verify a sending domain or pass from_integration_id." ) from e template.transactional_hog_flow_id = hog_flow_id - template.save(update_fields=["transactional_hog_flow_id", "updated_at"]) + template.save(update_fields=["content", "transactional_hog_flow_id", "updated_at"]) return hog_flow_id @@ -166,7 +216,7 @@ def sync_transactional_workflow(team_id: int, template_id: UUID, *, user_id: int team_id=team_id, user_id=user_id, hog_flow_id=hog_flow_id, - content=_content(template, from_integration_id=None), + content=_content(template), ) # The save already told the workers to reload, but before the caller's transaction committed, so a # worker can cache the old version. This reload runs once the new version is visible. diff --git a/products/messaging/backend/tests/api/test_message_templates.py b/products/messaging/backend/tests/api/test_message_templates.py index 326e1a3a17e8..f545bfc90b47 100644 --- a/products/messaging/backend/tests/api/test_message_templates.py +++ b/products/messaging/backend/tests/api/test_message_templates.py @@ -1,4 +1,5 @@ from datetime import UTC, datetime +from typing import Any from uuid import UUID from posthog.test.base import APIBaseTest @@ -130,7 +131,20 @@ def test_tags_only_update_leaves_the_template_row_alone(self): # A row save here would write back every column this request read, losing a concurrent content edit. assert MessageTemplate.objects.get(pk=self.message_template.id).updated_at == before - def test_retrieve_message_template(self): + @parameterized.expand( + [ + ("no_sender", None), + ("blank_sender", ""), + ("destination_sender", "Hedgebox "), + ("sender_without_an_id", {"name": "Hedgebox"}), + ] + ) + def test_retrieve_message_template(self, _name: str, stored_sender: Any) -> None: + if stored_sender is not None: + email = {**self.message_template.content["email"], "from": stored_sender} + MessageTemplate.objects.filter(id=self.message_template.id).update(content={"email": email}) + listed = self.client.get(f"/api/environments/{self.team.id}/messaging_templates/") + assert listed.status_code == status.HTTP_200_OK response = self.client.get(f"/api/environments/{self.team.id}/messaging_templates/{self.message_template.id}/") assert response.status_code == status.HTTP_200_OK @@ -184,13 +198,21 @@ def test_email_content_without_subject_fails(self, _name, method, detail, body): assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json() + @parameterized.expand( + [ + # The editor's blank template carries an empty sender. + ("blank_template", ""), + # A destination's "Save as new template" carries its sender string. + ("destination_sender", "Hedgebox "), + ] + ) @patch("products.messaging.backend.presentation.views.message_templates.report_user_action") - def test_create_email_template_with_subject_succeeds(self, mock_report): + def test_create_email_template_with_subject_succeeds(self, _name: str, sender: str, mock_report: Any) -> None: response = self.client.post( f"/api/environments/{self.team.id}/messaging_templates/", data={ "name": "Valid Template", - "content": {"email": {"subject": "Hello", "html": "

Hello

"}}, + "content": {"email": {"subject": "Hello", "html": "

Hello

", "from": sender}}, "type": "email", "sensitive": True, }, @@ -201,6 +223,7 @@ def test_create_email_template_with_subject_succeeds(self, mock_report): assert response.json()["sensitive"] is True assert MessageTemplate.objects.get(id=response.json()["id"]).sensitive is True assert response.json()["content"]["email"]["subject"] == "Hello" + assert "from" not in response.json()["content"]["email"] # The AI-first experiment counts creations from the editor and the agent with this one event. mock_report.assert_called_once() assert mock_report.call_args.args[1] == "message_template_created" diff --git a/products/messaging/backend/tests/api/test_transactional_email.py b/products/messaging/backend/tests/api/test_transactional_email.py index 7ab7b9b227dc..8479981cca75 100644 --- a/products/messaging/backend/tests/api/test_transactional_email.py +++ b/products/messaging/backend/tests/api/test_transactional_email.py @@ -289,6 +289,7 @@ def test_making_a_template_transactional_creates_its_workflow_once(self, mock_po assert first.status_code == status.HTTP_200_OK, first.json() hog_flow_id = first.json()["transactional_hog_flow_id"] assert second.json()["transactional_hog_flow_id"] == hog_flow_id + assert second.json()["content"]["email"]["from"] == {"integrationId": self.sender.id} workflow = self._workflow(hog_flow_id) assert workflow["status"] == "active" assert workflow["origin_product"] == "transactional_emails" @@ -303,12 +304,14 @@ def test_making_a_template_transactional_creates_its_workflow_once(self, mock_po ("template_without_subject", "template_not_sendable"), ("template_without_body", "template_not_sendable"), ("unverified_sender", "sender_unavailable"), + ("custom_address_off_the_chosen_senders_domain", "template_not_sendable"), ] ) def test_rejects_making_a_template_transactional_it_cannot_send( self, mock_post, _flag, _capture, case, expected_code ): template = self._template() + body: dict[str, Any] = {} if case in ("marketing_template", "deleted_marketing_category"): template.message_category = MessageCategory.objects.create( team=self.team, @@ -324,10 +327,22 @@ def test_rejects_making_a_template_transactional_it_cannot_send( elif case == "unverified_sender": self.sender.config = {"verified": False} self.sender.save() + elif case == "custom_address_off_the_chosen_senders_domain": + self.sender.config = {"verified": True, "email": "hello@example.com", "domain": "example.com"} + self.sender.save() + other = Integration.objects.create( + team=self.team, + kind="email", + integration_id="hello@example.org", + config={"verified": True, "email": "hello@example.org", "domain": "example.org"}, + ) + sender = {"integrationId": self.sender.id, "email": "security@example.com"} + template.content = {"email": {**template.content["email"], "from": sender}} + body = {"from_integration_id": other.id} template.save() response = self.client.post( - f"/api/projects/{self.team.id}/messaging_templates/{template.id}/make_transactional/", {}, format="json" + f"/api/projects/{self.team.id}/messaging_templates/{template.id}/make_transactional/", body, format="json" ) assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json() @@ -338,10 +353,20 @@ def test_rejects_making_a_template_transactional_it_cannot_send( @patch("products.messaging.backend.services.transactional_email.reload_hog_flows_on_workers") def test_saving_a_transactional_template_writes_a_new_workflow_version(self, reload, mock_post, _flag, _capture): MessageTemplate.objects.filter(id=self.template.id).update(draft_content={"email": {"subject": "Draft"}}) + security = Integration.objects.create( + team=self.team, kind="email", integration_id="security@example.com", config={"verified": True} + ) + email_content = { + "subject": "A new subject", + "html": "

New

", + # A templated address is only known at send time, so publish accepts it. + "from": {"integrationId": security.id, "name": "PostHog Security", "email": "{{ variables.alias }}"}, + "replyTo": "security@example.com", + } with self.captureOnCommitCallbacks(execute=True): response = self.client.patch( f"/api/projects/{self.team.id}/messaging_templates/{self.template.id}/", - {"content": {"email": {"subject": "A new subject", "html": "

New

"}}}, + {"content": {"email": email_content}}, format="json", ) @@ -352,6 +377,12 @@ def test_saving_a_transactional_template_writes_a_new_workflow_version(self, rel assert workflow["version"] == 2 email = workflow["actions"][1]["config"]["inputs"]["email"]["value"] assert (email["subject"], email["html"]) == ("A new subject", "

New

") + assert email["from"] == { + "integrationId": security.id, + "name": "PostHog Security", + "email": "{{ variables.alias }}", + } + assert email["replyTo"] == "security@example.com" assert response.json()["draft_content"] is None def test_saving_a_draft_leaves_the_sent_email_unchanged(self, mock_post, _flag, _capture): @@ -411,21 +442,56 @@ def test_tagging_a_transactional_template_leaves_its_workflow_untouched(self, mo after = self._workflow(self.hog_flow_id) assert (after["version"], after["updated_at"]) == (before["version"], before["updated_at"]) - def test_rejects_an_update_that_would_stop_a_transactional_template_sending(self, mock_post, _flag, _capture): - category = MessageCategory.objects.create( - team=self.team, key="news", name="News", category_type=MessageCategoryType.MARKETING - ) + @parameterized.expand( + [ + ("marketing_category", "marketing_template"), + ("unverified_sender", "sender_unavailable"), + ("another_projects_sender", "invalid_input"), + ("custom_address_off_the_senders_domain", "invalid_input"), + ("custom_address_that_is_not_an_address", "invalid_input"), + ("custom_address_too_long", "max_length"), + ("sender_without_an_id", "invalid_input"), + ] + ) + def test_rejects_an_update_that_would_stop_a_transactional_template_sending( + self, mock_post, _flag, _capture, case, expected_code + ): + before = MessageTemplate.objects.get(id=self.template.id) + if case == "marketing_category": + category = MessageCategory.objects.create( + team=self.team, key="news", name="News", category_type=MessageCategoryType.MARKETING + ) + body: dict[str, Any] = {"message_category": str(category.id)} + elif case == "sender_without_an_id": + body = {"content": {"email": {**before.content["email"], "from": {"name": "Security"}}}} + elif case.startswith("custom_address"): + self.sender.config = {"verified": True, "email": "hello@example.com", "domain": "example.com"} + self.sender.save() + address = { + "custom_address_off_the_senders_domain": "security@example.org", + "custom_address_that_is_not_an_address": "Security team", + "custom_address_too_long": f"{'a' * 250}@example.com", + }[case] + email = {**before.content["email"], "from": {"integrationId": self.sender.id, "email": address}} + body = {"content": {"email": email}} + else: + other_team = ( + self.team if case == "unverified_sender" else self.create_team_with_organization(self.organization) + ) + sender = Integration.objects.create( + team=other_team, kind="email", integration_id="other@example.com", config={"verified": False} + ) + email = {**before.content["email"], "from": {"integrationId": sender.id}} + body = {"content": {"email": email}} response = self.client.patch( - f"/api/projects/{self.team.id}/messaging_templates/{self.template.id}/", - {"message_category": str(category.id)}, - format="json", + f"/api/projects/{self.team.id}/messaging_templates/{self.template.id}/", body, format="json" ) assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json() - assert response.json()["code"] == "marketing_template" - self.template.refresh_from_db() - assert self.template.message_category_id is None + assert response.json()["code"] == expected_code + after = MessageTemplate.objects.get(id=self.template.id) + assert (after.message_category_id, after.content) == (None, before.content) assert self._workflow(self.hog_flow_id)["version"] == 1 diff --git a/products/messaging/frontend/generated/api.schemas.ts b/products/messaging/frontend/generated/api.schemas.ts index 879c33567d85..5381b7626d80 100644 --- a/products/messaging/frontend/generated/api.schemas.ts +++ b/products/messaging/frontend/generated/api.schemas.ts @@ -254,6 +254,18 @@ export const MessageTemplateContentTemplatingEnumApi = { Liquid: 'liquid', } as const +export interface EmailTemplateSenderApi { + /** + * Address to send from instead of the sender's own. Must be on the sender's verified domain. Supports Liquid templating. Leave out or empty to use the sender's address. + * @maxLength 254 + */ + email?: string + /** Sender name shown in the inbox instead of the sender's own. Supports Liquid templating. Leave out or empty to use the sender's name. */ + name?: string + /** ID of the project's email sender integration that sends this email. Transactional sends need a verified sender. */ + integrationId: number +} + /** * Highest htmlID suffix per element type, e.g. {"u_row": 1, "u_content_text": 2}. */ @@ -303,6 +315,10 @@ export interface EmailTemplateApi { html?: string /** Design JSON for PostHog's visual email editor — the authoring surface and source of truth. The server renders the sent email from it, and it opens as editable blocks in the editor. Full schema in the designing-email-templates skill. */ design?: EmailTemplateApiDesign + /** The sender a transactional template sends from. A template made transactional without one gets the sender it was made transactional with. */ + from?: EmailTemplateSenderApi + /** Reply-to address for a transactional template. Separate several addresses with commas. Supports Liquid templating. Leave empty to use the sender's default reply-to. */ + replyTo?: string } export interface MessageTemplateContentApi { diff --git a/products/messaging/frontend/generated/api.zod.ts b/products/messaging/frontend/generated/api.zod.ts index 6adc370fab50..bcbdb2664678 100644 --- a/products/messaging/frontend/generated/api.zod.ts +++ b/products/messaging/frontend/generated/api.zod.ts @@ -258,9 +258,12 @@ export const messagingTemplatesCreateBodyTagsItemMax = 255 export const messagingTemplatesCreateBodyTagsMax = 10 export const messagingTemplatesCreateBodyContentOneTemplatingDefault = `liquid` +export const messagingTemplatesCreateBodyContentOneEmailOneFromOneEmailMax = 254 + export const messagingTemplatesCreateBodyTypeMax = 24 export const messagingTemplatesCreateBodyDraftContentOneTemplatingDefault = `liquid` +export const messagingTemplatesCreateBodyDraftContentOneEmailOneFromOneEmailMax = 254 export const MessagingTemplatesCreateBody = /* @__PURE__ */ zod.object({ name: zod @@ -339,6 +342,37 @@ export const MessagingTemplatesCreateBody = /* @__PURE__ */ zod.object({ .describe( "Design JSON for PostHog's visual email editor — the authoring surface and source of truth. The server renders the sent email from it, and it opens as editable blocks in the editor. Full schema in the designing-email-templates skill." ), + from: zod + .object({ + email: zod + .string() + .max(messagingTemplatesCreateBodyContentOneEmailOneFromOneEmailMax) + .optional() + .describe( + "Address to send from instead of the sender's own. Must be on the sender's verified domain. Supports Liquid templating. Leave out or empty to use the sender's address." + ), + name: zod + .string() + .optional() + .describe( + "Sender name shown in the inbox instead of the sender's own. Supports Liquid templating. Leave out or empty to use the sender's name." + ), + integrationId: zod + .number() + .describe( + "ID of the project's email sender integration that sends this email. Transactional sends need a verified sender." + ), + }) + .optional() + .describe( + 'The sender a transactional template sends from. A template made transactional without one gets the sender it was made transactional with.' + ), + replyTo: zod + .string() + .optional() + .describe( + "Reply-to address for a transactional template. Separate several addresses with commas. Supports Liquid templating. Leave empty to use the sender's default reply-to." + ), }), zod.null(), ]) @@ -428,6 +462,37 @@ export const MessagingTemplatesCreateBody = /* @__PURE__ */ zod.object({ .describe( "Design JSON for PostHog's visual email editor — the authoring surface and source of truth. The server renders the sent email from it, and it opens as editable blocks in the editor. Full schema in the designing-email-templates skill." ), + from: zod + .object({ + email: zod + .string() + .max(messagingTemplatesCreateBodyDraftContentOneEmailOneFromOneEmailMax) + .optional() + .describe( + "Address to send from instead of the sender's own. Must be on the sender's verified domain. Supports Liquid templating. Leave out or empty to use the sender's address." + ), + name: zod + .string() + .optional() + .describe( + "Sender name shown in the inbox instead of the sender's own. Supports Liquid templating. Leave out or empty to use the sender's name." + ), + integrationId: zod + .number() + .describe( + "ID of the project's email sender integration that sends this email. Transactional sends need a verified sender." + ), + }) + .optional() + .describe( + 'The sender a transactional template sends from. A template made transactional without one gets the sender it was made transactional with.' + ), + replyTo: zod + .string() + .optional() + .describe( + "Reply-to address for a transactional template. Separate several addresses with commas. Supports Liquid templating. Leave empty to use the sender's default reply-to." + ), }), zod.null(), ]) @@ -449,9 +514,12 @@ export const messagingTemplatesUpdateBodyTagsItemMax = 255 export const messagingTemplatesUpdateBodyTagsMax = 10 export const messagingTemplatesUpdateBodyContentOneTemplatingDefault = `liquid` +export const messagingTemplatesUpdateBodyContentOneEmailOneFromOneEmailMax = 254 + export const messagingTemplatesUpdateBodyTypeMax = 24 export const messagingTemplatesUpdateBodyDraftContentOneTemplatingDefault = `liquid` +export const messagingTemplatesUpdateBodyDraftContentOneEmailOneFromOneEmailMax = 254 export const MessagingTemplatesUpdateBody = /* @__PURE__ */ zod.object({ name: zod @@ -530,6 +598,37 @@ export const MessagingTemplatesUpdateBody = /* @__PURE__ */ zod.object({ .describe( "Design JSON for PostHog's visual email editor — the authoring surface and source of truth. The server renders the sent email from it, and it opens as editable blocks in the editor. Full schema in the designing-email-templates skill." ), + from: zod + .object({ + email: zod + .string() + .max(messagingTemplatesUpdateBodyContentOneEmailOneFromOneEmailMax) + .optional() + .describe( + "Address to send from instead of the sender's own. Must be on the sender's verified domain. Supports Liquid templating. Leave out or empty to use the sender's address." + ), + name: zod + .string() + .optional() + .describe( + "Sender name shown in the inbox instead of the sender's own. Supports Liquid templating. Leave out or empty to use the sender's name." + ), + integrationId: zod + .number() + .describe( + "ID of the project's email sender integration that sends this email. Transactional sends need a verified sender." + ), + }) + .optional() + .describe( + 'The sender a transactional template sends from. A template made transactional without one gets the sender it was made transactional with.' + ), + replyTo: zod + .string() + .optional() + .describe( + "Reply-to address for a transactional template. Separate several addresses with commas. Supports Liquid templating. Leave empty to use the sender's default reply-to." + ), }), zod.null(), ]) @@ -619,6 +718,37 @@ export const MessagingTemplatesUpdateBody = /* @__PURE__ */ zod.object({ .describe( "Design JSON for PostHog's visual email editor — the authoring surface and source of truth. The server renders the sent email from it, and it opens as editable blocks in the editor. Full schema in the designing-email-templates skill." ), + from: zod + .object({ + email: zod + .string() + .max(messagingTemplatesUpdateBodyDraftContentOneEmailOneFromOneEmailMax) + .optional() + .describe( + "Address to send from instead of the sender's own. Must be on the sender's verified domain. Supports Liquid templating. Leave out or empty to use the sender's address." + ), + name: zod + .string() + .optional() + .describe( + "Sender name shown in the inbox instead of the sender's own. Supports Liquid templating. Leave out or empty to use the sender's name." + ), + integrationId: zod + .number() + .describe( + "ID of the project's email sender integration that sends this email. Transactional sends need a verified sender." + ), + }) + .optional() + .describe( + 'The sender a transactional template sends from. A template made transactional without one gets the sender it was made transactional with.' + ), + replyTo: zod + .string() + .optional() + .describe( + "Reply-to address for a transactional template. Separate several addresses with commas. Supports Liquid templating. Leave empty to use the sender's default reply-to." + ), }), zod.null(), ]) @@ -640,9 +770,12 @@ export const messagingTemplatesPartialUpdateBodyTagsItemMax = 255 export const messagingTemplatesPartialUpdateBodyTagsMax = 10 export const messagingTemplatesPartialUpdateBodyContentOneTemplatingDefault = `liquid` +export const messagingTemplatesPartialUpdateBodyContentOneEmailOneFromOneEmailMax = 254 + export const messagingTemplatesPartialUpdateBodyTypeMax = 24 export const messagingTemplatesPartialUpdateBodyDraftContentOneTemplatingDefault = `liquid` +export const messagingTemplatesPartialUpdateBodyDraftContentOneEmailOneFromOneEmailMax = 254 export const MessagingTemplatesPartialUpdateBody = /* @__PURE__ */ zod.object({ name: zod @@ -722,6 +855,37 @@ export const MessagingTemplatesPartialUpdateBody = /* @__PURE__ */ zod.object({ .describe( "Design JSON for PostHog's visual email editor — the authoring surface and source of truth. The server renders the sent email from it, and it opens as editable blocks in the editor. Full schema in the designing-email-templates skill." ), + from: zod + .object({ + email: zod + .string() + .max(messagingTemplatesPartialUpdateBodyContentOneEmailOneFromOneEmailMax) + .optional() + .describe( + "Address to send from instead of the sender's own. Must be on the sender's verified domain. Supports Liquid templating. Leave out or empty to use the sender's address." + ), + name: zod + .string() + .optional() + .describe( + "Sender name shown in the inbox instead of the sender's own. Supports Liquid templating. Leave out or empty to use the sender's name." + ), + integrationId: zod + .number() + .describe( + "ID of the project's email sender integration that sends this email. Transactional sends need a verified sender." + ), + }) + .optional() + .describe( + 'The sender a transactional template sends from. A template made transactional without one gets the sender it was made transactional with.' + ), + replyTo: zod + .string() + .optional() + .describe( + "Reply-to address for a transactional template. Separate several addresses with commas. Supports Liquid templating. Leave empty to use the sender's default reply-to." + ), }), zod.null(), ]) @@ -811,6 +975,37 @@ export const MessagingTemplatesPartialUpdateBody = /* @__PURE__ */ zod.object({ .describe( "Design JSON for PostHog's visual email editor — the authoring surface and source of truth. The server renders the sent email from it, and it opens as editable blocks in the editor. Full schema in the designing-email-templates skill." ), + from: zod + .object({ + email: zod + .string() + .max(messagingTemplatesPartialUpdateBodyDraftContentOneEmailOneFromOneEmailMax) + .optional() + .describe( + "Address to send from instead of the sender's own. Must be on the sender's verified domain. Supports Liquid templating. Leave out or empty to use the sender's address." + ), + name: zod + .string() + .optional() + .describe( + "Sender name shown in the inbox instead of the sender's own. Supports Liquid templating. Leave out or empty to use the sender's name." + ), + integrationId: zod + .number() + .describe( + "ID of the project's email sender integration that sends this email. Transactional sends need a verified sender." + ), + }) + .optional() + .describe( + 'The sender a transactional template sends from. A template made transactional without one gets the sender it was made transactional with.' + ), + replyTo: zod + .string() + .optional() + .describe( + "Reply-to address for a transactional template. Separate several addresses with commas. Supports Liquid templating. Leave empty to use the sender's default reply-to." + ), }), zod.null(), ]) diff --git a/products/workflows/backend/facade/contracts.py b/products/workflows/backend/facade/contracts.py index 06c0a2860d4c..7a15184f6125 100644 --- a/products/workflows/backend/facade/contracts.py +++ b/products/workflows/backend/facade/contracts.py @@ -587,6 +587,10 @@ class TransactionalEmailContent: message_category_id: UUID | None # Sends keep a placeholder instead of the body, and clicks drop the link's query string. sensitive: bool = False + # Empty values use the sender's own address, name and default reply-to. + from_email: str = "" + from_name: str = "" + reply_to: str = "" class TransactionalEmailSenderUnavailable(Exception): diff --git a/products/workflows/backend/facade/transactional_emails.py b/products/workflows/backend/facade/transactional_emails.py index fd1036a81b88..7d8a8c4f4a47 100644 --- a/products/workflows/backend/facade/transactional_emails.py +++ b/products/workflows/backend/facade/transactional_emails.py @@ -8,6 +8,7 @@ ) from products.workflows.backend.services.transactional_email_workflows import ( create_transactional_email_workflow, + resolve_transactional_email_sender, update_transactional_email_workflow, ) @@ -17,5 +18,6 @@ "TransactionalEmailSenderUnavailable", "WorkflowNotFound", "create_transactional_email_workflow", + "resolve_transactional_email_sender", "update_transactional_email_workflow", ] diff --git a/products/workflows/backend/services/transactional_email_workflows.py b/products/workflows/backend/services/transactional_email_workflows.py index e9e6ef1b8483..733ea49e5649 100644 --- a/products/workflows/backend/services/transactional_email_workflows.py +++ b/products/workflows/backend/services/transactional_email_workflows.py @@ -60,8 +60,12 @@ def transactional_email_actions(content: TransactionalEmailContent, from_integra "templating": "liquid", "value": { "to": {"email": RECIPIENT_EXPRESSION, "name": ""}, - "from": {"integrationId": from_integration_id}, - "replyTo": "", + "from": { + "integrationId": from_integration_id, + **({"email": content.from_email} if content.from_email else {}), + **({"name": content.from_name} if content.from_name else {}), + }, + "replyTo": content.reply_to, "cc": "", "bcc": "", "subject": content.subject, @@ -100,7 +104,8 @@ def _content_fields(content: TransactionalEmailContent, from_integration_id: int } -def _verified_sender(team_id: int, requested_id: int | None) -> int: +def resolve_transactional_email_sender(team_id: int, requested_id: int | None) -> int: + """The requested sender, or the project's default when none is requested. Raise when it isn't verified.""" sender_id = requested_id or ( TeamWorkflowsConfig.objects.filter(team_id=team_id) .values_list("default_email_integration_id", flat=True) @@ -125,7 +130,7 @@ def _current_sender(flow: HogFlow) -> int | None: def create_transactional_email_workflow( *, team_id: int, user_id: Optional[int], content: TransactionalEmailContent ) -> UUID: - from_integration_id = _verified_sender(team_id, content.from_integration_id) + from_integration_id = resolve_transactional_email_sender(team_id, content.from_integration_id) result = create_workflow( team_id=team_id, user_id=user_id, @@ -154,9 +159,9 @@ def update_transactional_email_workflow( if flow is None: raise WorkflowNotFound() if content.from_integration_id: - from_integration_id = _verified_sender(team_id, content.from_integration_id) + from_integration_id = resolve_transactional_email_sender(team_id, content.from_integration_id) else: - from_integration_id = _current_sender(flow) or _verified_sender(team_id, None) + from_integration_id = _current_sender(flow) or resolve_transactional_email_sender(team_id, None) update_workflow( team_id=team_id, user_id=user_id, diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index da5bcd525aad..439bb766043d 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -39442,6 +39442,18 @@ export namespace Schemas { body: EmailTemplateDesignBody; }; + export interface EmailTemplateSender { + /** + * Address to send from instead of the sender's own. Must be on the sender's verified domain. Supports Liquid templating. Leave out or empty to use the sender's address. + * @maxLength 254 + */ + email?: string; + /** Sender name shown in the inbox instead of the sender's own. Supports Liquid templating. Leave out or empty to use the sender's name. */ + name?: string; + /** ID of the project's email sender integration that sends this email. Transactional sends need a verified sender. */ + integrationId: number; + } + export interface EmailTemplate { /** Email subject line. Supports Liquid templating. Required in `content` for email-type templates. A `draft_content` may leave it blank until it is published. */ subject?: string; @@ -39453,6 +39465,10 @@ export namespace Schemas { html?: string; /** Design JSON for PostHog's visual email editor — the authoring surface and source of truth. The server renders the sent email from it, and it opens as editable blocks in the editor. Full schema in the designing-email-templates skill. */ design?: EmailTemplateDesign; + /** The sender a transactional template sends from. A template made transactional without one gets the sender it was made transactional with. */ + from?: EmailTemplateSender; + /** Reply-to address for a transactional template. Separate several addresses with commas. Supports Liquid templating. Leave empty to use the sender's default reply-to. */ + replyTo?: string; } /** diff --git a/services/mcp/src/generated/email_templates/api.ts b/services/mcp/src/generated/email_templates/api.ts index 12d6a3c232fa..74397267a1a2 100644 --- a/services/mcp/src/generated/email_templates/api.ts +++ b/services/mcp/src/generated/email_templates/api.ts @@ -36,6 +36,8 @@ export const messagingTemplatesCreateBodyTagsItemMax = 255 export const messagingTemplatesCreateBodyTagsMax = 10 export const messagingTemplatesCreateBodyContentOneTemplatingDefault = `liquid` +export const messagingTemplatesCreateBodyContentOneEmailOneFromOneEmailMax = 254 + export const messagingTemplatesCreateBodyTypeMax = 24 export const MessagingTemplatesCreateBody = () => zod.object({ @@ -109,6 +111,37 @@ export const MessagingTemplatesCreateBody = () => zod.object({ .describe( "Design JSON for PostHog's visual email editor — the authoring surface and source of truth. The server renders the sent email from it, and it opens as editable blocks in the editor. Full schema in the designing-email-templates skill." ), + from: zod + .object({ + email: zod + .string() + .max(messagingTemplatesCreateBodyContentOneEmailOneFromOneEmailMax) + .optional() + .describe( + "Address to send from instead of the sender's own. Must be on the sender's verified domain. Supports Liquid templating. Leave out or empty to use the sender's address." + ), + name: zod + .string() + .optional() + .describe( + "Sender name shown in the inbox instead of the sender's own. Supports Liquid templating. Leave out or empty to use the sender's name." + ), + integrationId: zod + .number() + .describe( + "ID of the project's email sender integration that sends this email. Transactional sends need a verified sender." + ), + }) + .optional() + .describe( + 'The sender a transactional template sends from. A template made transactional without one gets the sender it was made transactional with.' + ), + replyTo: zod + .string() + .optional() + .describe( + "Reply-to address for a transactional template. Separate several addresses with commas. Supports Liquid templating. Leave empty to use the sender's default reply-to." + ), }), zod.null(), ]) @@ -160,6 +193,8 @@ export const messagingTemplatesPartialUpdateBodyTagsItemMax = 255 export const messagingTemplatesPartialUpdateBodyTagsMax = 10 export const messagingTemplatesPartialUpdateBodyContentOneTemplatingDefault = `liquid` +export const messagingTemplatesPartialUpdateBodyContentOneEmailOneFromOneEmailMax = 254 + export const messagingTemplatesPartialUpdateBodyTypeMax = 24 export const MessagingTemplatesPartialUpdateBody = () => zod.object({ @@ -234,6 +269,37 @@ export const MessagingTemplatesPartialUpdateBody = () => zod.object({ .describe( "Design JSON for PostHog's visual email editor — the authoring surface and source of truth. The server renders the sent email from it, and it opens as editable blocks in the editor. Full schema in the designing-email-templates skill." ), + from: zod + .object({ + email: zod + .string() + .max(messagingTemplatesPartialUpdateBodyContentOneEmailOneFromOneEmailMax) + .optional() + .describe( + "Address to send from instead of the sender's own. Must be on the sender's verified domain. Supports Liquid templating. Leave out or empty to use the sender's address." + ), + name: zod + .string() + .optional() + .describe( + "Sender name shown in the inbox instead of the sender's own. Supports Liquid templating. Leave out or empty to use the sender's name." + ), + integrationId: zod + .number() + .describe( + "ID of the project's email sender integration that sends this email. Transactional sends need a verified sender." + ), + }) + .optional() + .describe( + 'The sender a transactional template sends from. A template made transactional without one gets the sender it was made transactional with.' + ), + replyTo: zod + .string() + .optional() + .describe( + "Reply-to address for a transactional template. Separate several addresses with commas. Supports Liquid templating. Leave empty to use the sender's default reply-to." + ), }), zod.null(), ]) diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-create-email-template.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-create-email-template.json index 49cea65ca000..981ecde88d3e 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-create-email-template.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-create-email-template.json @@ -66,10 +66,34 @@ "required": ["schemaVersion", "body"], "type": "object" }, + "from": { + "description": "The sender a transactional template sends from. A template made transactional without one gets the sender it was made transactional with.", + "properties": { + "email": { + "description": "Address to send from instead of the sender's own. Must be on the sender's verified domain. Supports Liquid templating. Leave out or empty to use the sender's address.", + "maxLength": 254, + "type": "string" + }, + "integrationId": { + "description": "ID of the project's email sender integration that sends this email. Transactional sends need a verified sender.", + "type": "number" + }, + "name": { + "description": "Sender name shown in the inbox instead of the sender's own. Supports Liquid templating. Leave out or empty to use the sender's name.", + "type": "string" + } + }, + "required": ["integrationId"], + "type": "object" + }, "preheader": { "description": "Preview text that most inboxes show after the subject. Supports Liquid templating.", "type": "string" }, + "replyTo": { + "description": "Reply-to address for a transactional template. Separate several addresses with commas. Supports Liquid templating. Leave empty to use the sender's default reply-to.", + "type": "string" + }, "subject": { "description": "Email subject line. Supports Liquid templating. Required in `content` for email-type templates. A `draft_content` may leave it blank until it is published.", "type": "string" diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-update-email-template.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-update-email-template.json index 2182f75acc70..aa998c3d50e8 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-update-email-template.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-update-email-template.json @@ -66,10 +66,34 @@ "required": ["schemaVersion", "body"], "type": "object" }, + "from": { + "description": "The sender a transactional template sends from. A template made transactional without one gets the sender it was made transactional with.", + "properties": { + "email": { + "description": "Address to send from instead of the sender's own. Must be on the sender's verified domain. Supports Liquid templating. Leave out or empty to use the sender's address.", + "maxLength": 254, + "type": "string" + }, + "integrationId": { + "description": "ID of the project's email sender integration that sends this email. Transactional sends need a verified sender.", + "type": "number" + }, + "name": { + "description": "Sender name shown in the inbox instead of the sender's own. Supports Liquid templating. Leave out or empty to use the sender's name.", + "type": "string" + } + }, + "required": ["integrationId"], + "type": "object" + }, "preheader": { "description": "Preview text that most inboxes show after the subject. Supports Liquid templating.", "type": "string" }, + "replyTo": { + "description": "Reply-to address for a transactional template. Separate several addresses with commas. Supports Liquid templating. Leave empty to use the sender's default reply-to.", + "type": "string" + }, "subject": { "description": "Email subject line. Supports Liquid templating. Required in `content` for email-type templates. A `draft_content` may leave it blank until it is published.", "type": "string" From f799de5058371dea47b0b212195a369886df40c5 Mon Sep 17 00:00:00 2001 From: Harley Alexander <43975092+mayteio@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:01:22 +0100 Subject: [PATCH 2/2] fix(messaging): skip the sender address check when no sender is set mypy flagged a possibly missing integration ID in the sender lookup. Without a sender there is no domain to check against, and the sender field already rejects that case. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 90d9a51d-beba-47ba-8f50-b351327676f9 --- products/messaging/backend/services/transactional_email.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/products/messaging/backend/services/transactional_email.py b/products/messaging/backend/services/transactional_email.py index d8a3943047b6..b322c5f2e3f7 100644 --- a/products/messaging/backend/services/transactional_email.py +++ b/products/messaging/backend/services/transactional_email.py @@ -79,8 +79,12 @@ def sender_address_error(team_id: int, sender: dict[str, Any]) -> str | None: return f"Use an address of at most {SENDER_ADDRESS_MAX_LENGTH} characters." if not SENDER_ADDRESS_PATTERN.match(address): return "Enter one email address, like hello@example.com." + integration_id = sender.get("integrationId") + # Without a sender there is no domain to check against. The sender field rejects that case on its own. + if integration_id is None: + return None config = ( - Integration.objects.filter(team_id=team_id, kind="email", id=sender.get("integrationId")) + Integration.objects.filter(team_id=team_id, kind="email", id=integration_id) .values_list("config", flat=True) .first() or {}