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..b322c5f2e3f7 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,46 @@ 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." + 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=integration_id) + .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 +111,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 +137,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 +220,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"