From a5dbb10460bcc9f8cebdaab46aef793d3ed0866a Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:42:29 +0000 Subject: [PATCH 01/30] feat(messaging): add key, owner, source and hash columns to templates Seven nullable columns on the message template, and a partial unique index on (team, key) for templates that are not deleted. The index is built concurrently in its own non-atomic migration. Nothing reads or writes the columns yet. Co-Authored-By: Claude Opus 5.5 --- ...007_messagetemplate_code_managed_fields.py | 82 +++++++++++++++++++ ...008_messagetemplate_unique_key_per_team.py | 35 ++++++++ .../backend/migrations/max_migration.txt | 2 +- .../backend/models/message_template.py | 46 +++++++++++ .../tests/test_message_template_key.py | 34 ++++++++ 5 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 products/messaging/backend/migrations/0007_messagetemplate_code_managed_fields.py create mode 100644 products/messaging/backend/migrations/0008_messagetemplate_unique_key_per_team.py create mode 100644 products/messaging/backend/tests/test_message_template_key.py diff --git a/products/messaging/backend/migrations/0007_messagetemplate_code_managed_fields.py b/products/messaging/backend/migrations/0007_messagetemplate_code_managed_fields.py new file mode 100644 index 000000000000..29e1eab9ad10 --- /dev/null +++ b/products/messaging/backend/migrations/0007_messagetemplate_code_managed_fields.py @@ -0,0 +1,82 @@ +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("messaging", "0006_messagetemplate_transactional_hog_flow_id"), + ] + + operations = [ + migrations.AddField( + model_name="messagetemplate", + name="content_hash", + field=models.CharField( + blank=True, + help_text="Hash of the published subject, preheader, HTML and text, as `v1:` and 64 hex characters.", + max_length=80, + null=True, + ), + ), + migrations.AddField( + model_name="messagetemplate", + name="key", + field=models.CharField( + blank=True, + help_text="Client-chosen identifier, unique within this environment among templates that are not deleted. Letters, numbers, hyphens (-) and underscores (_) only.", + max_length=400, + null=True, + ), + ), + migrations.AddField( + model_name="messagetemplate", + name="managed_by", + field=models.CharField( + blank=True, + choices=[("gui", "GUI"), ("code", "Code")], + help_text="Who owns the template content: `gui` for the PostHog app, `code` for uploads from a repository. Empty means `gui`.", + max_length=400, + null=True, + ), + ), + migrations.AddField( + model_name="messagetemplate", + name="source_path", + field=models.CharField( + blank=True, + help_text="Path of the template file in the source repository.", + max_length=400, + null=True, + ), + ), + migrations.AddField( + model_name="messagetemplate", + name="source_ref", + field=models.CharField( + blank=True, + help_text="Commit or branch of the last upload.", + max_length=400, + null=True, + ), + ), + migrations.AddField( + model_name="messagetemplate", + name="source_repository", + field=models.CharField( + blank=True, + help_text="Repository the template was last uploaded from, such as `github.com/example/app`.", + max_length=400, + null=True, + ), + ), + migrations.AddField( + model_name="messagetemplate", + name="superseded_content_hashes", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=80), + blank=True, + null=True, + size=None, + ), + ), + ] diff --git a/products/messaging/backend/migrations/0008_messagetemplate_unique_key_per_team.py b/products/messaging/backend/migrations/0008_messagetemplate_unique_key_per_team.py new file mode 100644 index 000000000000..24f5165ca319 --- /dev/null +++ b/products/messaging/backend/migrations/0008_messagetemplate_unique_key_per_team.py @@ -0,0 +1,35 @@ +from django.db import migrations, models + +from posthog.migration_helpers import CreateIndexConcurrently + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("messaging", "0007_messagetemplate_code_managed_fields"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.AddConstraint( + model_name="messagetemplate", + constraint=models.UniqueConstraint( + fields=("team", "key"), + condition=models.Q(("deleted", False), ("key__isnull", False)), + name="unique_message_template_key_per_team", + ), + ), + ], + database_operations=[ + CreateIndexConcurrently( + index_name="unique_message_template_key_per_team", + table_name="posthog_messagetemplate", + columns='("team_id", "key")', + unique=True, + where='WHERE (NOT "deleted" AND "key" IS NOT NULL)', + ), + ], + ), + ] diff --git a/products/messaging/backend/migrations/max_migration.txt b/products/messaging/backend/migrations/max_migration.txt index 2345ae4be7e7..a3caa3d81957 100644 --- a/products/messaging/backend/migrations/max_migration.txt +++ b/products/messaging/backend/migrations/max_migration.txt @@ -1 +1 @@ -0006_messagetemplate_transactional_hog_flow_id +0008_messagetemplate_unique_key_per_team diff --git a/products/messaging/backend/models/message_template.py b/products/messaging/backend/models/message_template.py index 874ed7c81e8e..c59ea8ace436 100644 --- a/products/messaging/backend/models/message_template.py +++ b/products/messaging/backend/models/message_template.py @@ -1,3 +1,4 @@ +from django.contrib.postgres.fields import ArrayField from django.db import models from posthog.models.utils import UUIDTModel @@ -8,6 +9,10 @@ class MessageTemplate(UUIDTModel): A model for storing message templates used for email and eventually other messaging channels. """ + class ManagedBy(models.TextChoices): + GUI = "gui", "GUI" + CODE = "code", "Code" + team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE, related_name="+") name = models.CharField(max_length=400) description = models.TextField(blank=True, default="") @@ -21,9 +26,50 @@ class MessageTemplate(UUIDTModel): # Set once the template is made transactional: the workflow the send API runs for it. A plain id, # because the workflow lives in the workflows product. transactional_hog_flow_id = models.UUIDField(null=True, blank=True) + key = models.CharField( + max_length=400, + null=True, + blank=True, + help_text="Client-chosen identifier, unique within this environment among templates that are not deleted. " + "Letters, numbers, hyphens (-) and underscores (_) only.", + ) + managed_by = models.CharField( + max_length=400, + choices=ManagedBy, + null=True, + blank=True, + help_text="Who owns the template content: `gui` for the PostHog app, `code` for uploads from a repository. " + "Empty means `gui`.", + ) + source_repository = models.CharField( + max_length=400, + null=True, + blank=True, + help_text="Repository the template was last uploaded from, such as `github.com/example/app`.", + ) + source_path = models.CharField( + max_length=400, null=True, blank=True, help_text="Path of the template file in the source repository." + ) + source_ref = models.CharField( + max_length=400, null=True, blank=True, help_text="Commit or branch of the last upload." + ) + content_hash = models.CharField( + max_length=80, + null=True, + blank=True, + help_text="Hash of the published subject, preheader, HTML and text, as `v1:` and 64 hex characters.", + ) + superseded_content_hashes = ArrayField(models.CharField(max_length=80), null=True, blank=True) class Meta: db_table = "posthog_messagetemplate" + constraints = [ + models.UniqueConstraint( + fields=["team", "key"], + condition=models.Q(key__isnull=False, deleted=False), + name="unique_message_template_key_per_team", + ), + ] def __str__(self): return self.name diff --git a/products/messaging/backend/tests/test_message_template_key.py b/products/messaging/backend/tests/test_message_template_key.py new file mode 100644 index 000000000000..ad0201893ccc --- /dev/null +++ b/products/messaging/backend/tests/test_message_template_key.py @@ -0,0 +1,34 @@ +from posthog.test.base import BaseTest + +from django.db import IntegrityError, transaction + +from parameterized import parameterized + +from posthog.models import Team + +from products.messaging.backend.models.message_template import MessageTemplate + + +class TestMessageTemplateKey(BaseTest): + def _create_template(self, team: Team, key: str, deleted: bool = False) -> MessageTemplate: + return MessageTemplate.objects.create(team=team, name=key, key=key, deleted=deleted) + + def test_a_live_template_holds_its_key_within_its_team(self) -> None: + self._create_template(self.team, "welcome") + + with self.assertRaises(IntegrityError), transaction.atomic(): + self._create_template(self.team, "welcome") + + @parameterized.expand( + [ + ("in_another_team", False, True), + ("after_the_holder_is_deleted", True, False), + ] + ) + def test_the_key_is_free(self, _name: str, first_is_deleted: bool, second_in_other_team: bool) -> None: + other_team = Team.objects.create(organization=self.organization, name="Other team") + self._create_template(self.team, "welcome", deleted=first_is_deleted) + + second = self._create_template(other_team if second_in_other_team else self.team, "welcome") + + self.assertEqual(MessageTemplate.objects.get(pk=second.pk).key, "welcome") From a17b303d4564f17bad796d3957dc7f2290c2163d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:30:23 +0000 Subject: [PATCH 02/30] feat(messaging): upload an email template by key from code PUT /api/projects/{project_id}/messaging_templates/by_key/{key}/ creates the template on the first upload (keyed, managed by code, transactional with the default verified sender), publishes changed content after that, and answers unchanged when nothing changed. Project secret API keys with hog_flow:write can call it, behind workflows-transactional-email-api. The update, design and upload routes now share one save-and-sync service that writes the template, its content hash and the workflow version together. Co-Authored-By: Claude Opus 5.5 --- .../messaging/backend/facade/contracts.py | 38 ++ .../messaging/backend/facade/templates.py | 97 ++++- .../backend/facade/transactional_email.py | 11 +- .../backend/models/message_template.py | 8 +- .../presentation/views/message_templates.py | 385 +++++++++++++++--- .../backend/services/content_hash.py | 30 ++ .../backend/services/message_templates.py | 30 +- .../backend/services/template_saves.py | 59 +++ .../backend/services/template_uploads.py | 150 +++++++ .../backend/services/transactional_email.py | 31 +- .../backend/tests/api/test_template_upload.py | 261 ++++++++++++ .../backend/tests/test_content_hash.py | 39 ++ .../frontend/generated/api.schemas.ts | 169 ++++++++ products/messaging/frontend/generated/api.ts | 24 ++ .../messaging/frontend/generated/api.zod.ts | 65 +++ .../tasks/frontend/generated/api.schemas.ts | 7 +- products/workflows/mcp/email_templates.yaml | 20 + .../schema/generated-tool-definitions.json | 15 + services/mcp/schema/tool-definitions-all.json | 15 + services/mcp/src/api/generated.ts | 201 ++++++++- .../mcp/src/generated/email_templates/api.ts | 76 +++- .../src/tools/generated/email_templates.ts | 45 ++ .../workflows-create-email-template.json | 4 + .../workflows-update-email-template.json | 4 + .../workflows-upload-email-template.json | 74 ++++ .../tests/unit/tool-schema-snapshots.test.ts | 1 + 26 files changed, 1732 insertions(+), 127 deletions(-) create mode 100644 products/messaging/backend/services/content_hash.py create mode 100644 products/messaging/backend/services/template_saves.py create mode 100644 products/messaging/backend/services/template_uploads.py create mode 100644 products/messaging/backend/tests/api/test_template_upload.py create mode 100644 products/messaging/backend/tests/test_content_hash.py create mode 100644 services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-upload-email-template.json diff --git a/products/messaging/backend/facade/contracts.py b/products/messaging/backend/facade/contracts.py index f71a960b4d41..e38b9cea98e9 100644 --- a/products/messaging/backend/facade/contracts.py +++ b/products/messaging/backend/facade/contracts.py @@ -3,6 +3,7 @@ from uuid import UUID from posthog.dataclasses import frozen +from posthog.enums import LabeledStrEnum class UnlayerError(Exception): @@ -73,3 +74,40 @@ class DeliveryUnavailable(Exception): @frozen class QueuedEmail: message_id: str + + +TEMPLATE_KEY_PATTERN = r"^[a-zA-Z0-9_-]+$" +TEMPLATE_KEY_MAX_LENGTH = 400 + + +class TemplateManagedBy(LabeledStrEnum): + GUI = "gui", "GUI" + CODE = "code", "Code" + + +class TemplateUploadResult(LabeledStrEnum): + CREATED = "created", "Created" + UPDATED = "updated", "Updated" + UNCHANGED = "unchanged", "Unchanged" + + +@frozen +class TemplateSource: + """Where the uploaded file lives. A part that is None leaves the stored part as it is.""" + + repository: str | None + path: str | None + ref: str | None + + +@frozen +class TemplateUpload: + key: str + name: str + subject: str + html: str + text: str + preheader: str + # The editor's design for the html, so the template opens in the visual editor. + design: dict[str, Any] + source: TemplateSource | None diff --git a/products/messaging/backend/facade/templates.py b/products/messaging/backend/facade/templates.py index f771e5fb3f1a..78166bb42236 100644 --- a/products/messaging/backend/facade/templates.py +++ b/products/messaging/backend/facade/templates.py @@ -9,8 +9,13 @@ from posthog.dataclasses import frozen +from products.messaging.backend.facade.contracts import TemplateManagedBy, TemplateUpload, TemplateUploadResult from products.messaging.backend.models.message_template import MessageTemplate -from products.messaging.backend.services import message_templates as templates_service +from products.messaging.backend.services import ( + message_templates as templates_service, + template_saves, + template_uploads, +) from products.messaging.backend.services.lazy_list import LazyList @@ -35,6 +40,18 @@ class MessageTemplateRow: message_category_id: UUID | None deleted: bool transactional_hog_flow_id: UUID | None + key: str | None + managed_by: TemplateManagedBy | None + source_repository: str | None + source_path: str | None + source_ref: str | None + content_hash: str | None + + +@frozen +class UploadedTemplate: + result: TemplateUploadResult + template: MessageTemplateRow def _to_contract(row: MessageTemplate) -> MessageTemplateRow: @@ -50,6 +67,12 @@ def _to_contract(row: MessageTemplate) -> MessageTemplateRow: message_category_id=row.message_category_id, deleted=row.deleted, transactional_hog_flow_id=row.transactional_hog_flow_id, + key=row.key, + managed_by=TemplateManagedBy(row.managed_by) if row.managed_by else None, + source_repository=row.source_repository, + source_path=row.source_path, + source_ref=row.source_ref, + content_hash=row.content_hash, ) @@ -82,16 +105,72 @@ def create_template(team_id: int, created_by_id: int | None, fields: dict[str, A return _to_contract(templates_service.create_template(team_id, created_by_id, fields)) -def update_template(team_id: int, template_id: UUID, fields: dict[str, Any]) -> MessageTemplateRow: - """Set the given fields and save the whole row. `message_category` holds a category id or None.""" - return _to_contract(templates_service.update_template(team_id, template_id, fields)) +def save_template_fields( + team_id: int, + template_id: UUID, + fields: dict[str, Any], + *, + user_id: int | None, + require_workflow_editor: Callable[[UUID], None], +) -> MessageTemplateRow: + """Set validated fields and save the template with its workflow. `message_category` holds a category id or None. + + Raises TransactionalEmailError when the template could no longer send. `require_workflow_editor` gets the id of + the template's workflow, under the row lock, and raises to refuse the save. + """ + return _to_contract( + template_saves.save_template( + team_id, + template_id, + lambda template: templates_service.apply_fields(template, fields), + user_id=user_id, + require_workflow_editor=require_workflow_editor, + ) + ) -def edit_template_content( - team_id: int, template_id: UUID, edit: Callable[[dict[str, Any]], dict[str, Any]] +def save_template_content( + team_id: int, + template_id: UUID, + edit: Callable[[dict[str, Any]], dict[str, Any]], + *, + user_id: int | None, + require_workflow_editor: Callable[[UUID], None], ) -> MessageTemplateRow: - """Lock the template, replace its content with `edit(content)` and save, in one transaction. + """Replace the template's content with `edit(content)` and save it as `save_template_fields` does. - `edit` gets a copy of the stored content. An exception from it rolls the change back. + `edit` gets a copy of the stored content, under the row lock. An exception from it rolls the change back. """ - return _to_contract(templates_service.edit_content_locked(team_id, template_id, edit)) + return _to_contract( + template_saves.save_template( + team_id, + template_id, + lambda template: templates_service.edit_content(template, edit), + user_id=user_id, + require_workflow_editor=require_workflow_editor, + ) + ) + + +def upload_template( + team_id: int, + upload: TemplateUpload, + *, + user_id: int | None, + require_workflow_editor: Callable[[UUID], None], + require_workflow_create: Callable[[], None], +) -> UploadedTemplate: + """Create the template with the upload's key, or publish the upload as its content. + + The first upload makes the template transactional with the default verified sender, and writes nothing when + there is none. Raises TransactionalEmailError when the template can't send. `require_workflow_create` runs + before a new workflow is made, and raises to refuse the upload. + """ + result, template = template_uploads.upload_template( + team_id, + upload, + user_id=user_id, + require_workflow_editor=require_workflow_editor, + require_workflow_create=require_workflow_create, + ) + return UploadedTemplate(result=result, template=_to_contract(template)) diff --git a/products/messaging/backend/facade/transactional_email.py b/products/messaging/backend/facade/transactional_email.py index c8ee6c56d0ce..09db0f902162 100644 --- a/products/messaging/backend/facade/transactional_email.py +++ b/products/messaging/backend/facade/transactional_email.py @@ -1,21 +1,12 @@ """Transactional email: a template made sendable over the API, backed by a workflow that runs once per send.""" from products.messaging.backend.facade.contracts import DeliveryUnavailable, QueuedEmail, TransactionalEmailError -from products.messaging.backend.services.transactional_email import ( - check_transactional_update, - lock_transactional_workflow_id, - make_transactional, - send_transactional_email, - sync_transactional_workflow, -) +from products.messaging.backend.services.transactional_email import make_transactional, send_transactional_email __all__ = [ "DeliveryUnavailable", "QueuedEmail", "TransactionalEmailError", - "check_transactional_update", - "lock_transactional_workflow_id", "make_transactional", "send_transactional_email", - "sync_transactional_workflow", ] diff --git a/products/messaging/backend/models/message_template.py b/products/messaging/backend/models/message_template.py index c59ea8ace436..7a4d4bdb01a4 100644 --- a/products/messaging/backend/models/message_template.py +++ b/products/messaging/backend/models/message_template.py @@ -3,16 +3,14 @@ from posthog.models.utils import UUIDTModel +from products.messaging.backend.facade.contracts import TemplateManagedBy + class MessageTemplate(UUIDTModel): """ A model for storing message templates used for email and eventually other messaging channels. """ - class ManagedBy(models.TextChoices): - GUI = "gui", "GUI" - CODE = "code", "Code" - team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE, related_name="+") name = models.CharField(max_length=400) description = models.TextField(blank=True, default="") @@ -35,7 +33,7 @@ class ManagedBy(models.TextChoices): ) managed_by = models.CharField( max_length=400, - choices=ManagedBy, + choices=TemplateManagedBy.choices, null=True, blank=True, help_text="Who owns the template content: `gui` for the PostHog app, `code` for uploads from a repository. " diff --git a/products/messaging/backend/presentation/views/message_templates.py b/products/messaging/backend/presentation/views/message_templates.py index 29fb1eb59459..d97731bd268c 100644 --- a/products/messaging/backend/presentation/views/message_templates.py +++ b/products/messaging/backend/presentation/views/message_templates.py @@ -1,10 +1,9 @@ +import re from collections.abc import Sequence from dataclasses import fields as dataclass_fields from types import SimpleNamespace from typing import Any -from django.db import transaction - import structlog import posthoganalytics from drf_spectacular.types import OpenApiTypes @@ -27,6 +26,7 @@ from posthog.api.routing import TeamAndOrgViewSetMixin from posthog.api.shared import UserBasicSerializer from posthog.auth import ProjectSecretAPIKeyAuthentication +from posthog.dataclasses import frozen from posthog.event_usage import groups, report_user_action from posthog.models import User from posthog.permissions import is_authenticated_via_project_secret_api_key @@ -40,25 +40,32 @@ render_design_html, validate_design, ) +from products.messaging.backend.facade.contracts import ( + TEMPLATE_KEY_MAX_LENGTH, + TEMPLATE_KEY_PATTERN, + TemplateManagedBy, + TemplateSource, + TemplateUpload, + TemplateUploadResult, +) from products.messaging.backend.facade.templates import ( MessageCategoryNotInTeam, MessageTemplateMissing, MessageTemplateRow, + UploadedTemplate, create_template, - edit_template_content, get_template, list_templates, + save_template_content, + save_template_fields, team_category_id, - update_template, + upload_template, ) from products.messaging.backend.facade.transactional_email import ( DeliveryUnavailable, TransactionalEmailError, - check_transactional_update, - lock_transactional_workflow_id, make_transactional, send_transactional_email, - sync_transactional_workflow, ) from products.messaging.backend.presentation.views.serializers import DesignOperationSerializer from products.notifications.backend.facade.api import publish_resource_edited @@ -111,6 +118,11 @@ class EmailTemplateSerializer(serializers.Serializer): required=False, help_text="Email subject line. Supports Liquid templating. Required for email-type templates.", ) + preheader = serializers.CharField( + required=False, + allow_blank=True, + help_text="Inbox preview text shown after the subject. Supports Liquid templating.", + ) text = serializers.CharField( required=False, allow_blank=True, @@ -205,6 +217,36 @@ class MessageTemplateSerializer(serializers.Serializer): help_text="The workflow that sends this template over the transactional email API. Null until the " "template is made transactional. Each content save writes a new version of it.", ) + key = serializers.CharField( + read_only=True, + allow_null=True, + help_text="Stable identifier code uses to upload and send this template, unique within the project. Null " + "for templates that have no key.", + ) + managed_by = serializers.ChoiceField( + choices=TemplateManagedBy.choices, + read_only=True, + allow_null=True, + help_text="Who owns the content. `code` when it was uploaded from a repository, so the next upload replaces " + "edits made in PostHog. `gui` or null when it is edited in PostHog.", + ) + source_repository = serializers.CharField( + read_only=True, + allow_null=True, + help_text="Repository the template was last uploaded from, such as `github.com/example/app`.", + ) + source_path = serializers.CharField( + read_only=True, allow_null=True, help_text="Path of the template file in the source repository." + ) + source_ref = serializers.CharField( + read_only=True, allow_null=True, help_text="Commit or branch the template was last uploaded from." + ) + content_hash = serializers.CharField( + read_only=True, + allow_null=True, + help_text="Hash of the published subject, preheader, HTML and text, as `v1:` and 64 hex characters. Changes " + "with every save that changes them.", + ) def validate(self, data: Any) -> Any: template_type = data.get("type") @@ -305,6 +347,153 @@ class SendTransactionalEmailResponseSerializer(serializers.Serializer): ) +class TemplateSourceSerializer(serializers.Serializer): + repository = serializers.CharField( + max_length=400, + required=False, + allow_null=True, + help_text="Host and path of the repository the file is in, such as `github.com/example/app`.", + ) + path = serializers.CharField( + max_length=400, + required=False, + allow_null=True, + help_text="Path of the template file in the repository, such as `emails/welcome.html`.", + ) + ref = serializers.CharField( + max_length=400, + required=False, + allow_null=True, + help_text="Commit or branch the file was uploaded from.", + ) + + +class UploadTemplateSerializer(serializers.Serializer): + # Content is stored byte for byte as sent, so the content hash a client computes matches the stored one. + name = serializers.CharField(max_length=400, help_text="Template name shown in the template library.") + subject = serializers.CharField( + trim_whitespace=False, help_text="Email subject line. Supports Liquid, such as `{{ variables.first_name }}`." + ) + html = serializers.CharField( + trim_whitespace=False, + help_text="Email body as HTML, stored as sent. Supports Liquid, such as `{{ variables.first_name }}`.", + ) + text = serializers.CharField( + required=False, + allow_blank=True, + trim_whitespace=False, + help_text="Plain-text body for email clients that can't show HTML. Leaving it out removes the stored one.", + ) + preheader = serializers.CharField( + required=False, + allow_blank=True, + trim_whitespace=False, + help_text="Inbox preview text shown after the subject. Leaving it out removes the stored one.", + ) + source = TemplateSourceSerializer( + required=False, + help_text="Where the template file lives, so people in PostHog can find it. Leaving it out, or a part of " + "it, keeps what is stored.", + ) + + +class UploadTemplateResponseSerializer(serializers.Serializer): + id = serializers.UUIDField(help_text="ID of the template.") + key = serializers.CharField(help_text="Key of the template.") + result = serializers.ChoiceField( + choices=TemplateUploadResult.choices, + help_text="`created`: the upload made a new template. `updated`: it published the uploaded content. " + "`unchanged`: the template already had this content, so nothing was published.", + ) + content_hash = serializers.CharField( + help_text="Hash of the published content, as `v1:` and 64 hex characters. Send it with an email to check " + "PostHog holds the content you hold." + ) + managed_by = serializers.ChoiceField(choices=TemplateManagedBy.choices, help_text="Always `code` after an upload.") + source = TemplateSourceSerializer(help_text="Where the template file lives, as stored after the upload.") + transactional_hog_flow_id = serializers.UUIDField( + help_text="The workflow that sends this template. Each upload that changes the content writes a new version " + "of it." + ) + + +class TemplateUploadErrorSerializer(serializers.Serializer): + type = serializers.CharField(help_text="Error class, such as `invalid_request`.") + code = serializers.CharField(help_text="Stable error code to branch on, such as `invalid_template_key`.") + detail = serializers.CharField(help_text="What went wrong.") + attr = serializers.CharField(allow_null=True, help_text="The request field the error is about, if any.") + why = serializers.CharField(required=False, help_text="Why PostHog refuses the request.") + fix = serializers.CharField(required=False, help_text="What to change before you retry.") + docs_url = serializers.URLField(required=False, help_text="Documentation for this error code.") + + +TRANSACTIONAL_EMAIL_DOCS_URL = "https://posthog.com/docs/workflows/transactional-email-api" + + +@frozen +class UploadRefused: + code: str + detail: str + attr: str | None + why: str + fix: str + + def response(self) -> Response: + return Response( + { + "type": "invalid_request", + "code": self.code, + "detail": self.detail, + "attr": self.attr, + "why": self.why, + "fix": self.fix, + "docs_url": f"{TRANSACTIONAL_EMAIL_DOCS_URL}#{self.code}", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + +def invalid_template_key(key: str) -> UploadRefused | None: + if len(key) <= TEMPLATE_KEY_MAX_LENGTH and re.match(TEMPLATE_KEY_PATTERN, key): + return None + return UploadRefused( + code="invalid_template_key", + detail=f"The key can have at most {TEMPLATE_KEY_MAX_LENGTH} letters, digits, hyphens (-) and underscores (_).", + attr="key", + why="The key is part of the URL and names the template in every project, so it must stay the same.", + fix="Use a key like `password-reset`.", + ) + + +def invalid_upload(errors: Any) -> UploadRefused: + attr, message = first_field_error(errors) + return UploadRefused( + code="invalid_request", + detail=f"{attr}: {message}", + attr=attr, + why="An upload needs a name, a subject and HTML, each as a string.", + fix=f"Fix `{attr}` and upload again.", + ) + + +def first_field_error(errors: Any, path: tuple[str, ...] = ()) -> tuple[str, str]: + if isinstance(errors, dict): + field, nested = next(iter(errors.items())) + return first_field_error(nested, (*path, str(field))) + if isinstance(errors, list) and errors and not isinstance(errors[0], str): + return first_field_error(errors[0], path) + return "__".join(path), str(errors[0] if isinstance(errors, list) else errors) + + +TEMPLATE_KEY_PARAMETER = OpenApiParameter( + name="key", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="Key of the template, chosen by you. Letters, digits, hyphens (-) and underscores (_), at most 400 " + "characters. The same key names the template in every project.", +) + + class TransactionalEmailRejected(exceptions.APIException): STATUS_BY_CODE = {"quota_limited": 429} # The caller has to change the request. Without this the error handler reports a server error. @@ -367,15 +556,16 @@ class MessageTemplatesViewSet( "design", "send", "make_transactional", + "upload", ] authentication_classes = [ProjectSecretAPIKeyAuthentication] - # A project secret API key can only send. Template CRUD stays session/personal API key/OAuth only. - psak_allowed_actions = ["send"] + # A project secret API key can only send and upload. Template CRUD stays session/personal API key/OAuth only. + psak_allowed_actions = ["send", "upload"] serializer_class = MessageTemplateSerializer def get_throttles(self): - if self.action == "send": + if self.action in ("send", "upload"): return [ TransactionalEmailBurstThrottle(), TransactionalEmailSustainedThrottle(), @@ -450,34 +640,21 @@ def update(self, request: Request, *args: Any, **kwargs: Any) -> Response: template = self.get_object() serializer = self.get_serializer(self._with_creators([template])[0], data=request.data, partial=partial) serializer.is_valid(raise_exception=True) - # Sends use the workflow's copy, so the template and its new workflow version are saved together, - # under a row lock that keeps a concurrent make_transactional from slipping in between. - with transaction.atomic(): - hog_flow_id = lock_transactional_workflow_id(self.team_id, template.id) - if hog_flow_id: - self._require_workflow_editor(hog_flow_id) - try: - check_transactional_update(template.id, self.team_id, serializer.validated_data) - except TransactionalEmailError as e: - raise TransactionalEmailRejected(e) from None - updated = update_template(self.team_id, template.id, serializer.validated_data) - if hog_flow_id and not updated.deleted: - self._sync_transactional_workflow(template.id) - # The sync unlinks the template when its workflow was deleted. - updated = get_template(self.team_id, template.id) - self._emit_resource_edited(updated) - return Response(self._serialize(updated)) - - def _sync_transactional_workflow(self, template_id: Any) -> None: - # Raising rolls back the template save, so the template and its workflow never disagree. try: - sync_transactional_workflow(self.team_id, template_id, user_id=self._actor_id()) + updated = save_template_fields( + self.team_id, + template.id, + serializer.validated_data, + user_id=self._actor_id(), + require_workflow_editor=self._require_workflow_editor, + ) except TransactionalEmailError as e: raise TransactionalEmailRejected(e) from None + self._emit_resource_edited(updated) + return Response(self._serialize(updated)) def _require_workflow_editor(self, hog_flow_id: Any) -> None: - # The template's own access rules don't cover the workflow it sends through, which can carry its own. - # A project secret API key has no user to check, and its scope already limits it to sending. + # A project secret API key has no user to check, and its scope already limits it to sending and uploads. if is_authenticated_via_project_secret_api_key(self.request): return try: @@ -556,19 +733,19 @@ def apply_operations(content: dict[str, Any]) -> dict[str, Any]: serializer.is_valid(raise_exception=True) return serializer.validated_data["content"] - with transaction.atomic(): - # Checked under the row lock, so a template made transactional meanwhile is checked too. - hog_flow_id = lock_transactional_workflow_id(self.team_id, template.id) - if hog_flow_id: - self._require_workflow_editor(hog_flow_id) - locked = edit_template_content(self.team_id, template.id, apply_operations) - if hog_flow_id: - self._sync_transactional_workflow(template.id) - # The sync unlinks the template when its workflow was deleted. - locked = get_template(self.team_id, template.id) + try: + saved = save_template_content( + self.team_id, + template.id, + apply_operations, + user_id=self._actor_id(), + require_workflow_editor=self._require_workflow_editor, + ) + except TransactionalEmailError as e: + raise TransactionalEmailRejected(e) from None - self._emit_resource_edited(locked) - return Response(self._serialize(locked)) + self._emit_resource_edited(saved) + return Response(self._serialize(saved)) @validated_request( SendTransactionalEmailSerializer, @@ -620,6 +797,120 @@ def send(self, request: Any, *args: Any, **kwargs: Any) -> Response: status=status.HTTP_202_ACCEPTED, ) + @extend_schema( + request=UploadTemplateSerializer, + responses={ + 200: OpenApiResponse( + response=UploadTemplateResponseSerializer, description="The template was updated, or is unchanged" + ), + 201: OpenApiResponse(response=UploadTemplateResponseSerializer, description="The template was created"), + 400: OpenApiResponse( + response=TemplateUploadErrorSerializer, + description="The key or a field is invalid, the template can't be sent, or there is no verified " + "default sender", + ), + 404: OpenApiResponse(description="The transactional email API is not enabled for this project"), + 429: OpenApiResponse(description="Rate limited"), + }, + summary="Upload a template by key", + description=( + "Create or update the email template with this key from HTML, for templates kept as files in a " + "repository. The first upload creates the template and makes it transactional with the project's " + "default verified sender. A later upload publishes changed content at once, and answers `unchanged` " + "when the content is the same. An upload keeps the template's sender and never turns a paused " + "template back on." + ), + parameters=[TEMPLATE_KEY_PARAMETER], + ) + @action(detail=False, methods=["PUT"], url_path=r"by_key/(?P[^/]+)", pagination_class=None) + def upload(self, request: Request, key: str, *args: Any, **kwargs: Any) -> Response: + self._require_transactional_email_flag() + refused = invalid_template_key(key) + body = UploadTemplateSerializer(data=request.data) + if refused is None and not body.is_valid(): + refused = invalid_upload(body.errors) + if refused is not None: + self._report_template_upload(request, {"result": "rejected", "error_code": refused.code}) + return refused.response() + + try: + uploaded = upload_template( + self.team_id, + self._template_upload(key, body.validated_data), + user_id=self._actor_id(), + require_workflow_editor=self._require_workflow_editor, + require_workflow_create=self._require_workflow_create, + ) + except TransactionalEmailError as e: + self._report_template_upload(request, {"result": "rejected", "error_code": e.code}) + raise TransactionalEmailRejected(e) from None + + self._report_template_upload(request, {"result": uploaded.result, "template_id": str(uploaded.template.id)}) + if uploaded.result != TemplateUploadResult.UNCHANGED: + self._emit_resource_edited(uploaded.template) + created = uploaded.result == TemplateUploadResult.CREATED + return Response( + UploadTemplateResponseSerializer(self._upload_response(uploaded)).data, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + @staticmethod + def _template_upload(key: str, data: dict[str, Any]) -> TemplateUpload: + source = data.get("source") + return TemplateUpload( + key=key, + name=data["name"], + subject=data["subject"], + html=data["html"], + text=data.get("text", ""), + preheader=data.get("preheader", ""), + # Wraps the html in one custom HTML block, so the visual editor can open the template. + design=build_html_wrap_design(data["html"]), + source=TemplateSource(repository=source.get("repository"), path=source.get("path"), ref=source.get("ref")) + if source + else None, + ) + + @staticmethod + def _upload_response(uploaded: UploadedTemplate) -> dict[str, Any]: + template = uploaded.template + return { + "id": template.id, + "key": template.key, + "result": uploaded.result, + "content_hash": template.content_hash, + "managed_by": template.managed_by, + "source": { + "repository": template.source_repository, + "path": template.source_path, + "ref": template.source_ref, + }, + "transactional_hog_flow_id": template.transactional_hog_flow_id, + } + + def _require_workflow_create(self) -> None: + if is_authenticated_via_project_secret_api_key(self.request): + return + # Access to one workflow lets a member through the viewset's checks, but an upload can create a new one. + if not self.user_access_control.check_access_level_for_resource("hog_flow", "editor"): + raise exceptions.PermissionDenied("You need editor access to workflows to upload a new template.") + + def _report_template_upload(self, request: Request, outcome: dict[str, Any]) -> None: + # report_user_action drops the synthetic user behind a project secret API key, so capture directly. + try: + posthoganalytics.capture( + distinct_id=request.user.distinct_id, + event="transactional email template uploaded", + properties={"team_id": self.team_id, "auth_method": self._auth_method(request), **outcome}, + groups=groups(self.organization, self.team), + ) + except Exception as e: + logger.warning("Failed to capture transactional email template upload event", error=str(e)) + + @staticmethod + def _auth_method(request: Request) -> str: + return "project_secret_api_key" if is_authenticated_via_project_secret_api_key(request) else "user" + def _require_transactional_email_flag(self) -> None: if not posthoganalytics.feature_enabled( TRANSACTIONAL_EMAIL_API_FLAG, @@ -690,9 +981,7 @@ def _report_transactional_email_send( properties={ "template_id": str(template.id), "team_id": self.team_id, - "auth_method": "project_secret_api_key" - if is_authenticated_via_project_secret_api_key(request) - else "user", + "auth_method": self._auth_method(request), **outcome, }, groups=groups(self.organization, self.team), diff --git a/products/messaging/backend/services/content_hash.py b/products/messaging/backend/services/content_hash.py new file mode 100644 index 000000000000..f359cf1e1c80 --- /dev/null +++ b/products/messaging/backend/services/content_hash.py @@ -0,0 +1,30 @@ +"""The hash of a template's published email. posthog-node computes the same bytes, so a send can say which content +the caller holds.""" + +import hashlib +from typing import Any + +HASH_PREFIX = "v1:" +HASHED_FIELDS = ("subject", "preheader", "html", "text") +SUPERSEDED_HASHES_KEPT = 100 + +_ENCODING_HEADER = b"posthog-email-template-v1\n" + + +def email_content_hash(email: dict[str, Any]) -> str: + encoded = bytearray(_ENCODING_HEADER) + for field in HASHED_FIELDS: + value = _normalized_line_endings(email.get(field) or "").encode() + encoded += f"{len(value)}:".encode() + value + b"\n" + return HASH_PREFIX + hashlib.sha256(encoded).hexdigest() + + +def _normalized_line_endings(value: str) -> str: + return value.replace("\r\n", "\n").replace("\r", "\n") + + +def superseded_after_publish(previous_hash: str | None, published_hash: str, superseded: list[str]) -> list[str]: + """The superseded list once `published_hash` replaces `previous_hash`: newest first, without the published one.""" + replaced = [previous_hash] if previous_hash and previous_hash != published_hash else [] + kept = [h for h in superseded if h not in (previous_hash, published_hash)] + return (replaced + kept)[:SUPERSEDED_HASHES_KEPT] diff --git a/products/messaging/backend/services/message_templates.py b/products/messaging/backend/services/message_templates.py index 885b66f760e5..e282818e3a79 100644 --- a/products/messaging/backend/services/message_templates.py +++ b/products/messaging/backend/services/message_templates.py @@ -9,6 +9,7 @@ from products.messaging.backend.facade.contracts import EmailTemplateSummary from products.messaging.backend.models import MessageCategory, MessageTemplate +from products.messaging.backend.services.content_hash import email_content_hash, superseded_after_publish def get_email_content(team_id: int, template_id: UUID) -> dict | None: @@ -83,31 +84,28 @@ def category_id_for_team(team_id: int, category_id: Any) -> UUID: return MessageCategory.objects.get(team_id=team_id, pk=category_id).pk -def _apply_fields(template: MessageTemplate, fields: dict[str, Any]) -> None: +def apply_fields(template: MessageTemplate, fields: dict[str, Any]) -> None: for attr, value in fields.items(): setattr(template, "message_category_id" if attr == "message_category" else attr, value) def create_template(team_id: int, created_by_id: int | None, fields: dict[str, Any]) -> MessageTemplate: template = MessageTemplate(team_id=team_id, created_by_id=created_by_id) - _apply_fields(template, fields) + apply_fields(template, fields) + record_content_hash(template) template.save(force_insert=True) return template -def update_template(team_id: int, template_id: UUID | str, fields: dict[str, Any]) -> MessageTemplate: - template = team_template(team_id, template_id) - _apply_fields(template, fields) - template.save() - return template +def edit_content(template: MessageTemplate, edit: Callable[[dict[str, Any]], dict[str, Any]]) -> None: + template.content = edit(deepcopy(template.content or {})) -def edit_content_locked( - team_id: int, template_id: UUID | str, edit: Callable[[dict[str, Any]], dict[str, Any]] -) -> MessageTemplate: - with transaction.atomic(): - # nosemgrep: idor-lookup-without-team (re-fetch of an already team-scoped template, locked for update) - locked = MessageTemplate.objects.select_for_update().get(pk=team_template(team_id, template_id).pk) - locked.content = edit(deepcopy(locked.content or {})) - locked.save() - return locked +def record_content_hash(template: MessageTemplate) -> None: + """Set the hash of the template's email, and remember the hash it replaces.""" + email = (template.content or {}).get("email") + published_hash = email_content_hash(email if isinstance(email, dict) else {}) + template.superseded_content_hashes = superseded_after_publish( + template.content_hash, published_hash, template.superseded_content_hashes or [] + ) + template.content_hash = published_hash diff --git a/products/messaging/backend/services/template_saves.py b/products/messaging/backend/services/template_saves.py new file mode 100644 index 000000000000..725fd7f1e4f0 --- /dev/null +++ b/products/messaging/backend/services/template_saves.py @@ -0,0 +1,59 @@ +"""The one path that saves a template's content. Sends use the workflow's copy of the content, so the template, its +content hash and the new version of its transactional workflow are written in one transaction, under the template's +row lock. The lock keeps a concurrent make_transactional from linking a workflow in between.""" + +from collections.abc import Callable +from uuid import UUID + +from django.db import transaction + +from products.messaging.backend.models import MessageTemplate +from products.messaging.backend.services.message_templates import record_content_hash +from products.messaging.backend.services.transactional_email import check_still_sendable, sync_transactional_workflow + +TemplateWrite = Callable[[MessageTemplate], None] +WorkflowEditorCheck = Callable[[UUID], None] + + +def save_template( + team_id: int, + template_id: UUID, + write: TemplateWrite, + *, + user_id: int | None, + require_workflow_editor: WorkflowEditorCheck, +) -> MessageTemplate: + """Apply `write` to the locked template and save it. An exception from `write` or from the workflow sync rolls + the whole save back, so the template and its workflow never disagree.""" + with transaction.atomic(): + template = lock_template(team_id, template_id) + save_locked_template(template, write, user_id=user_id, require_workflow_editor=require_workflow_editor) + return template + + +def lock_template(team_id: int, template_id: UUID) -> MessageTemplate: + return MessageTemplate.objects.select_for_update().get(team_id=team_id, id=template_id, deleted=False) + + +def save_locked_template( + template: MessageTemplate, + write: TemplateWrite, + *, + user_id: int | None, + require_workflow_editor: WorkflowEditorCheck, +) -> None: + """`save_template` for a template the caller locked in its own transaction.""" + hog_flow_id = template.transactional_hog_flow_id + # The template's own access rules don't cover the workflow it sends through, which can carry its own. + if hog_flow_id: + require_workflow_editor(hog_flow_id) + write(template) + syncs_workflow = hog_flow_id is not None and not template.deleted + if syncs_workflow: + check_still_sendable(template) + record_content_hash(template) + template.save() + if syncs_workflow: + sync_transactional_workflow(template.team_id, template.id, user_id=user_id) + # The sync unlinks the template when its workflow was deleted. + template.refresh_from_db() diff --git a/products/messaging/backend/services/template_uploads.py b/products/messaging/backend/services/template_uploads.py new file mode 100644 index 000000000000..ac1998878f40 --- /dev/null +++ b/products/messaging/backend/services/template_uploads.py @@ -0,0 +1,150 @@ +"""Upload a template from code by its key: create it on the first upload, publish changed content after that.""" + +from collections.abc import Callable +from typing import Any + +from django.db import IntegrityError, transaction + +from products.messaging.backend.facade.contracts import ( + SenderUnavailable, + TemplateManagedBy, + TemplateSource, + TemplateUpload, + TemplateUploadResult, +) +from products.messaging.backend.models import MessageTemplate +from products.messaging.backend.services.content_hash import HASHED_FIELDS +from products.messaging.backend.services.message_templates import record_content_hash +from products.messaging.backend.services.template_saves import WorkflowEditorCheck, save_locked_template +from products.messaging.backend.services.transactional_email import is_transactional, make_transactional + +KEY_CONSTRAINT = "unique_message_template_key_per_team" +SOURCE_PARTS = ("repository", "path", "ref") + +WorkflowCreateCheck = Callable[[], None] + + +def upload_template( + team_id: int, + upload: TemplateUpload, + *, + user_id: int | None, + require_workflow_editor: WorkflowEditorCheck, + require_workflow_create: WorkflowCreateCheck, +) -> tuple[TemplateUploadResult, MessageTemplate]: + def attempt() -> tuple[TemplateUploadResult, MessageTemplate]: + with transaction.atomic(): + return _upload( + team_id, + upload, + user_id=user_id, + require_workflow_editor=require_workflow_editor, + require_workflow_create=require_workflow_create, + ) + + try: + return attempt() + except IntegrityError as error: + if KEY_CONSTRAINT not in str(error): + raise + # A concurrent first upload created the template after this one looked for it. This one updates it. + return attempt() + + +def _upload( + team_id: int, + upload: TemplateUpload, + *, + user_id: int | None, + require_workflow_editor: WorkflowEditorCheck, + require_workflow_create: WorkflowCreateCheck, +) -> tuple[TemplateUploadResult, MessageTemplate]: + template = locked_template_with_key(team_id, upload.key) + if template is None: + require_workflow_create() + return TemplateUploadResult.CREATED, _create(team_id, upload, user_id=user_id) + if _is_unchanged(template, upload): + _save_source(template, upload.source) + return TemplateUploadResult.UNCHANGED, template + save_locked_template( + template, + lambda locked: _write_upload(locked, upload), + user_id=user_id, + require_workflow_editor=require_workflow_editor, + ) + if not template.transactional_hog_flow_id: + # An adopted template, or one whose workflow was deleted. + require_workflow_create() + _make_transactional(template, user_id=user_id) + return TemplateUploadResult.UPDATED, template + + +def locked_template_with_key(team_id: int, key: str) -> MessageTemplate | None: + return MessageTemplate.objects.select_for_update().filter(team_id=team_id, key=key, deleted=False).first() + + +def _create(team_id: int, upload: TemplateUpload, *, user_id: int | None) -> MessageTemplate: + template = MessageTemplate(team_id=team_id, created_by_id=user_id, key=upload.key) + _write_upload(template, upload) + record_content_hash(template) + template.save(force_insert=True) + _make_transactional(template, user_id=user_id) + return template + + +def _make_transactional(template: MessageTemplate, *, user_id: int | None) -> None: + try: + make_transactional(template.team_id, template.id, user_id=user_id, from_integration_id=None) + except SenderUnavailable as error: + raise SenderUnavailable( + "The project has no verified default email sender. Verify a sending domain, set it as the default " + "sender, and upload again." + ) from error + template.refresh_from_db() + + +def _write_upload(template: MessageTemplate, upload: TemplateUpload) -> None: + template.name = upload.name + template.type = "email" + template.managed_by = TemplateManagedBy.CODE + template.content = {**(template.content or {}), "templating": "liquid", "email": _email(upload)} + _write_source(template, upload.source) + + +def _email(upload: TemplateUpload) -> dict[str, Any]: + return { + "subject": upload.subject, + "preheader": upload.preheader, + "html": upload.html, + "text": upload.text, + "design": upload.design, + } + + +def _is_unchanged(template: MessageTemplate, upload: TemplateUpload) -> bool: + email = (template.content or {}).get("email") or {} + uploaded = _email(upload) + return ( + template.name == upload.name + and all((email.get(field) or "") == uploaded[field] for field in HASHED_FIELDS) + and template.managed_by == TemplateManagedBy.CODE + and is_transactional(template) + ) + + +def _write_source(template: MessageTemplate, source: TemplateSource | None) -> list[str]: + changed: list[str] = [] + for part in SOURCE_PARTS: + value = getattr(source, part) if source else None + field = f"source_{part}" + if value is not None and getattr(template, field) != value: + setattr(template, field, value) + changed.append(field) + return changed + + +def _save_source(template: MessageTemplate, source: TemplateSource | None) -> None: + # Metadata only: the content and the workflow stay as they are. + changed = _write_source(template, source) + if changed: + template.save(update_fields=changed) diff --git a/products/messaging/backend/services/transactional_email.py b/products/messaging/backend/services/transactional_email.py index ef62de81b0d6..a5dad014ee26 100644 --- a/products/messaging/backend/services/transactional_email.py +++ b/products/messaging/backend/services/transactional_email.py @@ -107,31 +107,22 @@ def _workflow_exists(team_id: int, hog_flow_id: UUID) -> bool: return True -def lock_transactional_workflow_id(team_id: int, template_id: UUID) -> UUID | None: - """Lock the template row for the rest of the transaction and return its workflow id. Holding the lock - keeps make_transactional from linking a workflow between this read and the update that follows.""" - return ( - MessageTemplate.objects.select_for_update() - .filter(team_id=team_id, id=template_id) - .values_list("transactional_hog_flow_id", flat=True) - .get() +def is_transactional(template: MessageTemplate) -> bool: + return template.transactional_hog_flow_id is not None and _workflow_exists( + template.team_id, template.transactional_hog_flow_id ) -def check_transactional_update(template_id: UUID, team_id: int, fields: dict[str, Any]) -> None: - """Raise when an update would leave a transactional template unable to send. `fields` are validated - serializer fields, where `message_category` holds a category id.""" - template = MessageTemplate.objects.get(team_id=team_id, id=template_id) - if not template.transactional_hog_flow_id or fields.get("deleted"): - return - # A link to a deleted workflow no longer restricts the template. The sync after the update clears it. - if not _workflow_exists(team_id, template.transactional_hog_flow_id): +def check_still_sendable(template: MessageTemplate) -> None: + """Raise when a written but unsaved template could no longer send as a transactional email.""" + # A link to a deleted workflow no longer restricts the template. The sync after the save clears it. + if template.deleted or not is_transactional(template): return check_sendable( - team_id, - template_type=fields.get("type", template.type), - content=fields.get("content", template.content), - message_category_id=fields.get("message_category", template.message_category_id), + template.team_id, + template_type=template.type, + content=template.content, + message_category_id=template.message_category_id, ) diff --git a/products/messaging/backend/tests/api/test_template_upload.py b/products/messaging/backend/tests/api/test_template_upload.py new file mode 100644 index 000000000000..8dd72822e18a --- /dev/null +++ b/products/messaging/backend/tests/api/test_template_upload.py @@ -0,0 +1,261 @@ +from typing import Any + +from posthog.test.base import APIBaseTest +from unittest.mock import MagicMock, patch + +from parameterized import parameterized +from rest_framework import status +from rest_framework.test import APIClient + +from posthog.models import ProjectSecretAPIKey, Team +from posthog.models.integration import Integration +from posthog.models.personal_api_key import hash_key_value +from posthog.models.utils import generate_random_token_secret + +from products.messaging.backend.models.message_template import MessageTemplate +from products.messaging.backend.services import template_uploads +from products.messaging.backend.services.transactional_email import make_transactional +from products.workflows.backend.facade.api import set_default_email_sender_if_unset + +WELCOME = { + "name": "Welcome", + "subject": "Welcome, {{ variables.first_name }}", + "html": "

Hi {{ variables.first_name }}

", + "source": {"repository": "github.com/example/app", "path": "emails/welcome.html", "ref": "3f2c1ab"}, +} +WELCOME_HASH = "v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6" +# HTML files end with a newline. The upload stores it, so the stored hash matches the hash of the file. +GREETING = {"name": "Welcome", "subject": "Grüße", "preheader": "Vorschau", "html": "

ä

\n", "text": "ä"} +GREETING_HASH = "v1:ff5293725c21e0f9b71918af66cf7b6e17b2021f250fe915f8426946b378c88d" + + +@patch("products.messaging.backend.presentation.views.message_templates.posthoganalytics.capture") +@patch( + "products.messaging.backend.presentation.views.message_templates.posthoganalytics.feature_enabled", + return_value=True, +) +@patch("posthog.plugins.plugin_server_api.internal_requests.post") +class TestTemplateUpload(APIBaseTest): + def setUp(self): + super().setUp() + self.sender = self._verified_sender("hello@example.com") + set_default_email_sender_if_unset(self.team.id, self.sender.id) + self.url = f"/api/projects/{self.team.id}/messaging_templates/by_key/welcome/" + self.psak = self._psak_client(["hog_flow:write"]) + + def _verified_sender(self, address: str) -> Integration: + return Integration.objects.create( + team=self.team, kind="email", integration_id=address, config={"verified": True} + ) + + def _psak_client(self, scopes: list[str], team: Team | None = None) -> APIClient: + raw_token = generate_random_token_secret() + ProjectSecretAPIKey.objects.create( + team=team or self.team, + label=f"template upload {raw_token[:8]}", + secure_value=hash_key_value(raw_token), + scopes=scopes, + mask_value=f"{raw_token[:4]}...{raw_token[-4:]}", + ) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {raw_token}") + return client + + def _upload(self, body: dict[str, Any]) -> dict[str, Any]: + response = self.psak.put(self.url, body, format="json") + assert response.status_code in (status.HTTP_200_OK, status.HTTP_201_CREATED), response.json() + return response.json() + + def _template(self, template_id: str) -> dict[str, Any]: + response = self.client.get(f"/api/projects/{self.team.id}/messaging_templates/{template_id}/") + assert response.status_code == status.HTTP_200_OK, response.json() + return response.json() + + def _workflow(self, hog_flow_id: str) -> dict[str, Any]: + response = self.client.get(f"/api/projects/{self.team.id}/hog_flows/{hog_flow_id}") + assert response.status_code == status.HTTP_200_OK, response.json() + return response.json() + + def _sent_email(self, hog_flow_id: str) -> dict[str, Any]: + return self._workflow(hog_flow_id)["actions"][1]["config"]["inputs"]["email"]["value"] + + def _superseded_hashes(self, template_id: str) -> list[str]: + return MessageTemplate.objects.get(id=template_id).superseded_content_hashes or [] + + def test_a_first_upload_creates_a_code_managed_transactional_template(self, _post: MagicMock, _flag, _capture): + response = self.psak.put(self.url, WELCOME, format="json") + + assert response.status_code == status.HTTP_201_CREATED, response.json() + uploaded = response.json() + assert uploaded["result"] == "created" + assert uploaded["key"] == "welcome" + assert uploaded["managed_by"] == "code" + assert uploaded["content_hash"] == WELCOME_HASH + assert uploaded["source"] == WELCOME["source"] + template = self._template(uploaded["id"]) + assert template["key"] == "welcome" + assert template["managed_by"] == "code" + assert template["content_hash"] == WELCOME_HASH + assert (template["source_repository"], template["source_path"], template["source_ref"]) == ( + "github.com/example/app", + "emails/welcome.html", + "3f2c1ab", + ) + assert template["transactional_hog_flow_id"] == uploaded["transactional_hog_flow_id"] + assert self._workflow(uploaded["transactional_hog_flow_id"])["status"] == "active" + email = self._sent_email(uploaded["transactional_hog_flow_id"]) + assert (email["subject"], email["html"], email["from"]["integrationId"]) == ( + WELCOME["subject"], + WELCOME["html"], + self.sender.id, + ) + + def test_an_identical_upload_is_unchanged_and_writes_no_workflow_version(self, _post, _flag, _capture): + first = self._upload(WELCOME) + + response = self.psak.put(self.url, {**WELCOME, "source": {"ref": "9e8d7c6"}}, format="json") + + assert response.status_code == status.HTTP_200_OK, response.json() + assert response.json()["result"] == "unchanged" + assert response.json()["content_hash"] == WELCOME_HASH + assert response.json()["source"] == {**WELCOME["source"], "ref": "9e8d7c6"} + assert self._workflow(first["transactional_hog_flow_id"])["version"] == 1 + + @patch("products.messaging.backend.services.transactional_email.reload_hog_flows_on_workers") + def test_a_changed_upload_is_what_the_next_send_delivers(self, _reload, post: MagicMock, _flag, _capture): + first = self._upload(WELCOME) + post.return_value = MagicMock(status_code=202, json=lambda: {"invocation_id": "invocation-1"}) + + response = self.psak.put(self.url, GREETING, format="json") + sent = self.psak.post( + f"/api/projects/{self.team.id}/messaging_templates/{first['id']}/send/", + {"to": "user@example.com", "message_data": {"first_name": "Jane"}}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK, response.json() + assert response.json()["result"] == "updated" + assert response.json()["content_hash"] == GREETING_HASH + assert sent.status_code == status.HTTP_202_ACCEPTED, sent.json() + hog_flow_id = response.json()["transactional_hog_flow_id"] + assert hog_flow_id == first["transactional_hog_flow_id"] + assert post.call_args.args[0].endswith(f"/hog_flows/{hog_flow_id}/transactional_email") + assert post.call_args.kwargs["json"]["variables"] == {"first_name": "Jane"} + email = self._sent_email(hog_flow_id) + assert (email["subject"], email["preheader"], email["html"], email["text"]) == ( + "Grüße", + "Vorschau", + "

ä

\n", + "ä", + ) + assert self._workflow(hog_flow_id)["version"] == 2 + + def test_an_upload_claims_a_keyed_template_and_keeps_its_sender_and_paused_workflow(self, _post, _flag, _capture): + other_sender = self._verified_sender("team@example.com") + adopted = MessageTemplate.objects.create( + team=self.team, + name="Welcome", + key="welcome", + content={"email": {"subject": "Hello", "html": "

Hello

"}}, + type="email", + ) + hog_flow_id = make_transactional( + self.team.id, adopted.id, user_id=self.user.id, from_integration_id=other_sender.id + ) + paused = self.client.patch(f"/api/projects/{self.team.id}/hog_flows/{hog_flow_id}", {"status": "archived"}) + assert paused.status_code == status.HTTP_200_OK, paused.json() + + response = self.client.put(self.url, WELCOME, format="json") + + assert response.status_code == status.HTTP_200_OK, response.json() + assert (response.json()["result"], response.json()["id"]) == ("updated", str(adopted.id)) + assert response.json()["managed_by"] == "code" + workflow = self._workflow(str(hog_flow_id)) + assert workflow["status"] == "archived" + assert self._sent_email(str(hog_flow_id))["from"]["integrationId"] == other_sender.id + + def test_without_a_verified_default_sender_an_upload_writes_nothing(self, _post, _flag, _capture): + self.sender.config = {"verified": False} + self.sender.save() + + response = self.psak.put(self.url, WELCOME, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json() + assert response.json()["code"] == "sender_unavailable" + assert not MessageTemplate.objects.filter(team=self.team).exists() + + @parameterized.expand( + [ + ("invalid_key", "wel!come", WELCOME, "invalid_template_key", "key"), + ("missing_subject", "welcome", {"name": "Welcome", "html": "

Hi

"}, "invalid_request", "subject"), + ( + "long_source_path", + "welcome", + {**WELCOME, "source": {"path": "a" * 401}}, + "invalid_request", + "source__path", + ), + ] + ) + def test_refuses_an_invalid_upload_with_the_field_to_fix( + self, _post, _flag, _capture, _name, key, body, expected_code, expected_attr + ): + response = self.psak.put(f"/api/projects/{self.team.id}/messaging_templates/by_key/{key}/", body, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json() + assert response.json()["code"] == expected_code + assert response.json()["attr"] == expected_attr + assert response.json()["docs_url"].endswith(f"#{expected_code}") + assert not MessageTemplate.objects.filter(team=self.team).exists() + + @parameterized.expand( + [ + ("read_scope", ["hog_flow:read"], False, True, status.HTTP_403_FORBIDDEN), + ("another_projects_key", ["hog_flow:write"], True, True, status.HTTP_403_FORBIDDEN), + ("flag_off", ["hog_flow:write"], False, False, status.HTTP_404_NOT_FOUND), + ] + ) + def test_refuses_an_upload_it_does_not_allow( + self, _post, flag, _capture, _name, scopes, other_project, flag_on, expected_status + ): + flag.return_value = flag_on + team = Team.objects.create(organization=self.organization, name="Other project") if other_project else None + + response = self._psak_client(scopes, team).put(self.url, WELCOME, format="json") + + assert response.status_code == expected_status, response.json() + assert not MessageTemplate.objects.exists() + + def test_two_racing_first_uploads_end_with_one_template(self, _post, _flag, _capture): + winner = self._upload(WELCOME) + lookup = template_uploads.locked_template_with_key + lookups: list[str] = [] + + def misses_the_winner_once(team_id: int, key: str) -> MessageTemplate | None: + lookups.append(key) + return None if len(lookups) == 1 else lookup(team_id, key) + + with patch.object(template_uploads, "locked_template_with_key", side_effect=misses_the_winner_once): + response = self.psak.put(self.url, GREETING, format="json") + + assert response.status_code == status.HTTP_200_OK, response.json() + assert (response.json()["result"], response.json()["id"]) == ("updated", winner["id"]) + assert MessageTemplate.objects.filter(team=self.team, key="welcome").count() == 1 + + def test_a_publish_moves_the_replaced_hash_to_the_superseded_list(self, _post, _flag, _capture): + first = self._upload(WELCOME) + + edited = self.client.patch( + f"/api/projects/{self.team.id}/messaging_templates/{first['id']}/", + {"content": {"email": {"subject": "Edited in PostHog", "html": "

Edited

"}}}, + format="json", + ) + edited_hash = edited.json()["content_hash"] + superseded_after_edit = self._superseded_hashes(first["id"]) + rolled_back = self._upload(WELCOME) + + assert edited.status_code == status.HTTP_200_OK, edited.json() + assert edited_hash not in (None, WELCOME_HASH) + assert superseded_after_edit == [WELCOME_HASH] + assert (rolled_back["result"], rolled_back["content_hash"]) == ("updated", WELCOME_HASH) + assert self._superseded_hashes(first["id"]) == [edited_hash] diff --git a/products/messaging/backend/tests/test_content_hash.py b/products/messaging/backend/tests/test_content_hash.py new file mode 100644 index 000000000000..4dee039023ac --- /dev/null +++ b/products/messaging/backend/tests/test_content_hash.py @@ -0,0 +1,39 @@ +from django.test import SimpleTestCase + +from parameterized import parameterized + +from products.messaging.backend.services.content_hash import email_content_hash + + +class TestEmailContentHash(SimpleTestCase): + @parameterized.expand( + [ + ( + "fields_absent", + {"subject": "Welcome, {{ variables.first_name }}", "html": "

Hi {{ variables.first_name }}

"}, + "v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6", + ), + ( + "non_ascii", + {"subject": "Grüße", "preheader": "Vorschau", "html": "

ä

", "text": "ä"}, + "v1:75cf6fc3189ca26f60da672d29a1d4c89c7db3dd458df3a550a34a4d2a92c149", + ), + ( + "windows_line_endings", + { + "subject": "Welcome, {{ variables.first_name }}", + "html": "

Hi {{ variables.first_name }}

", + "text": "\r\n\r", + }, + email_content_hash( + { + "subject": "Welcome, {{ variables.first_name }}", + "html": "

Hi {{ variables.first_name }}

", + "text": "\n\n", + } + ), + ), + ] + ) + def test_matches_the_spec_test_vectors(self, _name: str, email: dict, expected: str) -> None: + assert email_content_hash(email) == expected diff --git a/products/messaging/frontend/generated/api.schemas.ts b/products/messaging/frontend/generated/api.schemas.ts index 79ab7aa3efca..7870fba849a3 100644 --- a/products/messaging/frontend/generated/api.schemas.ts +++ b/products/messaging/frontend/generated/api.schemas.ts @@ -295,6 +295,8 @@ export type EmailTemplateApiDesign = { export interface EmailTemplateApi { /** Email subject line. Supports Liquid templating. Required for email-type templates. */ subject?: string + /** Inbox preview text shown after the subject. Supports Liquid templating. */ + preheader?: string /** Plain-text fallback body for clients that can't render the email. */ text?: string /** Rendered email body — derived from the design at save time. The visual editor's save path supplies it directly; omit it otherwise. */ @@ -369,6 +371,17 @@ export interface UserBasicApi { role_at_organization?: RoleAtOrganizationEnumApi | BlankEnumApi | null } +/** + * * `gui` - GUI + * * `code` - Code + */ +export type TemplateManagedByEnumApi = (typeof TemplateManagedByEnumApi)[keyof typeof TemplateManagedByEnumApi] + +export const TemplateManagedByEnumApi = { + Gui: 'gui', + Code: 'code', +} as const + export interface MessageTemplateApi { readonly id: string /** @@ -400,6 +413,36 @@ export interface MessageTemplateApi { * @nullable */ readonly transactional_hog_flow_id: string | null + /** + * Stable identifier code uses to upload and send this template, unique within the project. Null for templates that have no key. + * @nullable + */ + readonly key: string | null + /** Who owns the content. `code` when it was uploaded from a repository, so the next upload replaces edits made in PostHog. `gui` or null when it is edited in PostHog. + * + * * `gui` - GUI + * * `code` - Code */ + readonly managed_by: TemplateManagedByEnumApi | null + /** + * Repository the template was last uploaded from, such as `github.com/example/app`. + * @nullable + */ + readonly source_repository: string | null + /** + * Path of the template file in the source repository. + * @nullable + */ + readonly source_path: string | null + /** + * Commit or branch the template was last uploaded from. + * @nullable + */ + readonly source_ref: string | null + /** + * Hash of the published subject, preheader, HTML and text, as `v1:` and 64 hex characters. Changes with every save that changes them. + * @nullable + */ + readonly content_hash: string | null } export interface PaginatedMessageTemplateListApi { @@ -442,6 +485,36 @@ export interface PatchedMessageTemplateApi { * @nullable */ readonly transactional_hog_flow_id?: string | null + /** + * Stable identifier code uses to upload and send this template, unique within the project. Null for templates that have no key. + * @nullable + */ + readonly key?: string | null + /** Who owns the content. `code` when it was uploaded from a repository, so the next upload replaces edits made in PostHog. `gui` or null when it is edited in PostHog. + * + * * `gui` - GUI + * * `code` - Code */ + readonly managed_by?: TemplateManagedByEnumApi | null + /** + * Repository the template was last uploaded from, such as `github.com/example/app`. + * @nullable + */ + readonly source_repository?: string | null + /** + * Path of the template file in the source repository. + * @nullable + */ + readonly source_path?: string | null + /** + * Commit or branch the template was last uploaded from. + * @nullable + */ + readonly source_ref?: string | null + /** + * Hash of the published subject, preheader, HTML and text, as `v1:` and 64 hex characters. Changes with every save that changes them. + * @nullable + */ + readonly content_hash?: string | null } /** @@ -538,6 +611,102 @@ export interface SendTransactionalEmailResponseApi { message_id: string } +export interface TemplateSourceApi { + /** + * Host and path of the repository the file is in, such as `github.com/example/app`. + * @maxLength 400 + * @nullable + */ + repository?: string | null + /** + * Path of the template file in the repository, such as `emails/welcome.html`. + * @maxLength 400 + * @nullable + */ + path?: string | null + /** + * Commit or branch the file was uploaded from. + * @maxLength 400 + * @nullable + */ + ref?: string | null +} + +export interface UploadTemplateApi { + /** + * Template name shown in the template library. + * @maxLength 400 + */ + name: string + /** Email subject line. Supports Liquid, such as `{{ variables.first_name }}`. */ + subject: string + /** Email body as HTML, stored as sent. Supports Liquid, such as `{{ variables.first_name }}`. */ + html: string + /** Plain-text body for email clients that can't show HTML. Leaving it out removes the stored one. */ + text?: string + /** Inbox preview text shown after the subject. Leaving it out removes the stored one. */ + preheader?: string + /** Where the template file lives, so people in PostHog can find it. Leaving it out, or a part of it, keeps what is stored. */ + source?: TemplateSourceApi +} + +/** + * * `created` - Created + * * `updated` - Updated + * * `unchanged` - Unchanged + */ +export type TemplateUploadResultEnumApi = (typeof TemplateUploadResultEnumApi)[keyof typeof TemplateUploadResultEnumApi] + +export const TemplateUploadResultEnumApi = { + Created: 'created', + Updated: 'updated', + Unchanged: 'unchanged', +} as const + +export interface UploadTemplateResponseApi { + /** ID of the template. */ + id: string + /** Key of the template. */ + key: string + /** `created`: the upload made a new template. `updated`: it published the uploaded content. `unchanged`: the template already had this content, so nothing was published. + * + * * `created` - Created + * * `updated` - Updated + * * `unchanged` - Unchanged */ + result: TemplateUploadResultEnumApi + /** Hash of the published content, as `v1:` and 64 hex characters. Send it with an email to check PostHog holds the content you hold. */ + content_hash: string + /** Always `code` after an upload. + * + * * `gui` - GUI + * * `code` - Code */ + managed_by: TemplateManagedByEnumApi + /** Where the template file lives, as stored after the upload. */ + source: TemplateSourceApi + /** The workflow that sends this template. Each upload that changes the content writes a new version of it. */ + transactional_hog_flow_id: string +} + +export interface TemplateUploadErrorApi { + /** Error class, such as `invalid_request`. */ + type: string + /** Stable error code to branch on, such as `invalid_template_key`. */ + code: string + /** What went wrong. */ + detail: string + /** + * The request field the error is about, if any. + * @nullable + */ + attr: string | null + /** Why PostHog refuses the request. */ + why?: string + /** What to change before you retry. */ + fix?: string + /** Documentation for this error code. */ + docs_url?: string +} + export type MessagingCategoriesListParams = { /** * Number of results to return per page. diff --git a/products/messaging/frontend/generated/api.ts b/products/messaging/frontend/generated/api.ts index 6f4a2bd8cc79..22387a4d629a 100644 --- a/products/messaging/frontend/generated/api.ts +++ b/products/messaging/frontend/generated/api.ts @@ -35,6 +35,8 @@ import type { RemoveOptOutRequestApi, SendTransactionalEmailApi, SendTransactionalEmailResponseApi, + UploadTemplateApi, + UploadTemplateResponseApi, WebhookUrlApi, } from './api.schemas' @@ -768,3 +770,25 @@ export const messagingTemplatesSendCreate = async ( body: JSON.stringify(sendTransactionalEmailApi), }) } + +export const getMessagingTemplatesByKeyUpdateUrl = (projectId: string, key: string) => { + return `/api/projects/${projectId}/messaging_templates/by_key/${key}/` +} + +/** + * Create or update the email template with this key from HTML, for templates kept as files in a repository. The first upload creates the template and makes it transactional with the project's default verified sender. A later upload publishes changed content at once, and answers `unchanged` when the content is the same. An upload keeps the template's sender and never turns a paused template back on. + * @summary Upload a template by key + */ +export const messagingTemplatesByKeyUpdate = async ( + projectId: string, + key: string, + uploadTemplateApi: UploadTemplateApi, + options?: RequestInit +): Promise => { + return apiMutator(getMessagingTemplatesByKeyUpdateUrl(projectId, key), { + ...options, + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(uploadTemplateApi), + }) +} diff --git a/products/messaging/frontend/generated/api.zod.ts b/products/messaging/frontend/generated/api.zod.ts index 9957e6349bc4..d2b6b87a2df2 100644 --- a/products/messaging/frontend/generated/api.zod.ts +++ b/products/messaging/frontend/generated/api.zod.ts @@ -280,6 +280,10 @@ export const MessagingTemplatesCreateBody = /* @__PURE__ */ zod.object({ .describe( 'Email subject line. Supports Liquid templating. Required for email-type templates.' ), + preheader: zod + .string() + .optional() + .describe('Inbox preview text shown after the subject. Supports Liquid templating.'), text: zod .string() .optional() @@ -369,6 +373,10 @@ export const MessagingTemplatesUpdateBody = /* @__PURE__ */ zod.object({ .describe( 'Email subject line. Supports Liquid templating. Required for email-type templates.' ), + preheader: zod + .string() + .optional() + .describe('Inbox preview text shown after the subject. Supports Liquid templating.'), text: zod .string() .optional() @@ -459,6 +467,10 @@ export const MessagingTemplatesPartialUpdateBody = /* @__PURE__ */ zod.object({ .describe( 'Email subject line. Supports Liquid templating. Required for email-type templates.' ), + preheader: zod + .string() + .optional() + .describe('Inbox preview text shown after the subject. Supports Liquid templating.'), text: zod .string() .optional() @@ -621,3 +633,56 @@ export const MessagingTemplatesSendCreateBody = /* @__PURE__ */ zod.object({ 'Data for the template\'s Liquid placeholders, available as `variables`. For example `{\"reset_link\": \"https:\/\/...\"}` fills `{{ variables.reset_link }}`.' ), }) + +/** + * Create or update the email template with this key from HTML, for templates kept as files in a repository. The first upload creates the template and makes it transactional with the project's default verified sender. A later upload publishes changed content at once, and answers `unchanged` when the content is the same. An upload keeps the template's sender and never turns a paused template back on. + * @summary Upload a template by key + */ +export const messagingTemplatesByKeyUpdateBodyNameMax = 400 + +export const messagingTemplatesByKeyUpdateBodySourceOneRepositoryMax = 400 + +export const messagingTemplatesByKeyUpdateBodySourceOnePathMax = 400 + +export const messagingTemplatesByKeyUpdateBodySourceOneRefMax = 400 + +export const MessagingTemplatesByKeyUpdateBody = /* @__PURE__ */ zod.object({ + name: zod + .string() + .max(messagingTemplatesByKeyUpdateBodyNameMax) + .describe('Template name shown in the template library.'), + subject: zod.string().describe('Email subject line. Supports Liquid, such as `{{ variables.first_name }}`.'), + html: zod + .string() + .describe('Email body as HTML, stored as sent. Supports Liquid, such as `{{ variables.first_name }}`.'), + text: zod + .string() + .optional() + .describe("Plain-text body for email clients that can't show HTML. Leaving it out removes the stored one."), + preheader: zod + .string() + .optional() + .describe('Inbox preview text shown after the subject. Leaving it out removes the stored one.'), + source: zod + .object({ + repository: zod + .string() + .max(messagingTemplatesByKeyUpdateBodySourceOneRepositoryMax) + .nullish() + .describe('Host and path of the repository the file is in, such as `github.com\/example\/app`.'), + path: zod + .string() + .max(messagingTemplatesByKeyUpdateBodySourceOnePathMax) + .nullish() + .describe('Path of the template file in the repository, such as `emails\/welcome.html`.'), + ref: zod + .string() + .max(messagingTemplatesByKeyUpdateBodySourceOneRefMax) + .nullish() + .describe('Commit or branch the file was uploaded from.'), + }) + .optional() + .describe( + 'Where the template file lives, so people in PostHog can find it. Leaving it out, or a part of it, keeps what is stored.' + ), +}) diff --git a/products/tasks/frontend/generated/api.schemas.ts b/products/tasks/frontend/generated/api.schemas.ts index f4589fbd9667..c394a9f6a2c5 100644 --- a/products/tasks/frontend/generated/api.schemas.ts +++ b/products/tasks/frontend/generated/api.schemas.ts @@ -3892,9 +3892,10 @@ export interface TaskRunPeerMessageRequestApi { * * `target_finished` - target_finished * * `rejected` - rejected */ -export type ResultEnumApi = (typeof ResultEnumApi)[keyof typeof ResultEnumApi] +export type TaskRunPeerMessageResponseResultEnumApi = + (typeof TaskRunPeerMessageResponseResultEnumApi)[keyof typeof TaskRunPeerMessageResponseResultEnumApi] -export const ResultEnumApi = { +export const TaskRunPeerMessageResponseResultEnumApi = { Accepted: 'accepted', TargetFinished: 'target_finished', Rejected: 'rejected', @@ -3906,7 +3907,7 @@ export interface TaskRunPeerMessageResponseApi { * * `accepted` - accepted * * `target_finished` - target_finished * * `rejected` - rejected */ - result: ResultEnumApi + result: TaskRunPeerMessageResponseResultEnumApi /** Human-readable explanation of the result. */ detail: string /** diff --git a/products/workflows/mcp/email_templates.yaml b/products/workflows/mcp/email_templates.yaml index 6d3b677a59a8..7659bf49b1c3 100644 --- a/products/workflows/mcp/email_templates.yaml +++ b/products/workflows/mcp/email_templates.yaml @@ -120,6 +120,26 @@ tools: exclude: - content.email.design - created_by + workflows-upload-email-template: + operation: messaging_templates_by_key_update + enabled: true + feature_flag: workflows-transactional-email-api + annotations: + readOnly: false + destructive: false + idempotent: true + enrich_url: '{id}' + title: Upload email template by key + description: > + Upload an email template kept as an HTML file in a repository, by a key you choose (such as welcome or + password-reset). Use this for templates that live in code; use workflows-create-email-template for + templates designed in PostHog. Send name, subject, html, and optionally text, preheader and source + (repository, path, ref of the file). Use {{ variables.* }} for every value the email needs; templates + can't read person properties. The first upload creates the template and makes it sendable over the + transactional email API with the project's default verified sender, so verify a sender first. Later uploads + publish changed content at once and answer unchanged when nothing changed. Uploading replaces edits made + in PostHog. A plain 404 means the transactional email API is not enabled for this project: stop and tell + the user. workflows-update-email-template: operation: messaging_templates_partial_update enabled: true diff --git a/services/mcp/schema/generated-tool-definitions.json b/services/mcp/schema/generated-tool-definitions.json index 9168c1c2285c..f3c6547dd7aa 100644 --- a/services/mcp/schema/generated-tool-definitions.json +++ b/services/mcp/schema/generated-tool-definitions.json @@ -15328,6 +15328,21 @@ "readOnlyHint": false } }, + "workflows-upload-email-template": { + "description": "Upload an email template kept as an HTML file in a repository, by a key you choose (such as welcome or password-reset). Use this for templates that live in code; use workflows-create-email-template for templates designed in PostHog. Send name, subject, html, and optionally text, preheader and source (repository, path, ref of the file). Use {{ variables.* }} for every value the email needs; templates can't read person properties. The first upload creates the template and makes it sendable over the transactional email API with the project's default verified sender, so verify a sender first. Later uploads publish changed content at once and answer unchanged when nothing changed. Uploading replaces edits made in PostHog. A plain 404 means the transactional email API is not enabled for this project: stop and tell the user.", + "category": "Workflows", + "feature": "workflows", + "summary": "Upload email template by key", + "title": "Upload email template by key", + "required_scopes": ["hog_flow:write"], + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "readOnlyHint": false + }, + "feature_flag": "workflows-transactional-email-api" + }, "workflows-version-stats": { "description": "Execution stats for a single published version of a workflow, in the same shape as workflows-stats. That is how you tell whether a change helped: compare the version before it with the version after it. The unversioned read in workflows-stats keys batch and broadcast runs on the run rather than the workflow, so it is not the sum of the versions - compare versions with each other, not with it. Set instance_id to the step you are reading, or several steps share one denominator.", "category": "Workflows", diff --git a/services/mcp/schema/tool-definitions-all.json b/services/mcp/schema/tool-definitions-all.json index ccde7b646b62..bc32edef6d66 100644 --- a/services/mcp/schema/tool-definitions-all.json +++ b/services/mcp/schema/tool-definitions-all.json @@ -16073,6 +16073,21 @@ "readOnlyHint": false } }, + "workflows-upload-email-template": { + "description": "Upload an email template kept as an HTML file in a repository, by a key you choose (such as welcome or password-reset). Use this for templates that live in code; use workflows-create-email-template for templates designed in PostHog. Send name, subject, html, and optionally text, preheader and source (repository, path, ref of the file). Use {{ variables.* }} for every value the email needs; templates can't read person properties. The first upload creates the template and makes it sendable over the transactional email API with the project's default verified sender, so verify a sender first. Later uploads publish changed content at once and answer unchanged when nothing changed. Uploading replaces edits made in PostHog. A plain 404 means the transactional email API is not enabled for this project: stop and tell the user.", + "category": "Workflows", + "feature": "workflows", + "summary": "Upload email template by key", + "title": "Upload email template by key", + "required_scopes": ["hog_flow:write"], + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "readOnlyHint": false + }, + "feature_flag": "workflows-transactional-email-api" + }, "workflows-version-stats": { "description": "Execution stats for a single published version of a workflow, in the same shape as workflows-stats. That is how you tell whether a change helped: compare the version before it with the version after it. The unversioned read in workflows-stats keys batch and broadcast runs on the run rather than the workflow, so it is not the sum of the versions - compare versions with each other, not with it. Set instance_id to the step you are reading, or several steps share one denominator.", "category": "Workflows", diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index a39f73b661a0..97708cdfe086 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -39234,6 +39234,8 @@ export namespace Schemas { export interface EmailTemplate { /** Email subject line. Supports Liquid templating. Required for email-type templates. */ subject?: string; + /** Inbox preview text shown after the subject. Supports Liquid templating. */ + preheader?: string; /** Plain-text fallback body for clients that can't render the email. */ text?: string; /** Rendered email body — derived from the design at save time. The visual editor's save path supplies it directly; omit it otherwise. */ @@ -67145,6 +67147,18 @@ export namespace Schemas { email?: EmailTemplate | null; } + /** + * * `gui` - GUI + * * `code` - Code + */ + export type TemplateManagedByEnum = typeof TemplateManagedByEnum[keyof typeof TemplateManagedByEnum]; + + + export const TemplateManagedByEnum = { + Gui: 'gui', + Code: 'code', + } as const; + export interface MessageTemplate { readonly id: string; /** @@ -67176,6 +67190,36 @@ export namespace Schemas { * @nullable */ readonly transactional_hog_flow_id: string | null; + /** + * Stable identifier code uses to upload and send this template, unique within the project. Null for templates that have no key. + * @nullable + */ + readonly key: string | null; + /** Who owns the content. `code` when it was uploaded from a repository, so the next upload replaces edits made in PostHog. `gui` or null when it is edited in PostHog. + * + * * `gui` - GUI + * * `code` - Code */ + readonly managed_by: TemplateManagedByEnum | null; + /** + * Repository the template was last uploaded from, such as `github.com/example/app`. + * @nullable + */ + readonly source_repository: string | null; + /** + * Path of the template file in the source repository. + * @nullable + */ + readonly source_path: string | null; + /** + * Commit or branch the template was last uploaded from. + * @nullable + */ + readonly source_ref: string | null; + /** + * Hash of the published subject, preheader, HTML and text, as `v1:` and 64 hex characters. Changes with every save that changes them. + * @nullable + */ + readonly content_hash: string | null; } export interface MessagingError { @@ -83101,6 +83145,36 @@ export namespace Schemas { * @nullable */ readonly transactional_hog_flow_id?: string | null; + /** + * Stable identifier code uses to upload and send this template, unique within the project. Null for templates that have no key. + * @nullable + */ + readonly key?: string | null; + /** Who owns the content. `code` when it was uploaded from a repository, so the next upload replaces edits made in PostHog. `gui` or null when it is edited in PostHog. + * + * * `gui` - GUI + * * `code` - Code */ + readonly managed_by?: TemplateManagedByEnum | null; + /** + * Repository the template was last uploaded from, such as `github.com/example/app`. + * @nullable + */ + readonly source_repository?: string | null; + /** + * Path of the template file in the source repository. + * @nullable + */ + readonly source_path?: string | null; + /** + * Commit or branch the template was last uploaded from. + * @nullable + */ + readonly source_ref?: string | null; + /** + * Hash of the published subject, preheader, HTML and text, as `v1:` and 64 hex characters. Changes with every save that changes them. + * @nullable + */ + readonly content_hash?: string | null; } export interface PatchedMoveTileRequest { @@ -96957,20 +97031,6 @@ export namespace Schemas { agent_response?: string; } - /** - * * `accepted` - accepted - * * `target_finished` - target_finished - * * `rejected` - rejected - */ - export type ResultEnum = typeof ResultEnum[keyof typeof ResultEnum]; - - - export const ResultEnum = { - Accepted: 'accepted', - TargetFinished: 'target_finished', - Rejected: 'rejected', - } as const; - export interface ResultReceipt { /** Accepted item UUID. */ id: string; @@ -111149,13 +111209,27 @@ export namespace Schemas { artifact_ids?: string[]; } + /** + * * `accepted` - accepted + * * `target_finished` - target_finished + * * `rejected` - rejected + */ + export type TaskRunPeerMessageResponseResultEnum = typeof TaskRunPeerMessageResponseResultEnum[keyof typeof TaskRunPeerMessageResponseResultEnum]; + + + export const TaskRunPeerMessageResponseResultEnum = { + Accepted: 'accepted', + TargetFinished: 'target_finished', + Rejected: 'rejected', + } as const; + export interface TaskRunPeerMessageResponse { /** Send outcome: 'accepted' (queued for delivery — not a delivery confirmation), 'target_finished' (the peer's workflow is gone), or 'rejected' (throttled or invalid). * * * `accepted` - accepted * * `target_finished` - target_finished * * `rejected` - rejected */ - result: ResultEnum; + result: TaskRunPeerMessageResponseResultEnum; /** Human-readable explanation of the result. */ detail: string; /** @@ -112131,6 +112205,61 @@ export namespace Schemas { notes: string; } + export interface TemplateSource { + /** + * Host and path of the repository the file is in, such as `github.com/example/app`. + * @maxLength 400 + * @nullable + */ + repository?: string | null; + /** + * Path of the template file in the repository, such as `emails/welcome.html`. + * @maxLength 400 + * @nullable + */ + path?: string | null; + /** + * Commit or branch the file was uploaded from. + * @maxLength 400 + * @nullable + */ + ref?: string | null; + } + + export interface TemplateUploadError { + /** Error class, such as `invalid_request`. */ + type: string; + /** Stable error code to branch on, such as `invalid_template_key`. */ + code: string; + /** What went wrong. */ + detail: string; + /** + * The request field the error is about, if any. + * @nullable + */ + attr: string | null; + /** Why PostHog refuses the request. */ + why?: string; + /** What to change before you retry. */ + fix?: string; + /** Documentation for this error code. */ + docs_url?: string; + } + + /** + * * `created` - Created + * * `updated` - Updated + * * `unchanged` - Unchanged + */ + export type TemplateUploadResultEnum = typeof TemplateUploadResultEnum[keyof typeof TemplateUploadResultEnum]; + + + export const TemplateUploadResultEnum = { + Created: 'created', + Updated: 'updated', + Unchanged: 'unchanged', + } as const; + /** * Anthropic text, image, or tool content blocks. */ @@ -113083,6 +113212,48 @@ export namespace Schemas { results: ResultSubmission[]; } + export interface UploadTemplate { + /** + * Template name shown in the template library. + * @maxLength 400 + */ + name: string; + /** Email subject line. Supports Liquid, such as `{{ variables.first_name }}`. */ + subject: string; + /** Email body as HTML, stored as sent. Supports Liquid, such as `{{ variables.first_name }}`. */ + html: string; + /** Plain-text body for email clients that can't show HTML. Leaving it out removes the stored one. */ + text?: string; + /** Inbox preview text shown after the subject. Leaving it out removes the stored one. */ + preheader?: string; + /** Where the template file lives, so people in PostHog can find it. Leaving it out, or a part of it, keeps what is stored. */ + source?: TemplateSource; + } + + export interface UploadTemplateResponse { + /** ID of the template. */ + id: string; + /** Key of the template. */ + key: string; + /** `created`: the upload made a new template. `updated`: it published the uploaded content. `unchanged`: the template already had this content, so nothing was published. + * + * * `created` - Created + * * `updated` - Updated + * * `unchanged` - Unchanged */ + result: TemplateUploadResultEnum; + /** Hash of the published content, as `v1:` and 64 hex characters. Send it with an email to check PostHog holds the content you hold. */ + content_hash: string; + /** Always `code` after an upload. + * + * * `gui` - GUI + * * `code` - Code */ + managed_by: TemplateManagedByEnum; + /** Where the template file lives, as stored after the upload. */ + source: TemplateSource; + /** The workflow that sends this template. Each upload that changes the content writes a new version of it. */ + transactional_hog_flow_id: string; + } + export interface UploadVersionRequest { /** Zip archive containing the Streamlit app sources (max 10 MB). */ file: string; diff --git a/services/mcp/src/generated/email_templates/api.ts b/services/mcp/src/generated/email_templates/api.ts index 8155c6614b53..520649dad9ac 100644 --- a/services/mcp/src/generated/email_templates/api.ts +++ b/services/mcp/src/generated/email_templates/api.ts @@ -3,7 +3,7 @@ * MCP service uses these Zod schemas for generated tool handlers. * To regenerate: hogli build:openapi * - * PostHog API - MCP 5 enabled ops + * PostHog API - MCP 6 enabled ops * OpenAPI spec version: 1.0.0 */ import * as zod from 'zod' @@ -58,6 +58,10 @@ export const MessagingTemplatesCreateBody = () => zod.object({ .describe( 'Email subject line. Supports Liquid templating. Required for email-type templates.' ), + preheader: zod + .string() + .optional() + .describe('Inbox preview text shown after the subject. Supports Liquid templating.'), text: zod .string() .optional() @@ -160,6 +164,10 @@ export const MessagingTemplatesPartialUpdateBody = () => zod.object({ .describe( 'Email subject line. Supports Liquid templating. Required for email-type templates.' ), + preheader: zod + .string() + .optional() + .describe('Inbox preview text shown after the subject. Supports Liquid templating.'), text: zod .string() .optional() @@ -284,3 +292,69 @@ export const MessagingTemplatesDesignPartialUpdateBody = () => zod.object({ "Ordered edits applied atomically to a template's Unlayer design: the stored design is read, the ops are applied in order, the result is validated and re-rendered to HTML, and it's saved only if valid — otherwise the template is unchanged. Reference blocks by id so you never resend the whole design." ), }) + +/** + * Create or update the email template with this key from HTML, for templates kept as files in a repository. The first upload creates the template and makes it transactional with the project's default verified sender. A later upload publishes changed content at once, and answers `unchanged` when the content is the same. An upload keeps the template's sender and never turns a paused template back on. + * @summary Upload a template by key + */ +export const MessagingTemplatesByKeyUpdateParams = () => zod.object({ + key: zod + .string() + .describe( + 'Key of the template, chosen by you. Letters, digits, hyphens (-) and underscores (_), at most 400 characters. The same key names the template in every project.' + ), + project_id: zod + .string() + .describe( + "Project ID of the project you're trying to access. To find the ID of the project, make a call to \/api\/projects\/." + ), +}) + +export const messagingTemplatesByKeyUpdateBodyNameMax = 400 + +export const messagingTemplatesByKeyUpdateBodySourceOneRepositoryMax = 400 + +export const messagingTemplatesByKeyUpdateBodySourceOnePathMax = 400 + +export const messagingTemplatesByKeyUpdateBodySourceOneRefMax = 400 + +export const MessagingTemplatesByKeyUpdateBody = () => zod.object({ + name: zod + .string() + .max(messagingTemplatesByKeyUpdateBodyNameMax) + .describe('Template name shown in the template library.'), + subject: zod.string().describe('Email subject line. Supports Liquid, such as `{{ variables.first_name }}`.'), + html: zod + .string() + .describe('Email body as HTML, stored as sent. Supports Liquid, such as `{{ variables.first_name }}`.'), + text: zod + .string() + .optional() + .describe("Plain-text body for email clients that can't show HTML. Leaving it out removes the stored one."), + preheader: zod + .string() + .optional() + .describe('Inbox preview text shown after the subject. Leaving it out removes the stored one.'), + source: zod + .object({ + repository: zod + .string() + .max(messagingTemplatesByKeyUpdateBodySourceOneRepositoryMax) + .nullish() + .describe('Host and path of the repository the file is in, such as `github.com\/example\/app`.'), + path: zod + .string() + .max(messagingTemplatesByKeyUpdateBodySourceOnePathMax) + .nullish() + .describe('Path of the template file in the repository, such as `emails\/welcome.html`.'), + ref: zod + .string() + .max(messagingTemplatesByKeyUpdateBodySourceOneRefMax) + .nullish() + .describe('Commit or branch the file was uploaded from.'), + }) + .optional() + .describe( + 'Where the template file lives, so people in PostHog can find it. Leaving it out, or a part of it, keeps what is stored.' + ), +}) diff --git a/services/mcp/src/tools/generated/email_templates.ts b/services/mcp/src/tools/generated/email_templates.ts index 5f29e7ff3a41..f215f8aa5204 100644 --- a/services/mcp/src/tools/generated/email_templates.ts +++ b/services/mcp/src/tools/generated/email_templates.ts @@ -157,6 +157,50 @@ const workflowsShowEmailTemplate = (): ToolBase< }, }) +const WorkflowsUploadEmailTemplateSchema = () => { + const MessagingTemplatesByKeyUpdateBody = orvalSchemas.MessagingTemplatesByKeyUpdateBody() + const MessagingTemplatesByKeyUpdateParams = orvalSchemas.MessagingTemplatesByKeyUpdateParams() + return MessagingTemplatesByKeyUpdateParams.omit({ project_id: true }).extend( + MessagingTemplatesByKeyUpdateBody.shape + ) +} + +const workflowsUploadEmailTemplate = (): ToolBase< + ReturnType, + WithPostHogUrl +> => ({ + name: 'workflows-upload-email-template', + schema: WorkflowsUploadEmailTemplateSchema(), + handler: async (context: Context, params: z.infer>) => { + const projectId = await context.stateManager.getProjectId() + const body: Record = {} + if (params.name !== undefined) { + body['name'] = params.name + } + if (params.subject !== undefined) { + body['subject'] = params.subject + } + if (params.html !== undefined) { + body['html'] = params.html + } + if (params.text !== undefined) { + body['text'] = params.text + } + if (params.preheader !== undefined) { + body['preheader'] = params.preheader + } + if (params.source !== undefined) { + body['source'] = params.source + } + const result = await context.api.request({ + method: 'PUT', + path: `/api/projects/${encodeURIComponent(String(projectId))}/messaging_templates/by_key/${encodeURIComponent(String(params.key))}/`, + body, + }) + return await withPostHogUrl(context, result, `/workflows/library/templates/${result.id}`) + }, +}) + const WorkflowsUpdateEmailTemplateSchema = () => { const MessagingTemplatesPartialUpdateBody = orvalSchemas.MessagingTemplatesPartialUpdateBody() const MessagingTemplatesPartialUpdateParams = orvalSchemas.MessagingTemplatesPartialUpdateParams() @@ -208,5 +252,6 @@ export const GENERATED_TOOLS: Record ToolBase> = { 'workflows-list-email-templates': workflowsListEmailTemplates, 'workflows-patch-email-template': workflowsPatchEmailTemplate, 'workflows-show-email-template': workflowsShowEmailTemplate, + 'workflows-upload-email-template': workflowsUploadEmailTemplate, 'workflows-update-email-template': workflowsUpdateEmailTemplate, } 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 dbd860a6fac9..8f723927235a 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,6 +66,10 @@ "required": ["schemaVersion", "body"], "type": "object" }, + "preheader": { + "description": "Inbox preview text shown after the subject. Supports Liquid templating.", + "type": "string" + }, "subject": { "description": "Email subject line. Supports Liquid templating. Required for email-type templates.", "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 91d43313d24e..5475ac1d2fb7 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,6 +66,10 @@ "required": ["schemaVersion", "body"], "type": "object" }, + "preheader": { + "description": "Inbox preview text shown after the subject. Supports Liquid templating.", + "type": "string" + }, "subject": { "description": "Email subject line. Supports Liquid templating. Required for email-type templates.", "type": "string" diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-upload-email-template.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-upload-email-template.json new file mode 100644 index 000000000000..23b96a96e631 --- /dev/null +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-upload-email-template.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "html": { + "description": "Email body as HTML, stored as sent. Supports Liquid, such as `{{ variables.first_name }}`.", + "type": "string" + }, + "key": { + "description": "Key of the template, chosen by you. Letters, digits, hyphens (-) and underscores (_), at most 400 characters. The same key names the template in every project.", + "type": "string" + }, + "name": { + "description": "Template name shown in the template library.", + "maxLength": 400, + "type": "string" + }, + "preheader": { + "description": "Inbox preview text shown after the subject. Leaving it out removes the stored one.", + "type": "string" + }, + "source": { + "description": "Where the template file lives, so people in PostHog can find it. Leaving it out, or a part of it, keeps what is stored.", + "properties": { + "path": { + "anyOf": [ + { + "maxLength": 400, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Path of the template file in the repository, such as `emails/welcome.html`." + }, + "ref": { + "anyOf": [ + { + "maxLength": 400, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Commit or branch the file was uploaded from." + }, + "repository": { + "anyOf": [ + { + "maxLength": 400, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Host and path of the repository the file is in, such as `github.com/example/app`." + } + }, + "type": "object" + }, + "subject": { + "description": "Email subject line. Supports Liquid, such as `{{ variables.first_name }}`.", + "type": "string" + }, + "text": { + "description": "Plain-text body for email clients that can't show HTML. Leaving it out removes the stored one.", + "type": "string" + } + }, + "required": ["key", "name", "subject", "html"], + "type": "object" +} diff --git a/services/mcp/tests/unit/tool-schema-snapshots.test.ts b/services/mcp/tests/unit/tool-schema-snapshots.test.ts index 4f8b6d2fff25..dbebee200219 100644 --- a/services/mcp/tests/unit/tool-schema-snapshots.test.ts +++ b/services/mcp/tests/unit/tool-schema-snapshots.test.ts @@ -104,6 +104,7 @@ async function loadSnapshotTools(): Promise[]> { 'scouts-tool-access': true, 'subscription-source-summaries': true, 'workflows-data-suggestions': true, + 'workflows-transactional-email-api': true, } return [...(await getToolsFromContext(createMockContext(), { featureFlags }))].sort((a, b) => a.name.localeCompare(b.name) From 65d441ac6e9d270ed650f2e93997248cf32e7143 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:54:13 +0000 Subject: [PATCH 03/30] refactor(messaging): serialize upload errors and return dataclasses Co-Authored-By: Claude Opus 5.5 --- .../messaging/backend/facade/templates.py | 4 +- .../presentation/views/message_templates.py | 45 +++++++++---------- .../backend/services/template_uploads.py | 20 ++++++--- .../backend/tests/api/test_template_upload.py | 2 +- 4 files changed, 39 insertions(+), 32 deletions(-) diff --git a/products/messaging/backend/facade/templates.py b/products/messaging/backend/facade/templates.py index 78166bb42236..5c135c65e3b7 100644 --- a/products/messaging/backend/facade/templates.py +++ b/products/messaging/backend/facade/templates.py @@ -166,11 +166,11 @@ def upload_template( there is none. Raises TransactionalEmailError when the template can't send. `require_workflow_create` runs before a new workflow is made, and raises to refuse the upload. """ - result, template = template_uploads.upload_template( + uploaded = template_uploads.upload_template( team_id, upload, user_id=user_id, require_workflow_editor=require_workflow_editor, require_workflow_create=require_workflow_create, ) - return UploadedTemplate(result=result, template=_to_contract(template)) + return UploadedTemplate(result=uploaded.result, template=_to_contract(uploaded.template)) diff --git a/products/messaging/backend/presentation/views/message_templates.py b/products/messaging/backend/presentation/views/message_templates.py index d97731bd268c..a3ff1721ccf5 100644 --- a/products/messaging/backend/presentation/views/message_templates.py +++ b/products/messaging/backend/presentation/views/message_templates.py @@ -390,7 +390,7 @@ class UploadTemplateSerializer(serializers.Serializer): trim_whitespace=False, help_text="Inbox preview text shown after the subject. Leaving it out removes the stored one.", ) - source = TemplateSourceSerializer( + source = TemplateSourceSerializer( # type: ignore[assignment] # field named `source` shadows DRF Field.source required=False, help_text="Where the template file lives, so people in PostHog can find it. Leaving it out, or a part of " "it, keeps what is stored.", @@ -410,7 +410,9 @@ class UploadTemplateResponseSerializer(serializers.Serializer): "PostHog holds the content you hold." ) managed_by = serializers.ChoiceField(choices=TemplateManagedBy.choices, help_text="Always `code` after an upload.") - source = TemplateSourceSerializer(help_text="Where the template file lives, as stored after the upload.") + source = TemplateSourceSerializer( # type: ignore[assignment] # field named `source` shadows DRF Field.source + help_text="Where the template file lives, as stored after the upload." + ) transactional_hog_flow_id = serializers.UUIDField( help_text="The workflow that sends this template. Each upload that changes the content writes a new version " "of it." @@ -437,20 +439,17 @@ class UploadRefused: attr: str | None why: str fix: str + type: str = "invalid_request" - def response(self) -> Response: - return Response( - { - "type": "invalid_request", - "code": self.code, - "detail": self.detail, - "attr": self.attr, - "why": self.why, - "fix": self.fix, - "docs_url": f"{TRANSACTIONAL_EMAIL_DOCS_URL}#{self.code}", - }, - status=status.HTTP_400_BAD_REQUEST, - ) + @property + def docs_url(self) -> str: + return f"{TRANSACTIONAL_EMAIL_DOCS_URL}#{self.code}" + + +@frozen +class FieldError: + attr: str + message: str def invalid_template_key(key: str) -> UploadRefused | None: @@ -466,23 +465,23 @@ def invalid_template_key(key: str) -> UploadRefused | None: def invalid_upload(errors: Any) -> UploadRefused: - attr, message = first_field_error(errors) + error = first_field_error(errors) return UploadRefused( code="invalid_request", - detail=f"{attr}: {message}", - attr=attr, + detail=f"{error.attr}: {error.message}", + attr=error.attr, why="An upload needs a name, a subject and HTML, each as a string.", - fix=f"Fix `{attr}` and upload again.", + fix=f"Fix `{error.attr}` and upload again.", ) -def first_field_error(errors: Any, path: tuple[str, ...] = ()) -> tuple[str, str]: +def first_field_error(errors: Any, path: tuple[str, ...] = ()) -> FieldError: if isinstance(errors, dict): field, nested = next(iter(errors.items())) return first_field_error(nested, (*path, str(field))) if isinstance(errors, list) and errors and not isinstance(errors[0], str): return first_field_error(errors[0], path) - return "__".join(path), str(errors[0] if isinstance(errors, list) else errors) + return FieldError(attr="__".join(path), message=str(errors[0] if isinstance(errors, list) else errors)) TEMPLATE_KEY_PARAMETER = OpenApiParameter( @@ -831,7 +830,7 @@ def upload(self, request: Request, key: str, *args: Any, **kwargs: Any) -> Respo refused = invalid_upload(body.errors) if refused is not None: self._report_template_upload(request, {"result": "rejected", "error_code": refused.code}) - return refused.response() + return Response(TemplateUploadErrorSerializer(refused).data, status=status.HTTP_400_BAD_REQUEST) try: uploaded = upload_template( @@ -895,7 +894,7 @@ def _require_workflow_create(self) -> None: if not self.user_access_control.check_access_level_for_resource("hog_flow", "editor"): raise exceptions.PermissionDenied("You need editor access to workflows to upload a new template.") - def _report_template_upload(self, request: Request, outcome: dict[str, Any]) -> None: + def _report_template_upload(self, request: Any, outcome: dict[str, Any]) -> None: # report_user_action drops the synthetic user behind a project secret API key, so capture directly. try: posthoganalytics.capture( diff --git a/products/messaging/backend/services/template_uploads.py b/products/messaging/backend/services/template_uploads.py index ac1998878f40..8fb5a9dda681 100644 --- a/products/messaging/backend/services/template_uploads.py +++ b/products/messaging/backend/services/template_uploads.py @@ -5,6 +5,8 @@ from django.db import IntegrityError, transaction +from posthog.dataclasses import frozen + from products.messaging.backend.facade.contracts import ( SenderUnavailable, TemplateManagedBy, @@ -24,6 +26,12 @@ WorkflowCreateCheck = Callable[[], None] +@frozen +class Uploaded: + result: TemplateUploadResult + template: MessageTemplate + + def upload_template( team_id: int, upload: TemplateUpload, @@ -31,8 +39,8 @@ def upload_template( user_id: int | None, require_workflow_editor: WorkflowEditorCheck, require_workflow_create: WorkflowCreateCheck, -) -> tuple[TemplateUploadResult, MessageTemplate]: - def attempt() -> tuple[TemplateUploadResult, MessageTemplate]: +) -> Uploaded: + def attempt() -> Uploaded: with transaction.atomic(): return _upload( team_id, @@ -58,14 +66,14 @@ def _upload( user_id: int | None, require_workflow_editor: WorkflowEditorCheck, require_workflow_create: WorkflowCreateCheck, -) -> tuple[TemplateUploadResult, MessageTemplate]: +) -> Uploaded: template = locked_template_with_key(team_id, upload.key) if template is None: require_workflow_create() - return TemplateUploadResult.CREATED, _create(team_id, upload, user_id=user_id) + return Uploaded(result=TemplateUploadResult.CREATED, template=_create(team_id, upload, user_id=user_id)) if _is_unchanged(template, upload): _save_source(template, upload.source) - return TemplateUploadResult.UNCHANGED, template + return Uploaded(result=TemplateUploadResult.UNCHANGED, template=template) save_locked_template( template, lambda locked: _write_upload(locked, upload), @@ -76,7 +84,7 @@ def _upload( # An adopted template, or one whose workflow was deleted. require_workflow_create() _make_transactional(template, user_id=user_id) - return TemplateUploadResult.UPDATED, template + return Uploaded(result=TemplateUploadResult.UPDATED, template=template) def locked_template_with_key(team_id: int, key: str) -> MessageTemplate | None: diff --git a/products/messaging/backend/tests/api/test_template_upload.py b/products/messaging/backend/tests/api/test_template_upload.py index 8dd72822e18a..6706c6da6973 100644 --- a/products/messaging/backend/tests/api/test_template_upload.py +++ b/products/messaging/backend/tests/api/test_template_upload.py @@ -17,7 +17,7 @@ from products.messaging.backend.services.transactional_email import make_transactional from products.workflows.backend.facade.api import set_default_email_sender_if_unset -WELCOME = { +WELCOME: dict[str, Any] = { "name": "Welcome", "subject": "Welcome, {{ variables.first_name }}", "html": "

Hi {{ variables.first_name }}

", From bfe29ccc10d12f4ddde4ddd7ec6de48f73713e60 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:17:25 +0000 Subject: [PATCH 04/30] feat(messaging): refuse edits to code-managed email templates A template uploaded from code is replaced by its next upload, so an edit made in PostHog was lost without warning. One ownership policy now refuses every write to a template with managed_by = code except the upload, sends, make transactional and a release. It answers 403 immutable with why, fix (naming the file when the source is known) and docs_url. The policy keys on the action and the whole payload, so a write action added later is refused until someone allows it. It runs on the row read by the permission check, and again on the row locked by the save path, so an upload that claims the template mid-request still wins. A PATCH whose only field is managed_by: gui releases the template. It keeps its key, so the next upload claims it again. The MCP update and design tool descriptions point to the upload tool. Co-Authored-By: Claude Opus 5.5 --- .../messaging/backend/facade/contracts.py | 22 +++ .../messaging/backend/facade/templates.py | 36 +++- .../presentation/views/message_templates.py | 78 ++++++-- .../backend/services/template_ownership.py | 62 ++++++ .../backend/services/template_saves.py | 22 ++- .../api/test_code_managed_template_lock.py | 183 ++++++++++++++++++ .../backend/tests/api/test_template_upload.py | 4 + .../frontend/generated/api.schemas.ts | 44 ++--- products/workflows/mcp/email_templates.yaml | 6 +- .../schema/generated-tool-definitions.json | 4 +- services/mcp/schema/tool-definitions-all.json | 4 +- services/mcp/src/api/generated.ts | 44 ++--- 12 files changed, 437 insertions(+), 72 deletions(-) create mode 100644 products/messaging/backend/services/template_ownership.py create mode 100644 products/messaging/backend/tests/api/test_code_managed_template_lock.py diff --git a/products/messaging/backend/facade/contracts.py b/products/messaging/backend/facade/contracts.py index e38b9cea98e9..5e86edc4ecad 100644 --- a/products/messaging/backend/facade/contracts.py +++ b/products/messaging/backend/facade/contracts.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from dataclasses import dataclass from typing import Any from uuid import UUID @@ -91,6 +92,27 @@ class TemplateUploadResult(LabeledStrEnum): UNCHANGED = "unchanged", "Unchanged" +@frozen +class TemplateWriteIntent: + """A write to a template: the API action that carries it and its whole request body.""" + + action: str + payload: Mapping[str, Any] + + +class TemplateManagedByCode(Exception): + """The template is managed by code, so it refuses this write. `detail`, `why` and `fix` are written for the + caller.""" + + code = "immutable" + + def __init__(self, *, detail: str, why: str, fix: str) -> None: + super().__init__(detail) + self.detail = detail + self.why = why + self.fix = fix + + @frozen class TemplateSource: """Where the uploaded file lives. A part that is None leaves the stored part as it is.""" diff --git a/products/messaging/backend/facade/templates.py b/products/messaging/backend/facade/templates.py index 5c135c65e3b7..efac450d93b9 100644 --- a/products/messaging/backend/facade/templates.py +++ b/products/messaging/backend/facade/templates.py @@ -9,10 +9,16 @@ from posthog.dataclasses import frozen -from products.messaging.backend.facade.contracts import TemplateManagedBy, TemplateUpload, TemplateUploadResult +from products.messaging.backend.facade.contracts import ( + TemplateManagedBy, + TemplateUpload, + TemplateUploadResult, + TemplateWriteIntent, +) from products.messaging.backend.models.message_template import MessageTemplate from products.messaging.backend.services import ( message_templates as templates_service, + template_ownership, template_saves, template_uploads, ) @@ -110,19 +116,22 @@ def save_template_fields( template_id: UUID, fields: dict[str, Any], *, + intent: TemplateWriteIntent, user_id: int | None, require_workflow_editor: Callable[[UUID], None], ) -> MessageTemplateRow: """Set validated fields and save the template with its workflow. `message_category` holds a category id or None. - Raises TransactionalEmailError when the template could no longer send. `require_workflow_editor` gets the id of - the template's workflow, under the row lock, and raises to refuse the save. + Raises TransactionalEmailError when the template could no longer send, and TemplateManagedByCode when the template + refuses `intent`. `require_workflow_editor` gets the id of the template's workflow, under the row lock, and raises + to refuse the save. """ return _to_contract( template_saves.save_template( team_id, template_id, lambda template: templates_service.apply_fields(template, fields), + intent=intent, user_id=user_id, require_workflow_editor=require_workflow_editor, ) @@ -134,6 +143,7 @@ def save_template_content( template_id: UUID, edit: Callable[[dict[str, Any]], dict[str, Any]], *, + intent: TemplateWriteIntent, user_id: int | None, require_workflow_editor: Callable[[UUID], None], ) -> MessageTemplateRow: @@ -146,12 +156,32 @@ def save_template_content( team_id, template_id, lambda template: templates_service.edit_content(template, edit), + intent=intent, user_id=user_id, require_workflow_editor=require_workflow_editor, ) ) +def check_template_write(template: MessageTemplateRow, intent: TemplateWriteIntent) -> None: + """Raise TemplateManagedByCode when the template is managed by code and refuses `intent`.""" + template_ownership.check_write(template, intent) + + +def is_release(intent: TemplateWriteIntent) -> bool: + """Whether `intent` hands a template managed by code back to PostHog.""" + return template_ownership.is_release(intent) + + +def release_template( + team_id: int, template_id: UUID, *, require_workflow_editor: Callable[[UUID], None] +) -> MessageTemplateRow: + """Hand the template back to PostHog, so it takes edits again. It keeps its key, so the next upload claims it.""" + return _to_contract( + template_saves.release_template(team_id, template_id, require_workflow_editor=require_workflow_editor) + ) + + def upload_template( team_id: int, upload: TemplateUpload, diff --git a/products/messaging/backend/presentation/views/message_templates.py b/products/messaging/backend/presentation/views/message_templates.py index a3ff1721ccf5..87aa84a07028 100644 --- a/products/messaging/backend/presentation/views/message_templates.py +++ b/products/messaging/backend/presentation/views/message_templates.py @@ -1,5 +1,5 @@ import re -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import fields as dataclass_fields from types import SimpleNamespace from typing import Any @@ -14,7 +14,7 @@ extend_schema_field, extend_schema_view, ) -from rest_framework import exceptions, serializers, status, viewsets +from rest_framework import exceptions, permissions, serializers, status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import NotFound from rest_framework.permissions import IsAuthenticated @@ -44,18 +44,23 @@ TEMPLATE_KEY_MAX_LENGTH, TEMPLATE_KEY_PATTERN, TemplateManagedBy, + TemplateManagedByCode, TemplateSource, TemplateUpload, TemplateUploadResult, + TemplateWriteIntent, ) from products.messaging.backend.facade.templates import ( MessageCategoryNotInTeam, MessageTemplateMissing, MessageTemplateRow, UploadedTemplate, + check_template_write, create_template, get_template, + is_release, list_templates, + release_template, save_template_content, save_template_fields, team_category_id, @@ -227,8 +232,9 @@ class MessageTemplateSerializer(serializers.Serializer): choices=TemplateManagedBy.choices, read_only=True, allow_null=True, - help_text="Who owns the content. `code` when it was uploaded from a repository, so the next upload replaces " - "edits made in PostHog. `gui` or null when it is edited in PostHog.", + help_text="Who owns the content. `code` when it was uploaded from a repository: PostHog then refuses edits, and " + "the template changes through its next upload. To edit it in PostHog, send a PATCH whose only field is " + "`managed_by` set to `gui`. `gui` or null when it is edited in PostHog.", ) source_repository = serializers.CharField( read_only=True, @@ -419,7 +425,7 @@ class UploadTemplateResponseSerializer(serializers.Serializer): ) -class TemplateUploadErrorSerializer(serializers.Serializer): +class TemplateErrorSerializer(serializers.Serializer): type = serializers.CharField(help_text="Error class, such as `invalid_request`.") code = serializers.CharField(help_text="Stable error code to branch on, such as `invalid_template_key`.") detail = serializers.CharField(help_text="What went wrong.") @@ -433,7 +439,7 @@ class TemplateUploadErrorSerializer(serializers.Serializer): @frozen -class UploadRefused: +class TemplateRefusal: code: str detail: str attr: str | None @@ -452,10 +458,10 @@ class FieldError: message: str -def invalid_template_key(key: str) -> UploadRefused | None: +def invalid_template_key(key: str) -> TemplateRefusal | None: if len(key) <= TEMPLATE_KEY_MAX_LENGTH and re.match(TEMPLATE_KEY_PATTERN, key): return None - return UploadRefused( + return TemplateRefusal( code="invalid_template_key", detail=f"The key can have at most {TEMPLATE_KEY_MAX_LENGTH} letters, digits, hyphens (-) and underscores (_).", attr="key", @@ -464,9 +470,9 @@ def invalid_template_key(key: str) -> UploadRefused | None: ) -def invalid_upload(errors: Any) -> UploadRefused: +def invalid_upload(errors: Any) -> TemplateRefusal: error = first_field_error(errors) - return UploadRefused( + return TemplateRefusal( code="invalid_request", detail=f"{error.attr}: {error.message}", attr=error.attr, @@ -531,10 +537,19 @@ class TransactionalEmailTeamSustainedThrottle(ProjectSecretApiKeyTeamRateThrottl rate = "20000/hour" +MANAGED_BY_CODE_RESPONSE = OpenApiResponse( + response=TemplateErrorSerializer, + description="The template is managed by code. `immutable`: change its file and upload it, or release it first", +) +TEMPLATE_UPDATE_SCHEMA = extend_schema( + parameters=[TEMPLATE_ID_PARAMETER], responses={200: MessageTemplateSerializer, 403: MANAGED_BY_CODE_RESPONSE} +) + + @extend_schema_view( retrieve=extend_schema(parameters=[TEMPLATE_ID_PARAMETER]), - update=extend_schema(parameters=[TEMPLATE_ID_PARAMETER]), - partial_update=extend_schema(parameters=[TEMPLATE_ID_PARAMETER]), + update=TEMPLATE_UPDATE_SCHEMA, + partial_update=TEMPLATE_UPDATE_SCHEMA, destroy=extend_schema(parameters=[TEMPLATE_ID_PARAMETER]), ) class MessageTemplatesViewSet( @@ -583,6 +598,21 @@ def dangerously_get_object(self) -> MessageTemplateRow: self.check_object_permissions(self.request, template) return template + def check_object_permissions(self, request: Request, obj: Any) -> None: + super().check_object_permissions(request, obj) + if request.method not in permissions.SAFE_METHODS: + check_template_write(obj, self._write_intent()) + + def _write_intent(self) -> TemplateWriteIntent: + payload = self.request.data + return TemplateWriteIntent(action=self.action, payload=payload if isinstance(payload, Mapping) else {}) + + def handle_exception(self, exc: Exception) -> Response: + if isinstance(exc, TemplateManagedByCode): + refusal = TemplateRefusal(code=exc.code, detail=exc.detail, attr=None, why=exc.why, fix=exc.fix) + return Response(TemplateErrorSerializer(refusal).data, status=status.HTTP_403_FORBIDDEN) + return super().handle_exception(exc) + def _with_creators(self, templates: Sequence[MessageTemplateRow]) -> list[SimpleNamespace]: creator_ids = {template.created_by_id for template in templates if template.created_by_id} creators = {user.id: user for user in User.objects.filter(id__in=creator_ids)} if creator_ids else {} @@ -637,6 +667,13 @@ def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: def update(self, request: Request, *args: Any, **kwargs: Any) -> Response: partial = kwargs.pop("partial", False) template = self.get_object() + intent = self._write_intent() + if is_release(intent): + released = release_template( + self.team_id, template.id, require_workflow_editor=self._require_workflow_editor + ) + self._emit_resource_edited(released) + return Response(self._serialize(released)) serializer = self.get_serializer(self._with_creators([template])[0], data=request.data, partial=partial) serializer.is_valid(raise_exception=True) try: @@ -644,6 +681,7 @@ def update(self, request: Request, *args: Any, **kwargs: Any) -> Response: self.team_id, template.id, serializer.validated_data, + intent=intent, user_id=self._actor_id(), require_workflow_editor=self._require_workflow_editor, ) @@ -691,7 +729,9 @@ def _emit_resource_edited(self, instance: MessageTemplateRow) -> None: logger.warning("Failed to publish message template edited event", error=str(e)) @extend_schema( - request=DesignPatchSerializer, responses={200: MessageTemplateSerializer}, parameters=[TEMPLATE_ID_PARAMETER] + request=DesignPatchSerializer, + responses={200: MessageTemplateSerializer, 403: MANAGED_BY_CODE_RESPONSE}, + parameters=[TEMPLATE_ID_PARAMETER], ) @action(detail=True, methods=["PATCH"]) def design(self, request: Request, *args: Any, **kwargs: Any) -> Response: @@ -699,13 +739,14 @@ def design(self, request: Request, *args: Any, **kwargs: Any) -> Response: # of re-transmitting the whole design JSON. Reads, applies, validates, re-renders, and saves # atomically so a rejected batch leaves the template untouched (and concurrent visual-editor saves # can't interleave). - op_serializer = DesignPatchSerializer(data=request.data) - op_serializer.is_valid(raise_exception=True) - operations = op_serializer.validated_data["operations"] # Authorize + team-scope via the normal lookup, then re-read FOR UPDATE inside the transaction. template = self.get_object() + op_serializer = DesignPatchSerializer(data=request.data) + op_serializer.is_valid(raise_exception=True) + operations = op_serializer.validated_data["operations"] + def apply_operations(content: dict[str, Any]) -> dict[str, Any]: email = content.get("email") or {} design = email.get("design") @@ -737,6 +778,7 @@ def apply_operations(content: dict[str, Any]) -> dict[str, Any]: self.team_id, template.id, apply_operations, + intent=self._write_intent(), user_id=self._actor_id(), require_workflow_editor=self._require_workflow_editor, ) @@ -804,7 +846,7 @@ def send(self, request: Any, *args: Any, **kwargs: Any) -> Response: ), 201: OpenApiResponse(response=UploadTemplateResponseSerializer, description="The template was created"), 400: OpenApiResponse( - response=TemplateUploadErrorSerializer, + response=TemplateErrorSerializer, description="The key or a field is invalid, the template can't be sent, or there is no verified " "default sender", ), @@ -830,7 +872,7 @@ def upload(self, request: Request, key: str, *args: Any, **kwargs: Any) -> Respo refused = invalid_upload(body.errors) if refused is not None: self._report_template_upload(request, {"result": "rejected", "error_code": refused.code}) - return Response(TemplateUploadErrorSerializer(refused).data, status=status.HTTP_400_BAD_REQUEST) + return Response(TemplateErrorSerializer(refused).data, status=status.HTTP_400_BAD_REQUEST) try: uploaded = upload_template( diff --git a/products/messaging/backend/services/template_ownership.py b/products/messaging/backend/services/template_ownership.py new file mode 100644 index 000000000000..eeeec1384511 --- /dev/null +++ b/products/messaging/backend/services/template_ownership.py @@ -0,0 +1,62 @@ +"""Which writes a template managed by code still takes. + +Its content comes from a file that code uploads, so an edit made in PostHog would be lost on the next upload. The +policy names the actions it allows and refuses every other write, so a write action added later stays refused until +someone decides otherwise. +""" + +from typing import Final, Protocol + +from products.messaging.backend.facade.contracts import TemplateManagedBy, TemplateManagedByCode, TemplateWriteIntent + +# Operating a template does not change its content, so the file has no opinion on these. +ALLOWED_ACTIONS: Final = frozenset({"upload", "send", "make_transactional"}) +RELEASE_ACTION: Final = "partial_update" + + +class OwnedTemplate(Protocol): + @property + def managed_by(self) -> str | None: ... + + @property + def source_repository(self) -> str | None: ... + + @property + def source_path(self) -> str | None: ... + + +def is_release(intent: TemplateWriteIntent) -> bool: + """A PATCH whose only field is `managed_by: gui`. Matched on the whole body, so a form spread into a PATCH never + releases a template by accident.""" + payload = intent.payload + return ( + intent.action == RELEASE_ACTION + and set(payload.keys()) == {"managed_by"} + and payload.get("managed_by") == TemplateManagedBy.GUI + ) + + +def check_write(template: OwnedTemplate, intent: TemplateWriteIntent) -> None: + """Raise TemplateManagedByCode unless the template takes this write. Check the row read under the write's lock + too, because an upload can claim the template between an earlier read and the lock.""" + if template.managed_by != TemplateManagedBy.CODE: + return + if intent.action in ALLOWED_ACTIONS or is_release(intent): + return + raise TemplateManagedByCode( + detail="This template is managed by code, so it can't be changed in PostHog.", + why="Its content comes from a file in a repository. The next upload of that file replaces changes made here.", + fix=f"Change {_template_file(template)} and upload it again. To edit the template in PostHog instead, stop " + "managing it in code first: send a PATCH whose only field is `managed_by` set to `gui`.", + ) + + +def _template_file(template: OwnedTemplate) -> str: + path, repository = template.source_path, template.source_repository + if path and repository: + return f"`{path}` in `{repository}`" + if path: + return f"`{path}`" + if repository: + return f"the template's file in `{repository}`" + return "the template's file" diff --git a/products/messaging/backend/services/template_saves.py b/products/messaging/backend/services/template_saves.py index 725fd7f1e4f0..e06f9208ee2b 100644 --- a/products/messaging/backend/services/template_saves.py +++ b/products/messaging/backend/services/template_saves.py @@ -7,8 +7,10 @@ from django.db import transaction +from products.messaging.backend.facade.contracts import TemplateManagedBy, TemplateWriteIntent from products.messaging.backend.models import MessageTemplate from products.messaging.backend.services.message_templates import record_content_hash +from products.messaging.backend.services.template_ownership import check_write from products.messaging.backend.services.transactional_email import check_still_sendable, sync_transactional_workflow TemplateWrite = Callable[[MessageTemplate], None] @@ -20,17 +22,35 @@ def save_template( template_id: UUID, write: TemplateWrite, *, + intent: TemplateWriteIntent, user_id: int | None, require_workflow_editor: WorkflowEditorCheck, ) -> MessageTemplate: """Apply `write` to the locked template and save it. An exception from `write` or from the workflow sync rolls - the whole save back, so the template and its workflow never disagree.""" + the whole save back, so the template and its workflow never disagree. Raises TemplateManagedByCode when the + locked template refuses `intent`.""" with transaction.atomic(): template = lock_template(team_id, template_id) + check_write(template, intent) save_locked_template(template, write, user_id=user_id, require_workflow_editor=require_workflow_editor) return template +def release_template( + team_id: int, template_id: UUID, *, require_workflow_editor: WorkflowEditorCheck +) -> MessageTemplate: + """Hand a template managed by code back to PostHog. It keeps its key, so the next upload claims it again. The + content and the workflow stay as they are.""" + with transaction.atomic(): + template = lock_template(team_id, template_id) + if template.transactional_hog_flow_id: + require_workflow_editor(template.transactional_hog_flow_id) + if template.managed_by == TemplateManagedBy.CODE: + template.managed_by = TemplateManagedBy.GUI + template.save(update_fields=["managed_by", "updated_at"]) + return template + + def lock_template(team_id: int, template_id: UUID) -> MessageTemplate: return MessageTemplate.objects.select_for_update().get(team_id=team_id, id=template_id, deleted=False) diff --git a/products/messaging/backend/tests/api/test_code_managed_template_lock.py b/products/messaging/backend/tests/api/test_code_managed_template_lock.py new file mode 100644 index 000000000000..c2ccaaf0b4bf --- /dev/null +++ b/products/messaging/backend/tests/api/test_code_managed_template_lock.py @@ -0,0 +1,183 @@ +from typing import Any + +from posthog.test.base import APIBaseTest +from unittest.mock import MagicMock, patch + +from parameterized import parameterized +from rest_framework import status +from rest_framework.test import APIClient + +from posthog.models import ProjectSecretAPIKey +from posthog.models.integration import Integration +from posthog.models.personal_api_key import PersonalAPIKey, hash_key_value +from posthog.models.utils import generate_random_token_personal, generate_random_token_secret + +from products.messaging.backend.models.message_template import MessageTemplate +from products.messaging.backend.presentation.views.message_templates import MessageTemplatesViewSet +from products.workflows.backend.facade.api import set_default_email_sender_if_unset + +WELCOME: dict[str, Any] = { + "name": "Welcome", + "subject": "Welcome, {{ variables.first_name }}", + "html": "

Hi {{ variables.first_name }}

", + "source": {"repository": "github.com/example/app", "path": "emails/welcome.html", "ref": "3f2c1ab"}, +} +WELCOME_HASH = "v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6" +EDITS: list[tuple[str, str, str, dict[str, Any]]] = [ + ( + "update", + "put", + "", + {"name": "Welcome", "content": {"email": {"subject": "Edited", "html": "

Edited

"}}, "type": "email"}, + ), + ("partial_update", "patch", "", {"name": "Renamed in PostHog"}), + ("soft_delete", "patch", "", {"deleted": True}), + ("release_with_an_edit", "patch", "", {"managed_by": "gui", "name": "Renamed in PostHog"}), + ( + "design", + "patch", + "design/", + {"operations": [{"op": "update_body", "patch": {"values": {"backgroundColor": "#000000"}}}]}, + ), +] + + +@patch("products.messaging.backend.presentation.views.message_templates.posthoganalytics.capture") +@patch( + "products.messaging.backend.presentation.views.message_templates.posthoganalytics.feature_enabled", + return_value=True, +) +@patch("posthog.plugins.plugin_server_api.internal_requests.post") +class TestCodeManagedTemplateLock(APIBaseTest): + def setUp(self): + super().setUp() + sender = Integration.objects.create( + team=self.team, kind="email", integration_id="hello@example.com", config={"verified": True} + ) + set_default_email_sender_if_unset(self.team.id, sender.id) + + def _upload(self, body: dict[str, Any], key: str = "welcome") -> dict[str, Any]: + response = self.client.put( + f"/api/projects/{self.team.id}/messaging_templates/by_key/{key}/", body, format="json" + ) + assert response.status_code in (status.HTTP_200_OK, status.HTTP_201_CREATED), response.json() + return response.json() + + def _url(self, template_id: str, suffix: str = "") -> str: + return f"/api/projects/{self.team.id}/messaging_templates/{template_id}/{suffix}" + + def _template(self, template_id: str) -> dict[str, Any]: + return self.client.get(self._url(template_id)).json() + + def _workflow_version(self, hog_flow_id: str) -> int: + return self.client.get(f"/api/projects/{self.team.id}/hog_flows/{hog_flow_id}").json()["version"] + + def _rename(self, template_id: str, client: APIClient | None = None) -> Any: + return (client or self.client).patch(self._url(template_id), {"name": "Renamed in PostHog"}, format="json") + + def _assert_refused(self, response: Any) -> dict[str, Any]: + assert response.status_code == status.HTTP_403_FORBIDDEN, response.json() + body = response.json() + assert body["code"] == "immutable" + assert body["docs_url"] == "https://posthog.com/docs/workflows/transactional-email-api#immutable" + assert body["detail"] and body["why"] + return body + + @parameterized.expand(EDITS) + def test_refuses_an_edit_and_says_how_to_change_it_in_code( + self, _post, _flag, _capture, _name, method, suffix, body + ): + uploaded = self._upload(WELCOME) + + response = getattr(self.client, method)(self._url(uploaded["id"], suffix), body, format="json") + + refusal = self._assert_refused(response) + assert "`emails/welcome.html` in `github.com/example/app`" in refusal["fix"] + template = self._template(uploaded["id"]) + assert (template["name"], template["deleted"], template["content_hash"]) == ("Welcome", False, WELCOME_HASH) + assert self._workflow_version(uploaded["transactional_hog_flow_id"]) == 1 + + def test_an_mcp_update_gets_the_same_refusal(self, _post, _flag, _capture): + uploaded = self._upload(WELCOME) + token = generate_random_token_personal() + PersonalAPIKey.objects.create( + label="MCP", user=self.user, secure_value=hash_key_value(token), scopes=["hog_flow:write"] + ) + mcp = APIClient() + mcp.credentials(HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_X_POSTHOG_CLIENT="mcp") + + self._assert_refused(self._rename(uploaded["id"], mcp)) + + @parameterized.expand( + [ + ("path_and_repository", WELCOME["source"], "`emails/welcome.html` in `github.com/example/app`"), + ("path_only", {"path": "emails/welcome.html"}, "`emails/welcome.html`"), + ("no_source", None, "the template's file"), + ] + ) + def test_the_fix_names_the_file_as_far_as_the_template_records_it( + self, _post, _flag, _capture, _name, source, expected_file + ): + body = {key: value for key, value in WELCOME.items() if key != "source"} + uploaded = self._upload({**body, "source": source} if source else body, key=_name.replace("_", "-")) + + refusal = self._assert_refused(self._rename(uploaded["id"])) + + assert refusal["fix"].startswith(f"Change {expected_file} and upload it again.") + + @patch("products.messaging.backend.services.transactional_email.reload_hog_flows_on_workers") + def test_a_code_managed_template_still_sends_and_stays_transactional( + self, _reload, post: MagicMock, _flag, _capture + ): + uploaded = self._upload(WELCOME) + post.return_value = MagicMock(status_code=202, json=lambda: {"invocation_id": "invocation-1"}) + raw_token = generate_random_token_secret() + ProjectSecretAPIKey.objects.create( + team=self.team, + label="lock test", + secure_value=hash_key_value(raw_token), + scopes=["hog_flow:write"], + mask_value=f"{raw_token[:4]}...{raw_token[-4:]}", + ) + psak = APIClient() + psak.credentials(HTTP_AUTHORIZATION=f"Bearer {raw_token}") + + sent = psak.post(self._url(uploaded["id"], "send/"), {"to": "jane@example.com"}, format="json") + made_transactional = self.client.post(self._url(uploaded["id"], "make_transactional/"), {}, format="json") + + assert sent.status_code == status.HTTP_202_ACCEPTED, sent.json() + assert made_transactional.status_code == status.HTTP_200_OK, made_transactional.json() + assert made_transactional.json()["transactional_hog_flow_id"] == uploaded["transactional_hog_flow_id"] + + def test_a_release_hands_the_template_to_posthog_until_the_next_upload(self, _post, _flag, _capture): + uploaded = self._upload(WELCOME) + + released = self.client.patch(self._url(uploaded["id"]), {"managed_by": "gui"}, format="json") + renamed = self._rename(uploaded["id"]) + claimed = self._upload(WELCOME) + refused = self.client.patch(self._url(uploaded["id"]), {"name": "Renamed again"}, format="json") + + assert released.status_code == status.HTTP_200_OK, released.json() + assert (released.json()["managed_by"], released.json()["key"]) == ("gui", "welcome") + assert renamed.status_code == status.HTTP_200_OK, renamed.json() + assert renamed.json()["name"] == "Renamed in PostHog" + assert (claimed["id"], claimed["result"], claimed["managed_by"]) == (uploaded["id"], "updated", "code") + self._assert_refused(refused) + assert self._template(uploaded["id"])["name"] == "Welcome" + + def test_an_upload_that_claims_the_template_mid_request_still_refuses_the_edit(self, _post, _flag, _capture): + uploaded = self._upload(WELCOME) + released = self.client.patch(self._url(uploaded["id"]), {"managed_by": "gui"}, format="json") + assert released.status_code == status.HTTP_200_OK, released.json() + original = MessageTemplatesViewSet.dangerously_get_object + + def read_then_upload_claims(viewset: MessageTemplatesViewSet) -> Any: + template = original(viewset) + MessageTemplate.objects.filter(id=template.id).update(managed_by="code") + return template + + with patch.object(MessageTemplatesViewSet, "dangerously_get_object", read_then_upload_claims): + response = self._rename(uploaded["id"]) + + self._assert_refused(response) + assert self._template(uploaded["id"])["name"] == "Welcome" diff --git a/products/messaging/backend/tests/api/test_template_upload.py b/products/messaging/backend/tests/api/test_template_upload.py index 6706c6da6973..6456a67bff06 100644 --- a/products/messaging/backend/tests/api/test_template_upload.py +++ b/products/messaging/backend/tests/api/test_template_upload.py @@ -244,6 +244,10 @@ def misses_the_winner_once(team_id: int, key: str) -> MessageTemplate | None: def test_a_publish_moves_the_replaced_hash_to_the_superseded_list(self, _post, _flag, _capture): first = self._upload(WELCOME) + released = self.client.patch( + f"/api/projects/{self.team.id}/messaging_templates/{first['id']}/", {"managed_by": "gui"}, format="json" + ) + assert released.status_code == status.HTTP_200_OK, released.json() edited = self.client.patch( f"/api/projects/{self.team.id}/messaging_templates/{first['id']}/", diff --git a/products/messaging/frontend/generated/api.schemas.ts b/products/messaging/frontend/generated/api.schemas.ts index 7870fba849a3..315dedf2450a 100644 --- a/products/messaging/frontend/generated/api.schemas.ts +++ b/products/messaging/frontend/generated/api.schemas.ts @@ -418,7 +418,7 @@ export interface MessageTemplateApi { * @nullable */ readonly key: string | null - /** Who owns the content. `code` when it was uploaded from a repository, so the next upload replaces edits made in PostHog. `gui` or null when it is edited in PostHog. + /** Who owns the content. `code` when it was uploaded from a repository: PostHog then refuses edits, and the template changes through its next upload. To edit it in PostHog, send a PATCH whose only field is `managed_by` set to `gui`. `gui` or null when it is edited in PostHog. * * * `gui` - GUI * * `code` - Code */ @@ -454,6 +454,26 @@ export interface PaginatedMessageTemplateListApi { results: MessageTemplateApi[] } +export interface TemplateErrorApi { + /** Error class, such as `invalid_request`. */ + type: string + /** Stable error code to branch on, such as `invalid_template_key`. */ + code: string + /** What went wrong. */ + detail: string + /** + * The request field the error is about, if any. + * @nullable + */ + attr: string | null + /** Why PostHog refuses the request. */ + why?: string + /** What to change before you retry. */ + fix?: string + /** Documentation for this error code. */ + docs_url?: string +} + export interface PatchedMessageTemplateApi { readonly id?: string /** @@ -490,7 +510,7 @@ export interface PatchedMessageTemplateApi { * @nullable */ readonly key?: string | null - /** Who owns the content. `code` when it was uploaded from a repository, so the next upload replaces edits made in PostHog. `gui` or null when it is edited in PostHog. + /** Who owns the content. `code` when it was uploaded from a repository: PostHog then refuses edits, and the template changes through its next upload. To edit it in PostHog, send a PATCH whose only field is `managed_by` set to `gui`. `gui` or null when it is edited in PostHog. * * * `gui` - GUI * * `code` - Code */ @@ -687,26 +707,6 @@ export interface UploadTemplateResponseApi { transactional_hog_flow_id: string } -export interface TemplateUploadErrorApi { - /** Error class, such as `invalid_request`. */ - type: string - /** Stable error code to branch on, such as `invalid_template_key`. */ - code: string - /** What went wrong. */ - detail: string - /** - * The request field the error is about, if any. - * @nullable - */ - attr: string | null - /** Why PostHog refuses the request. */ - why?: string - /** What to change before you retry. */ - fix?: string - /** Documentation for this error code. */ - docs_url?: string -} - export type MessagingCategoriesListParams = { /** * Number of results to return per page. diff --git a/products/workflows/mcp/email_templates.yaml b/products/workflows/mcp/email_templates.yaml index 7659bf49b1c3..2b42e1cd575a 100644 --- a/products/workflows/mcp/email_templates.yaml +++ b/products/workflows/mcp/email_templates.yaml @@ -95,7 +95,8 @@ tools: changes; ids and Unlayer numbering for added blocks are assigned for you. Operations apply atomically — the design is read, the ops applied in order, the result validated and re-rendered to HTML, and saved only if valid, otherwise nothing changes. After patching, call workflows-show-email-template so the user sees the - result. + result. A template whose managed_by is code refuses this with 403 immutable: change its file in the + repository and upload it with workflows-upload-email-template instead. response: exclude: - content @@ -158,7 +159,8 @@ tools: workflows-get-email-template first (the design may carry fresh human edits from the visual editor) and send back the complete content.email with your edited design. The server re-renders the sent email from the design on save. After updating, call workflows-show-email-template so the user sees the result, and share - the returned _posthogUrl edit link. + the returned _posthogUrl edit link. A template whose managed_by is code refuses every update with 403 + immutable: change its file in the repository and upload it with workflows-upload-email-template instead. exclude_params: - content.email.html response: diff --git a/services/mcp/schema/generated-tool-definitions.json b/services/mcp/schema/generated-tool-definitions.json index f3c6547dd7aa..88f181868815 100644 --- a/services/mcp/schema/generated-tool-definitions.json +++ b/services/mcp/schema/generated-tool-definitions.json @@ -15174,7 +15174,7 @@ } }, "workflows-patch-email-template": { - "description": "Surgically edit an email template's design by sending a small, ordered list of operations instead of resending the whole design JSON. Prefer this over workflows-update-email-template for any change to an existing design. Reference blocks by their id (call workflows-get-email-template first to read the current design and its ids). For the common case — tweak one block — use update_content with a deep-merge patch (e.g. {op: update_content, id: '', patch: {values: {text: '

New copy

'}}}); a null leaf deletes that key. Use add_content / remove_content / move_content / add_row / remove_row for structural changes; ids and Unlayer numbering for added blocks are assigned for you. Operations apply atomically — the design is read, the ops applied in order, the result validated and re-rendered to HTML, and saved only if valid, otherwise nothing changes. After patching, call workflows-show-email-template so the user sees the result.", + "description": "Surgically edit an email template's design by sending a small, ordered list of operations instead of resending the whole design JSON. Prefer this over workflows-update-email-template for any change to an existing design. Reference blocks by their id (call workflows-get-email-template first to read the current design and its ids). For the common case — tweak one block — use update_content with a deep-merge patch (e.g. {op: update_content, id: '', patch: {values: {text: '

New copy

'}}}); a null leaf deletes that key. Use add_content / remove_content / move_content / add_row / remove_row for structural changes; ids and Unlayer numbering for added blocks are assigned for you. Operations apply atomically — the design is read, the ops applied in order, the result validated and re-rendered to HTML, and saved only if valid, otherwise nothing changes. After patching, call workflows-show-email-template so the user sees the result. A template whose managed_by is code refuses this with 403 immutable: change its file in the repository and upload it with workflows-upload-email-template instead.", "category": "Workflows", "feature": "workflows", "summary": "Patch email template design", @@ -15301,7 +15301,7 @@ } }, "workflows-update-email-template": { - "description": "Replace an email template's content as a whole. For small edits to an existing design (change a heading, a button color, one paragraph), prefer workflows-patch-email-template — it sends just the changed fields instead of the entire design JSON. Use this tool to set content from scratch or replace it wholesale: call workflows-get-email-template first (the design may carry fresh human edits from the visual editor) and send back the complete content.email with your edited design. The server re-renders the sent email from the design on save. After updating, call workflows-show-email-template so the user sees the result, and share the returned _posthogUrl edit link.", + "description": "Replace an email template's content as a whole. For small edits to an existing design (change a heading, a button color, one paragraph), prefer workflows-patch-email-template — it sends just the changed fields instead of the entire design JSON. Use this tool to set content from scratch or replace it wholesale: call workflows-get-email-template first (the design may carry fresh human edits from the visual editor) and send back the complete content.email with your edited design. The server re-renders the sent email from the design on save. After updating, call workflows-show-email-template so the user sees the result, and share the returned _posthogUrl edit link. A template whose managed_by is code refuses every update with 403 immutable: change its file in the repository and upload it with workflows-upload-email-template instead.", "category": "Workflows", "feature": "workflows", "summary": "Update email template", diff --git a/services/mcp/schema/tool-definitions-all.json b/services/mcp/schema/tool-definitions-all.json index bc32edef6d66..b39d704accac 100644 --- a/services/mcp/schema/tool-definitions-all.json +++ b/services/mcp/schema/tool-definitions-all.json @@ -15891,7 +15891,7 @@ } }, "workflows-patch-email-template": { - "description": "Surgically edit an email template's design by sending a small, ordered list of operations instead of resending the whole design JSON. Prefer this over workflows-update-email-template for any change to an existing design. Reference blocks by their id (call workflows-get-email-template first to read the current design and its ids). For the common case — tweak one block — use update_content with a deep-merge patch (e.g. {op: update_content, id: '', patch: {values: {text: '

New copy

'}}}); a null leaf deletes that key. Use add_content / remove_content / move_content / add_row / remove_row for structural changes; ids and Unlayer numbering for added blocks are assigned for you. Operations apply atomically — the design is read, the ops applied in order, the result validated and re-rendered to HTML, and saved only if valid, otherwise nothing changes. After patching, call workflows-show-email-template so the user sees the result.", + "description": "Surgically edit an email template's design by sending a small, ordered list of operations instead of resending the whole design JSON. Prefer this over workflows-update-email-template for any change to an existing design. Reference blocks by their id (call workflows-get-email-template first to read the current design and its ids). For the common case — tweak one block — use update_content with a deep-merge patch (e.g. {op: update_content, id: '', patch: {values: {text: '

New copy

'}}}); a null leaf deletes that key. Use add_content / remove_content / move_content / add_row / remove_row for structural changes; ids and Unlayer numbering for added blocks are assigned for you. Operations apply atomically — the design is read, the ops applied in order, the result validated and re-rendered to HTML, and saved only if valid, otherwise nothing changes. After patching, call workflows-show-email-template so the user sees the result. A template whose managed_by is code refuses this with 403 immutable: change its file in the repository and upload it with workflows-upload-email-template instead.", "category": "Workflows", "feature": "workflows", "summary": "Patch email template design", @@ -16046,7 +16046,7 @@ } }, "workflows-update-email-template": { - "description": "Replace an email template's content as a whole. For small edits to an existing design (change a heading, a button color, one paragraph), prefer workflows-patch-email-template — it sends just the changed fields instead of the entire design JSON. Use this tool to set content from scratch or replace it wholesale: call workflows-get-email-template first (the design may carry fresh human edits from the visual editor) and send back the complete content.email with your edited design. The server re-renders the sent email from the design on save. After updating, call workflows-show-email-template so the user sees the result, and share the returned _posthogUrl edit link.", + "description": "Replace an email template's content as a whole. For small edits to an existing design (change a heading, a button color, one paragraph), prefer workflows-patch-email-template — it sends just the changed fields instead of the entire design JSON. Use this tool to set content from scratch or replace it wholesale: call workflows-get-email-template first (the design may carry fresh human edits from the visual editor) and send back the complete content.email with your edited design. The server re-renders the sent email from the design on save. After updating, call workflows-show-email-template so the user sees the result, and share the returned _posthogUrl edit link. A template whose managed_by is code refuses every update with 403 immutable: change its file in the repository and upload it with workflows-upload-email-template instead.", "category": "Workflows", "feature": "workflows", "summary": "Update email template", diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 97708cdfe086..53f9f8d4adf4 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -67195,7 +67195,7 @@ export namespace Schemas { * @nullable */ readonly key: string | null; - /** Who owns the content. `code` when it was uploaded from a repository, so the next upload replaces edits made in PostHog. `gui` or null when it is edited in PostHog. + /** Who owns the content. `code` when it was uploaded from a repository: PostHog then refuses edits, and the template changes through its next upload. To edit it in PostHog, send a PATCH whose only field is `managed_by` set to `gui`. `gui` or null when it is edited in PostHog. * * * `gui` - GUI * * `code` - Code */ @@ -83150,7 +83150,7 @@ export namespace Schemas { * @nullable */ readonly key?: string | null; - /** Who owns the content. `code` when it was uploaded from a repository, so the next upload replaces edits made in PostHog. `gui` or null when it is edited in PostHog. + /** Who owns the content. `code` when it was uploaded from a repository: PostHog then refuses edits, and the template changes through its next upload. To edit it in PostHog, send a PATCH whose only field is `managed_by` set to `gui`. `gui` or null when it is edited in PostHog. * * * `gui` - GUI * * `code` - Code */ @@ -112182,6 +112182,26 @@ export namespace Schemas { readonly retention_last_updated: string | null; } + export interface TemplateError { + /** Error class, such as `invalid_request`. */ + type: string; + /** Stable error code to branch on, such as `invalid_template_key`. */ + code: string; + /** What went wrong. */ + detail: string; + /** + * The request field the error is about, if any. + * @nullable + */ + attr: string | null; + /** Why PostHog refuses the request. */ + why?: string; + /** What to change before you retry. */ + fix?: string; + /** Documentation for this error code. */ + docs_url?: string; + } + export interface TemplateInfo { /** Template identifier, e.g. 'likely_active_soon'. Pass to autoresearch-resolve-template-create. * @@ -112226,26 +112246,6 @@ export namespace Schemas { ref?: string | null; } - export interface TemplateUploadError { - /** Error class, such as `invalid_request`. */ - type: string; - /** Stable error code to branch on, such as `invalid_template_key`. */ - code: string; - /** What went wrong. */ - detail: string; - /** - * The request field the error is about, if any. - * @nullable - */ - attr: string | null; - /** Why PostHog refuses the request. */ - why?: string; - /** What to change before you retry. */ - fix?: string; - /** Documentation for this error code. */ - docs_url?: string; - } - /** * * `created` - Created * * `updated` - Updated From 0221cb6ed98695d85ba9e59b94ed447e6457e033 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:18:06 +0000 Subject: [PATCH 05/30] feat(messaging): send an email template named by key or id, with a content hash POST /api/projects/{project_id}/messaging/send/email/ names the template in the body as {key, hash?} or {id} and sends it through the existing send service, behind workflows-transactional-email-api. Project secret API keys with hog_flow:write can call only this action. A hash the template never published answers 409 template_outdated and sends nothing. A hash newer content replaced sends the current content with a template_superseded warning, so an old deploy never brings old content back. The send usage event records how the template was addressed and how the hash compared. A generated MCP tool exposes the send. Co-Authored-By: Claude Opus 5.5 --- .../messaging/backend/facade/contracts.py | 18 ++ .../messaging/backend/facade/templates.py | 36 ++- .../presentation/views/message_templates.py | 209 ++++++++++-------- .../presentation/views/messaging_send.py | 194 ++++++++++++++++ products/messaging/backend/routes.py | 2 + .../backend/services/content_hash.py | 12 + .../backend/services/message_templates.py | 5 + .../backend/tests/api/test_template_upload.py | 38 ++++ .../tests/api/test_transactional_email.py | 173 +++++++++++++-- .../frontend/generated/api.schemas.ts | 99 ++++++--- products/messaging/frontend/generated/api.ts | 41 ++++ .../messaging/frontend/generated/api.zod.ts | 60 +++++ products/workflows/mcp/email_templates.yaml | 18 ++ .../schema/generated-tool-definitions.json | 15 ++ services/mcp/schema/tool-definitions-all.json | 15 ++ services/mcp/src/api/generated.ts | 83 +++++-- .../mcp/src/generated/email_templates/api.ts | 73 +++++- .../src/tools/generated/email_templates.ts | 33 +++ .../tool-schemas/workflows-send-email.json | 57 +++++ 19 files changed, 1009 insertions(+), 172 deletions(-) create mode 100644 products/messaging/backend/presentation/views/messaging_send.py create mode 100644 services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-send-email.json diff --git a/products/messaging/backend/facade/contracts.py b/products/messaging/backend/facade/contracts.py index e38b9cea98e9..e1369032cde8 100644 --- a/products/messaging/backend/facade/contracts.py +++ b/products/messaging/backend/facade/contracts.py @@ -91,6 +91,24 @@ class TemplateUploadResult(LabeledStrEnum): UNCHANGED = "unchanged", "Unchanged" +class ContentHashState(LabeledStrEnum): + """How the content hash a send carries compares with the template's published content.""" + + NONE = "none", "None" + CURRENT = "current", "Current" + SUPERSEDED = "superseded", "Superseded" + OUTDATED = "outdated", "Outdated" + + +@frozen +class TemplateReference: + """A template a send names by exactly one of `key` or `id`.""" + + key: str | None + id: UUID | None + hash: str | None + + @frozen class TemplateSource: """Where the uploaded file lives. A part that is None leaves the stored part as it is.""" diff --git a/products/messaging/backend/facade/templates.py b/products/messaging/backend/facade/templates.py index 5c135c65e3b7..e4ffbd30b496 100644 --- a/products/messaging/backend/facade/templates.py +++ b/products/messaging/backend/facade/templates.py @@ -9,13 +9,20 @@ from posthog.dataclasses import frozen -from products.messaging.backend.facade.contracts import TemplateManagedBy, TemplateUpload, TemplateUploadResult +from products.messaging.backend.facade.contracts import ( + ContentHashState, + TemplateManagedBy, + TemplateReference, + TemplateUpload, + TemplateUploadResult, +) from products.messaging.backend.models.message_template import MessageTemplate from products.messaging.backend.services import ( message_templates as templates_service, template_saves, template_uploads, ) +from products.messaging.backend.services.content_hash import sent_hash_state from products.messaging.backend.services.lazy_list import LazyList @@ -54,6 +61,12 @@ class UploadedTemplate: template: MessageTemplateRow +@frozen +class TemplateToSend: + template: MessageTemplateRow + hash_state: ContentHashState + + def _to_contract(row: MessageTemplate) -> MessageTemplateRow: return MessageTemplateRow( id=row.id, @@ -88,6 +101,27 @@ def get_template(team_id: int, template_id: UUID | str) -> MessageTemplateRow: raise MessageTemplateMissing() from e +def find_template_to_send(team_id: int, reference: TemplateReference) -> TemplateToSend: + """The template a send names, and how the hash the send carries compares with its published content. + + Raises MessageTemplateMissing when the team has no template with that key or id that is not deleted. + """ + try: + template = _referenced_template(team_id, reference) + except (MessageTemplate.DoesNotExist, ValidationError, ValueError, TypeError) as e: + raise MessageTemplateMissing() from e + hash_state = sent_hash_state(reference.hash, template.content_hash, template.superseded_content_hashes or []) + return TemplateToSend(template=_to_contract(template), hash_state=hash_state) + + +def _referenced_template(team_id: int, reference: TemplateReference) -> MessageTemplate: + if reference.key is not None: + return templates_service.team_template_with_key(team_id, reference.key) + if reference.id is not None: + return templates_service.team_template(team_id, reference.id) + raise MessageTemplate.DoesNotExist() + + def team_category_id(team_id: int, category_id: Any) -> UUID: """The id of the team's category with that pk, deleted or not. diff --git a/products/messaging/backend/presentation/views/message_templates.py b/products/messaging/backend/presentation/views/message_templates.py index a3ff1721ccf5..118397a50223 100644 --- a/products/messaging/backend/presentation/views/message_templates.py +++ b/products/messaging/backend/presentation/views/message_templates.py @@ -20,6 +20,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.settings import api_settings from posthog.api.forbid_destroy_model import ForbidDestroyModel from posthog.api.mixins import validated_request @@ -43,6 +44,8 @@ from products.messaging.backend.facade.contracts import ( TEMPLATE_KEY_MAX_LENGTH, TEMPLATE_KEY_PATTERN, + ContentHashState, + QueuedEmail, TemplateManagedBy, TemplateSource, TemplateUpload, @@ -419,7 +422,7 @@ class UploadTemplateResponseSerializer(serializers.Serializer): ) -class TemplateUploadErrorSerializer(serializers.Serializer): +class TransactionalEmailErrorSerializer(serializers.Serializer): type = serializers.CharField(help_text="Error class, such as `invalid_request`.") code = serializers.CharField(help_text="Stable error code to branch on, such as `invalid_template_key`.") detail = serializers.CharField(help_text="What went wrong.") @@ -433,7 +436,9 @@ class TemplateUploadErrorSerializer(serializers.Serializer): @frozen -class UploadRefused: +class RequestRefused: + """A refusal of the upload or send routes, with what to change before the caller retries.""" + code: str detail: str attr: str | None @@ -452,33 +457,35 @@ class FieldError: message: str -def invalid_template_key(key: str) -> UploadRefused | None: +def invalid_template_key(key: str, attr: str = "key") -> RequestRefused | None: if len(key) <= TEMPLATE_KEY_MAX_LENGTH and re.match(TEMPLATE_KEY_PATTERN, key): return None - return UploadRefused( + return RequestRefused( code="invalid_template_key", detail=f"The key can have at most {TEMPLATE_KEY_MAX_LENGTH} letters, digits, hyphens (-) and underscores (_).", - attr="key", - why="The key is part of the URL and names the template in every project, so it must stay the same.", + attr=attr, + why="Code names the template by its key in every project, so the key must stay the same.", fix="Use a key like `password-reset`.", ) -def invalid_upload(errors: Any) -> UploadRefused: +def invalid_request(errors: Any, *, why: str, retry: str) -> RequestRefused: error = first_field_error(errors) - return UploadRefused( + return RequestRefused( code="invalid_request", detail=f"{error.attr}: {error.message}", attr=error.attr, - why="An upload needs a name, a subject and HTML, each as a string.", - fix=f"Fix `{error.attr}` and upload again.", + why=why, + fix=f"Fix `{error.attr}` and {retry}.", ) def first_field_error(errors: Any, path: tuple[str, ...] = ()) -> FieldError: if isinstance(errors, dict): field, nested = next(iter(errors.items())) - return first_field_error(nested, (*path, str(field))) + # An error about a nested object as a whole names the object. + field_path = path if field == api_settings.NON_FIELD_ERRORS_KEY else (*path, str(field)) + return first_field_error(nested, field_path) if isinstance(errors, list) and errors and not isinstance(errors[0], str): return first_field_error(errors[0], path) return FieldError(attr="__".join(path), message=str(errors[0] if isinstance(errors, list) else errors)) @@ -531,6 +538,87 @@ class TransactionalEmailTeamSustainedThrottle(ProjectSecretApiKeyTeamRateThrottl rate = "20000/hour" +TRANSACTIONAL_EMAIL_THROTTLES = [ + TransactionalEmailBurstThrottle, + TransactionalEmailSustainedThrottle, + TransactionalEmailTeamBurstThrottle, + TransactionalEmailTeamSustainedThrottle, +] + + +class TransactionalEmailViewSetMixin(TeamAndOrgViewSetMixin): + """What every route of the transactional email API shares: the flag, workflow access and the send.""" + + def _require_transactional_email_flag(self) -> None: + if not posthoganalytics.feature_enabled( + TRANSACTIONAL_EMAIL_API_FLAG, + str(self.team.uuid), + groups={"organization": str(self.team.organization_id)}, + group_properties={"organization": {"id": str(self.team.organization_id)}}, + send_feature_flag_events=False, + ): + raise NotFound() + + def _require_workflow_editor(self, hog_flow_id: Any) -> None: + # A project secret API key has no user to check, and its scope already limits it to sending and uploads. + if is_authenticated_via_project_secret_api_key(self.request): + return + try: + get_workflow( + team_id=self.team_id, + workflow_id=str(hog_flow_id), + user_access_control=self.user_access_control, + required_level="editor", + ) + except WorkflowAccessDenied: + raise exceptions.PermissionDenied( + "You need editor access to this template's transactional email workflow." + ) from None + except WorkflowNotFound: + return + + @staticmethod + def _auth_method(request: Request) -> str: + return "project_secret_api_key" if is_authenticated_via_project_secret_api_key(request) else "user" + + def _send_transactional_email( + self, request: Any, template: MessageTemplateRow, data: dict[str, Any], *, usage: dict[str, str] + ) -> QueuedEmail: + if template.transactional_hog_flow_id: + self._require_workflow_editor(template.transactional_hog_flow_id) + outcome: dict[str, Any] = {"outcome": "error"} + try: + queued = send_transactional_email( + self.team, + template.id, + to=data["to"], + distinct_id=(data.get("identifiers") or {}).get("distinct_id") or None, + message_data=data["message_data"], + ) + outcome = {"outcome": "queued"} + except TransactionalEmailError as e: + outcome = {"outcome": "rejected", "error_code": e.code} + raise TransactionalEmailRejected(e) + except DeliveryUnavailable: + outcome = {"outcome": "unavailable"} + raise TransactionalEmailUnavailable() from None + finally: + self._report_transactional_email_send(request, {"template_id": str(template.id), **usage, **outcome}) + return queued + + def _report_transactional_email_send(self, request: Any, properties: dict[str, Any]) -> None: + # report_user_action drops the synthetic user behind a project secret API key, so capture directly. + try: + posthoganalytics.capture( + distinct_id=request.user.distinct_id, + event="transactional email api send", + properties={"team_id": self.team_id, "auth_method": self._auth_method(request), **properties}, + groups=groups(self.organization, self.team), + ) + except Exception as e: + logger.warning("Failed to capture transactional email usage event", error=str(e)) + + @extend_schema_view( retrieve=extend_schema(parameters=[TEMPLATE_ID_PARAMETER]), update=extend_schema(parameters=[TEMPLATE_ID_PARAMETER]), @@ -538,7 +626,7 @@ class TransactionalEmailTeamSustainedThrottle(ProjectSecretApiKeyTeamRateThrottl destroy=extend_schema(parameters=[TEMPLATE_ID_PARAMETER]), ) class MessageTemplatesViewSet( - TeamAndOrgViewSetMixin, + TransactionalEmailViewSetMixin, ForbidDestroyModel, viewsets.ModelViewSet, ): @@ -565,12 +653,7 @@ class MessageTemplatesViewSet( def get_throttles(self): if self.action in ("send", "upload"): - return [ - TransactionalEmailBurstThrottle(), - TransactionalEmailSustainedThrottle(), - TransactionalEmailTeamBurstThrottle(), - TransactionalEmailTeamSustainedThrottle(), - ] + return [throttle() for throttle in TRANSACTIONAL_EMAIL_THROTTLES] return super().get_throttles() def dangerously_get_object(self) -> MessageTemplateRow: @@ -652,24 +735,6 @@ def update(self, request: Request, *args: Any, **kwargs: Any) -> Response: self._emit_resource_edited(updated) return Response(self._serialize(updated)) - def _require_workflow_editor(self, hog_flow_id: Any) -> None: - # A project secret API key has no user to check, and its scope already limits it to sending and uploads. - if is_authenticated_via_project_secret_api_key(self.request): - return - try: - get_workflow( - team_id=self.team_id, - workflow_id=str(hog_flow_id), - user_access_control=self.user_access_control, - required_level="editor", - ) - except WorkflowAccessDenied: - raise exceptions.PermissionDenied( - "You need editor access to this template's transactional email workflow." - ) from None - except WorkflowNotFound: - return - def _actor_id(self) -> int | None: return self.request.user.id if self.request.user.is_authenticated else None @@ -769,32 +834,13 @@ def apply_operations(content: dict[str, Any]) -> dict[str, Any]: def send(self, request: Any, *args: Any, **kwargs: Any) -> Response: self._require_transactional_email_flag() - template = self.get_object() - if template.transactional_hog_flow_id: - self._require_workflow_editor(template.transactional_hog_flow_id) - data = request.validated_data - outcome: dict[str, Any] = {"outcome": "error"} - try: - queued = send_transactional_email( - self.team, - template.id, - to=data["to"], - distinct_id=(data.get("identifiers") or {}).get("distinct_id") or None, - message_data=data["message_data"], - ) - outcome = {"outcome": "queued"} - except TransactionalEmailError as e: - outcome = {"outcome": "rejected", "error_code": e.code} - raise TransactionalEmailRejected(e) - except DeliveryUnavailable: - outcome = {"outcome": "unavailable"} - raise TransactionalEmailUnavailable() from None - finally: - self._report_transactional_email_send(request, template, outcome) - return Response( - SendTransactionalEmailResponseSerializer(queued).data, - status=status.HTTP_202_ACCEPTED, + queued = self._send_transactional_email( + request, + self.get_object(), + request.validated_data, + usage={"addressed_by": "url_id", "hash_state": ContentHashState.NONE}, ) + return Response(SendTransactionalEmailResponseSerializer(queued).data, status=status.HTTP_202_ACCEPTED) @extend_schema( request=UploadTemplateSerializer, @@ -804,7 +850,7 @@ def send(self, request: Any, *args: Any, **kwargs: Any) -> Response: ), 201: OpenApiResponse(response=UploadTemplateResponseSerializer, description="The template was created"), 400: OpenApiResponse( - response=TemplateUploadErrorSerializer, + response=TransactionalEmailErrorSerializer, description="The key or a field is invalid, the template can't be sent, or there is no verified " "default sender", ), @@ -827,10 +873,12 @@ def upload(self, request: Request, key: str, *args: Any, **kwargs: Any) -> Respo refused = invalid_template_key(key) body = UploadTemplateSerializer(data=request.data) if refused is None and not body.is_valid(): - refused = invalid_upload(body.errors) + refused = invalid_request( + body.errors, why="An upload needs a name, a subject and HTML, each as a string.", retry="upload again" + ) if refused is not None: self._report_template_upload(request, {"result": "rejected", "error_code": refused.code}) - return Response(TemplateUploadErrorSerializer(refused).data, status=status.HTTP_400_BAD_REQUEST) + return Response(TransactionalEmailErrorSerializer(refused).data, status=status.HTTP_400_BAD_REQUEST) try: uploaded = upload_template( @@ -906,20 +954,6 @@ def _report_template_upload(self, request: Any, outcome: dict[str, Any]) -> None except Exception as e: logger.warning("Failed to capture transactional email template upload event", error=str(e)) - @staticmethod - def _auth_method(request: Request) -> str: - return "project_secret_api_key" if is_authenticated_via_project_secret_api_key(request) else "user" - - def _require_transactional_email_flag(self) -> None: - if not posthoganalytics.feature_enabled( - TRANSACTIONAL_EMAIL_API_FLAG, - str(self.team.uuid), - groups={"organization": str(self.team.organization_id)}, - group_properties={"organization": {"id": str(self.team.organization_id)}}, - send_feature_flag_events=False, - ): - raise NotFound() - @validated_request( MakeTransactionalSerializer, responses={ @@ -968,22 +1002,3 @@ def make_transactional(self, request: Any, *args: Any, **kwargs: Any) -> Respons except Exception as e: logger.warning("Failed to capture message template usage event", error=str(e)) return Response(self._serialize(updated)) - - def _report_transactional_email_send( - self, request: Any, template: MessageTemplateRow, outcome: dict[str, Any] - ) -> None: - # report_user_action drops the synthetic user behind a project secret API key, so capture directly. - try: - posthoganalytics.capture( - distinct_id=request.user.distinct_id, - event="transactional email api send", - properties={ - "template_id": str(template.id), - "team_id": self.team_id, - "auth_method": self._auth_method(request), - **outcome, - }, - groups=groups(self.organization, self.team), - ) - except Exception as e: - logger.warning("Failed to capture transactional email usage event", error=str(e)) diff --git a/products/messaging/backend/presentation/views/messaging_send.py b/products/messaging/backend/presentation/views/messaging_send.py new file mode 100644 index 000000000000..2a653fe81844 --- /dev/null +++ b/products/messaging/backend/presentation/views/messaging_send.py @@ -0,0 +1,194 @@ +from typing import Any + +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import serializers, status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response + +from posthog.auth import ProjectSecretAPIKeyAuthentication +from posthog.dataclasses import frozen + +from products.messaging.backend.facade.contracts import ContentHashState, QueuedEmail, TemplateReference +from products.messaging.backend.facade.templates import MessageTemplateMissing, find_template_to_send +from products.messaging.backend.presentation.views.message_templates import ( + TRANSACTIONAL_EMAIL_THROTTLES, + RequestRefused, + SendTransactionalEmailResponseSerializer, + SendTransactionalEmailSerializer, + TransactionalEmailErrorSerializer, + TransactionalEmailViewSetMixin, + invalid_request, + invalid_template_key, +) + + +class TemplateReferenceSerializer(serializers.Serializer): + key = serializers.CharField( + required=False, + help_text="Key of the template, as uploaded with `PUT /api/projects/@current/messaging_templates/by_key/{key}/`. " + "Give either `key` or `id`.", + ) + hash = serializers.CharField( + required=False, + help_text="Content hash of the template you hold, as `v1:` and 64 hex characters. When PostHog never " + "published that content, the send answers `template_outdated` and sends nothing. Only with `key`.", + ) + id = serializers.UUIDField( + required=False, + help_text="ID of the template, for templates made in PostHog. Give either `key` or `id`.", + ) + + def validate(self, data: dict[str, Any]) -> dict[str, Any]: + if ("key" in data) == ("id" in data) or ("hash" in data and "id" in data): + raise serializers.ValidationError("Name the template with either `key` (and an optional `hash`) or `id`.") + return data + + +class SendEmailSerializer(SendTransactionalEmailSerializer): + template = TemplateReferenceSerializer(help_text="The template to send, by key or by id.") + + +class SendEmailWarningSerializer(serializers.Serializer): + code = serializers.CharField(help_text="Stable warning code to branch on, such as `template_superseded`.") + detail = serializers.CharField(help_text="What happened.") + fix = serializers.CharField(help_text="What you can change, if the warning is not what you want.") + + +class SendEmailResponseSerializer(SendTransactionalEmailResponseSerializer): + warnings = SendEmailWarningSerializer( + many=True, help_text="Things to know about the queued email. Empty in the normal case." + ) + + +@frozen +class SendWarning: + code: str + detail: str + fix: str + + +TEMPLATE_SUPERSEDED = SendWarning( + code="template_superseded", + detail="The email was sent with the template's current content, because newer content replaced the content you " + "hold.", + fix="Deploy the newer file, or upload your file to publish it again.", +) + + +def template_reference(template: dict[str, Any]) -> TemplateReference: + return TemplateReference(key=template.get("key"), id=template.get("id"), hash=template.get("hash")) + + +def invalid_send(errors: Any) -> RequestRefused: + return invalid_request( + errors, why="A send needs a template, a recipient address, and data as an object.", retry="send again" + ) + + +def template_not_found(reference: TemplateReference) -> RequestRefused: + if reference.key is not None: + return RequestRefused( + code="template_not_found", + detail=f"No template in this project has the key `{reference.key}`.", + attr="template__key", + why="A send names a template of this project that is not deleted.", + fix=f"Upload the template first with `PUT /api/projects/@current/messaging_templates/by_key/{reference.key}/`, " + "or check the key and the project.", + ) + return RequestRefused( + code="template_not_found", + detail=f"No template in this project has the id `{reference.id}`.", + attr="template__id", + why="A send names a template of this project that is not deleted.", + fix="Check the id and the project.", + ) + + +TEMPLATE_OUTDATED = RequestRefused( + code="template_outdated", + detail="PostHog does not hold the content this hash describes, so nothing was sent.", + attr="template__hash", + why="The template never had this content, so sending its current content could send the wrong email.", + fix="Upload the template, then send again. posthog-node does this on its own.", +) + + +class MessagingSendViewSet(TransactionalEmailViewSetMixin, viewsets.ViewSet): + scope_object = "hog_flow" + scope_object_write_actions = ["send_email"] + permission_classes = [IsAuthenticated] + authentication_classes = [ProjectSecretAPIKeyAuthentication] + psak_allowed_actions = ["send_email"] + throttle_classes = TRANSACTIONAL_EMAIL_THROTTLES + + @extend_schema( + request=SendEmailSerializer, + responses={ + 202: OpenApiResponse(response=SendEmailResponseSerializer, description="Email queued"), + 400: OpenApiResponse( + response=TransactionalEmailErrorSerializer, + description="A field is invalid, the template isn't transactional or its email is paused, or the " + "recipient can't be used", + ), + 404: OpenApiResponse( + response=TransactionalEmailErrorSerializer, + description="No template has this key or id, or the transactional email API is not enabled", + ), + 409: OpenApiResponse( + response=TransactionalEmailErrorSerializer, + description="PostHog does not hold the content the hash describes. Nothing was sent", + ), + 429: OpenApiResponse(description="Rate limited, or the project is over its email quota"), + 503: OpenApiResponse(description="The email pipeline did not confirm the send"), + }, + summary="Send a transactional email", + description=( + "Send one transactional email to one recipient from a template named by its key or id. `message_data` " + "fills the template's `{{ variables.* }}` placeholders. With `template.hash`, PostHog checks that it " + "holds the content you hold: content it never published sends nothing, and content that newer content " + "replaced sends the current content with a `template_superseded` warning. Transactional emails skip " + "opt-outs and frequency caps." + ), + ) + @action(detail=False, methods=["POST"], url_path="send/email") + def send_email(self, request: Request, *args: Any, **kwargs: Any) -> Response: + self._require_transactional_email_flag() + body = SendEmailSerializer(data=request.data) + if not body.is_valid(): + return self._refuse(invalid_send(body.errors)) + reference = template_reference(body.validated_data["template"]) + if reference.key is not None and (refused := invalid_template_key(reference.key, attr="template__key")): + return self._refuse(refused) + + addressed_by = "key" if reference.key is not None else "id" + try: + target = find_template_to_send(self.team_id, reference) + except MessageTemplateMissing: + return self._refuse(template_not_found(reference), status.HTTP_404_NOT_FOUND, addressed_by=addressed_by) + usage = {"addressed_by": addressed_by, "hash_state": target.hash_state.value} + if target.hash_state == ContentHashState.OUTDATED: + return self._refuse( + TEMPLATE_OUTDATED, status.HTTP_409_CONFLICT, template_id=str(target.template.id), **usage + ) + + queued = self._send_transactional_email(request, target.template, body.validated_data, usage=usage) + return self._queued(queued, target.hash_state) + + @staticmethod + def _queued(queued: QueuedEmail, hash_state: ContentHashState) -> Response: + warnings = [TEMPLATE_SUPERSEDED] if hash_state == ContentHashState.SUPERSEDED else [] + return Response( + SendEmailResponseSerializer({"message_id": queued.message_id, "warnings": warnings}).data, + status=status.HTTP_202_ACCEPTED, + ) + + def _refuse( + self, refused: RequestRefused, status_code: int = status.HTTP_400_BAD_REQUEST, **usage: str + ) -> Response: + if usage: + self._report_transactional_email_send( + self.request, {**usage, "outcome": "rejected", "error_code": refused.code} + ) + return Response(TransactionalEmailErrorSerializer(refused).data, status=status_code) diff --git a/products/messaging/backend/routes.py b/products/messaging/backend/routes.py index 2e64fcaf697d..6d1386cc91a4 100644 --- a/products/messaging/backend/routes.py +++ b/products/messaging/backend/routes.py @@ -4,6 +4,7 @@ from products.messaging.backend.presentation.views.message_preferences import MessagePreferencesViewSet from products.messaging.backend.presentation.views.message_suppression import MessageSuppressionViewSet from products.messaging.backend.presentation.views.message_templates import MessageTemplatesViewSet +from products.messaging.backend.presentation.views.messaging_send import MessagingSendViewSet def register_routes(routers: RouterRegistry) -> None: @@ -20,3 +21,4 @@ def register_routes(routers: RouterRegistry) -> None: routers.projects.register( r"messaging_suppressions", MessageSuppressionViewSet, "project_messaging_suppressions", ["team_id"] ) + routers.projects.register(r"messaging", MessagingSendViewSet, "project_messaging", ["team_id"]) diff --git a/products/messaging/backend/services/content_hash.py b/products/messaging/backend/services/content_hash.py index f359cf1e1c80..b5f68204b18e 100644 --- a/products/messaging/backend/services/content_hash.py +++ b/products/messaging/backend/services/content_hash.py @@ -4,6 +4,8 @@ import hashlib from typing import Any +from products.messaging.backend.facade.contracts import ContentHashState + HASH_PREFIX = "v1:" HASHED_FIELDS = ("subject", "preheader", "html", "text") SUPERSEDED_HASHES_KEPT = 100 @@ -28,3 +30,13 @@ def superseded_after_publish(previous_hash: str | None, published_hash: str, sup replaced = [previous_hash] if previous_hash and previous_hash != published_hash else [] kept = [h for h in superseded if h not in (previous_hash, published_hash)] return (replaced + kept)[:SUPERSEDED_HASHES_KEPT] + + +def sent_hash_state(sent_hash: str | None, content_hash: str | None, superseded: list[str]) -> ContentHashState: + if sent_hash is None: + return ContentHashState.NONE + if sent_hash == content_hash: + return ContentHashState.CURRENT + if sent_hash in superseded: + return ContentHashState.SUPERSEDED + return ContentHashState.OUTDATED diff --git a/products/messaging/backend/services/message_templates.py b/products/messaging/backend/services/message_templates.py index e282818e3a79..f7d97e2bc978 100644 --- a/products/messaging/backend/services/message_templates.py +++ b/products/messaging/backend/services/message_templates.py @@ -79,6 +79,11 @@ def team_template(team_id: int, template_id: UUID | str) -> MessageTemplate: return MessageTemplate.objects.get(team_id=team_id, deleted=False, pk=template_id) +def team_template_with_key(team_id: int, key: str) -> MessageTemplate: + # A deleted template releases its key, so only one template that is not deleted can hold it. + return MessageTemplate.objects.get(team_id=team_id, deleted=False, key=key) + + def category_id_for_team(team_id: int, category_id: Any) -> UUID: # Not filtered on deleted, as the related field this serves never was. return MessageCategory.objects.get(team_id=team_id, pk=category_id).pk diff --git a/products/messaging/backend/tests/api/test_template_upload.py b/products/messaging/backend/tests/api/test_template_upload.py index 6706c6da6973..691323c9597a 100644 --- a/products/messaging/backend/tests/api/test_template_upload.py +++ b/products/messaging/backend/tests/api/test_template_upload.py @@ -259,3 +259,41 @@ def test_a_publish_moves_the_replaced_hash_to_the_superseded_list(self, _post, _ assert superseded_after_edit == [WELCOME_HASH] assert (rolled_back["result"], rolled_back["content_hash"]) == ("updated", WELCOME_HASH) assert self._superseded_hashes(first["id"]) == [edited_hash] + + @parameterized.expand( + [ + ("current", None, status.HTTP_202_ACCEPTED, []), + ("superseded", WELCOME_HASH, status.HTTP_202_ACCEPTED, ["template_superseded"]), + ("outdated", f"v1:{'0' * 64}", status.HTTP_409_CONFLICT, None), + ] + ) + @patch("products.messaging.backend.services.transactional_email.reload_hog_flows_on_workers") + def test_a_send_by_key_with_a_hash_only_ever_sends_the_published_content( + self, hash_state, sent_hash, expected_status, expected_warnings, _reload, post: MagicMock, _flag, capture + ): + post.return_value = MagicMock(status_code=202, json=lambda: {"invocation_id": "invocation-1"}) + self._upload(WELCOME) + published = self._upload({**WELCOME, "subject": "Welcome aboard, {{ variables.first_name }}"}) + hog_flow_id = published["transactional_hog_flow_id"] + + response = self.psak.post( + f"/api/projects/{self.team.id}/messaging/send/email/", + { + "template": {"key": "welcome", "hash": sent_hash or published["content_hash"]}, + "to": "jane@example.com", + "message_data": {"first_name": "Jane"}, + }, + format="json", + ) + + assert response.status_code == expected_status, response.json() + assert capture.call_args.kwargs["properties"]["hash_state"] == hash_state + if expected_warnings is None: + assert response.json()["code"] == "template_outdated" + post.assert_not_called() + else: + assert [warning["code"] for warning in response.json()["warnings"]] == expected_warnings + assert post.call_args.args[0].endswith(f"/hog_flows/{hog_flow_id}/transactional_email") + assert self._template(published["id"])["content_hash"] == published["content_hash"] + assert self._sent_email(hog_flow_id)["subject"] == "Welcome aboard, {{ variables.first_name }}" + assert self._workflow(hog_flow_id)["version"] == 2 diff --git a/products/messaging/backend/tests/api/test_transactional_email.py b/products/messaging/backend/tests/api/test_transactional_email.py index 7f6cf43cf933..85506263c4d3 100644 --- a/products/messaging/backend/tests/api/test_transactional_email.py +++ b/products/messaging/backend/tests/api/test_transactional_email.py @@ -19,6 +19,7 @@ from products.access_control.backend.facade.testing import create_access_control from products.messaging.backend.models.message_category import MessageCategory, MessageCategoryType from products.messaging.backend.models.message_template import MessageTemplate +from products.messaging.backend.presentation.views.message_templates import TransactionalEmailBurstThrottle from products.messaging.backend.services.transactional_email import make_transactional from products.workflows.backend.facade.api import set_default_email_sender_if_unset @@ -36,26 +37,37 @@ def setUp(self): team=self.team, kind="email", integration_id="hello@example.com", config={"verified": True} ) set_default_email_sender_if_unset(self.team.id, self.sender.id) - self.template = self._template() + self.template = self._template(key="password-reset") self.hog_flow_id = make_transactional( self.team.id, self.template.id, user_id=self.user.id, from_integration_id=None ) self.url = f"/api/projects/{self.team.id}/messaging_templates/{self.template.id}/send/" + self.send_email_url = f"/api/projects/{self.team.id}/messaging/send/email/" self.body = { "to": "user@example.com", "identifiers": {"distinct_id": "user-1"}, "message_data": {"link": "https://example.com/reset", "nickname": None}, } - def _template(self, content: dict | None = None) -> MessageTemplate: + def _template(self, content: dict | None = None, key: str | None = None) -> MessageTemplate: return MessageTemplate.objects.create( team=self.team, name="Password reset", content=content or {"email": {"subject": "Reset your password", "html": "Reset"}}, type="email", + key=key, ) + def _route(self, path: str) -> str: + return f"/api/projects/{self.team.id}/{path.format(id=self.template.id)}" + + def _send_request(self, addressed_by: str, body: dict[str, Any]) -> tuple[str, dict[str, Any]]: + if addressed_by == "url_id": + return self.url, body + template = {"key": "password-reset"} if addressed_by == "key" else {"id": str(self.template.id)} + return self.send_email_url, {**body, "template": template} + def _workflow(self, hog_flow_id) -> dict: response = self.client.get(f"/api/projects/{self.team.id}/hog_flows/{hog_flow_id}") assert response.status_code == status.HTTP_200_OK, response.json() @@ -79,25 +91,32 @@ def _psak_client(self, scopes: list[str]) -> APIClient: @parameterized.expand( [ - ("with_distinct_id", {"distinct_id": "user-1"}, "user-1"), + ("with_distinct_id", "url_id", {"distinct_id": "user-1"}, "user-1"), # Without a distinct_id the recipient's address is the person the send is tracked against. - ("email_only", None, "user@example.com"), + ("email_only", "url_id", None, "user@example.com"), + ("by_key", "key", {"distinct_id": "user-1"}, "user-1"), + ("by_id", "id", None, "user@example.com"), ] ) def test_runs_the_templates_workflow_for_the_recipient( - self, mock_post, _flag, _capture, _name, identifiers, expected_distinct_id + self, mock_post, _flag, capture, _name, addressed_by, identifiers, expected_distinct_id ): self._cdp_accepts(mock_post) - body = ( + url, body = self._send_request( + addressed_by, {**self.body, "identifiers": identifiers} if identifiers - else {k: v for k, v in self.body.items() if k != "identifiers"} + else {k: v for k, v in self.body.items() if k != "identifiers"}, ) - response = self._psak_client(["hog_flow:write"]).post(self.url, body, format="json") + response = self._psak_client(["hog_flow:write"]).post(url, body, format="json") assert response.status_code == status.HTTP_202_ACCEPTED, response.json() - assert response.json() == {"message_id": "invocation-1"} + expected_response: dict[str, Any] = {"message_id": "invocation-1"} + if addressed_by != "url_id": + expected_response["warnings"] = [] + assert response.json() == expected_response + assert capture.call_args.kwargs["properties"]["addressed_by"] == addressed_by assert mock_post.call_args.args[0].endswith( f"/api/projects/{self.team.id}/hog_flows/{self.hog_flow_id}/transactional_email" ) @@ -109,26 +128,59 @@ def test_runs_the_templates_workflow_for_the_recipient( @parameterized.expand( [ - ("send_with_write_scope", "post", "send/", ["hog_flow:write"], status.HTTP_202_ACCEPTED), - ("send_with_read_scope", "post", "send/", ["hog_flow:read"], status.HTTP_403_FORBIDDEN), - ("retrieve_is_not_a_psak_action", "get", "", ["hog_flow:write"], status.HTTP_403_FORBIDDEN), + ( + "send_with_write_scope", + "post", + "messaging_templates/{id}/send/", + ["hog_flow:write"], + status.HTTP_202_ACCEPTED, + ), + ( + "send_with_read_scope", + "post", + "messaging_templates/{id}/send/", + ["hog_flow:read"], + status.HTTP_403_FORBIDDEN, + ), + ( + "send_email_with_write_scope", + "post", + "messaging/send/email/", + ["hog_flow:write"], + status.HTTP_202_ACCEPTED, + ), + ( + "send_email_with_read_scope", + "post", + "messaging/send/email/", + ["hog_flow:read"], + status.HTTP_403_FORBIDDEN, + ), + ( + "retrieve_is_not_a_psak_action", + "get", + "messaging_templates/{id}/", + ["hog_flow:write"], + status.HTTP_403_FORBIDDEN, + ), ( "make_transactional_is_not_a_psak_action", "post", - "make_transactional/", + "messaging_templates/{id}/make_transactional/", ["hog_flow:write"], status.HTTP_403_FORBIDDEN, ), ] ) def test_project_secret_api_key_can_only_send( - self, mock_post, _flag, _capture, _name, method, suffix, scopes, expected_status + self, mock_post, _flag, _capture, _name, method, path, scopes, expected_status ): self._cdp_accepts(mock_post) - url = f"/api/projects/{self.team.id}/messaging_templates/{self.template.id}/{suffix}" response = getattr(self._psak_client(scopes), method)( - url, self.body if method == "post" else None, format="json" + self._route(path), + {**self.body, "template": {"key": "password-reset"}} if method == "post" else None, + format="json", ) assert response.status_code == expected_status, response.json() @@ -166,6 +218,63 @@ def test_rejects_a_send_it_cannot_make(self, mock_post, _flag, _capture, case, e assert response.json()["type"] == "invalid_request" mock_post.assert_not_called() + @parameterized.expand( + [ + ("unknown_key", {"key": "welcome"}, status.HTTP_404_NOT_FOUND, "template_not_found", "template__key"), + ("unknown_id", {"id": str(uuid4())}, status.HTTP_404_NOT_FOUND, "template_not_found", "template__id"), + ("deleted", {"key": "password-reset"}, status.HTTP_404_NOT_FOUND, "template_not_found", "template__key"), + ( + "key_and_id", + {"key": "password-reset", "id": str(uuid4())}, + status.HTTP_400_BAD_REQUEST, + "invalid_request", + "template", + ), + ("neither_key_nor_id", {}, status.HTTP_400_BAD_REQUEST, "invalid_request", "template"), + ( + "hash_with_id", + {"id": str(uuid4()), "hash": f"v1:{'0' * 64}"}, + status.HTTP_400_BAD_REQUEST, + "invalid_request", + "template", + ), + ( + "invalid_key", + {"key": "password reset"}, + status.HTTP_400_BAD_REQUEST, + "invalid_template_key", + "template__key", + ), + ] + ) + def test_refuses_a_template_reference_it_cannot_send_with_what_to_fix( + self, mock_post, _flag, _capture, case, template, expected_status, expected_code, expected_attr + ): + if case == "deleted": + deleted = self.client.patch(self._route("messaging_templates/{id}/"), {"deleted": True}, format="json") + assert deleted.status_code == status.HTTP_200_OK, deleted.json() + + response = self.client.post(self.send_email_url, {**self.body, "template": template}, format="json") + + assert response.status_code == expected_status, response.json() + error = response.json() + assert (error["code"], error["attr"]) == (expected_code, expected_attr) + assert error["docs_url"] == f"https://posthog.com/docs/workflows/transactional-email-api#{expected_code}" + assert error["why"] and error["fix"] + mock_post.assert_not_called() + + @patch("posthog.rate_limit.is_rate_limit_enabled", return_value=True) + def test_both_send_routes_share_one_rate_limit_per_key(self, _rate_limit, mock_post, _flag, _capture): + self._cdp_accepts(mock_post) + psak = self._psak_client(["hog_flow:write"]) + + with patch.object(TransactionalEmailBurstThrottle, "rate", "1/minute"): + by_url_id = psak.post(self.url, self.body, format="json") + by_key = psak.post(self.send_email_url, {**self.body, "template": {"key": "password-reset"}}, format="json") + + assert by_url_id.status_code == status.HTTP_202_ACCEPTED, by_url_id.json() + assert by_key.status_code == status.HTTP_429_TOO_MANY_REQUESTS, by_key.json() + @parameterized.expand( [ ("timeout", requests.ReadTimeout()), @@ -242,12 +351,18 @@ def test_reports_quota_limited_teams(self, mock_post, _flag, _capture): assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS assert response.json()["code"] == "quota_limited" - @parameterized.expand([("send", "send/"), ("make_transactional", "make_transactional/")]) - def test_is_hidden_while_the_flag_is_off(self, mock_post, flag, _capture, _name, suffix): + @parameterized.expand( + [ + ("send", "messaging_templates/{id}/send/"), + ("make_transactional", "messaging_templates/{id}/make_transactional/"), + ("send_email", "messaging/send/email/"), + ] + ) + def test_is_hidden_while_the_flag_is_off(self, mock_post, flag, _capture, _name, path): flag.return_value = False response = self.client.post( - f"/api/projects/{self.team.id}/messaging_templates/{self.template.id}/{suffix}", self.body, format="json" + self._route(path), {**self.body, "template": {"key": "password-reset"}}, format="json" ) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -394,6 +509,7 @@ def setUp(self): name="Password reset", content={"email": {"subject": "Reset your password", "html": "

Reset

"}}, type="email", + key="password-reset", ) self.hog_flow_id = make_transactional( self.team.id, self.template.id, user_id=self.user.id, from_integration_id=None @@ -415,12 +531,23 @@ def _login_member(self, email: str, levels: dict[str | None, str]) -> None: @parameterized.expand( [ - ("send", "post", "send/", {"to": "user@example.com"}), - ("update", "patch", "", {"content": {"email": {"subject": "New", "html": "

New

"}}}), + ("send", "post", "messaging_templates/{id}/send/", {"to": "user@example.com"}), + ( + "send_email", + "post", + "messaging/send/email/", + {"to": "user@example.com", "template": {"key": "password-reset"}}, + ), + ( + "update", + "patch", + "messaging_templates/{id}/", + {"content": {"email": {"subject": "New", "html": "

New

"}}}, + ), ] ) - def test_a_member_denied_the_workflow_cannot_send_or_change_it(self, mock_post, _flag, _name, method, suffix, body): - url = f"/api/projects/{self.team.id}/messaging_templates/{self.template.id}/{suffix}" + def test_a_member_denied_the_workflow_cannot_send_or_change_it(self, mock_post, _flag, _name, method, path, body): + url = f"/api/projects/{self.team.id}/{path.format(id=self.template.id)}" response = getattr(self.client, method)(url, body, format="json") diff --git a/products/messaging/frontend/generated/api.schemas.ts b/products/messaging/frontend/generated/api.schemas.ts index 7870fba849a3..29dc35260388 100644 --- a/products/messaging/frontend/generated/api.schemas.ts +++ b/products/messaging/frontend/generated/api.schemas.ts @@ -7,6 +7,76 @@ * PostHog API - generated * OpenAPI spec version: 1.0.0 */ +/** + * Data for the template's Liquid placeholders, available as `variables`. For example `{"reset_link": "https://..."}` fills `{{ variables.reset_link }}`. + */ +export type SendEmailApiMessageData = { [key: string]: unknown } + +export interface TransactionalEmailIdentifiersApi { + /** + * Distinct ID of the recipient's person. The email's engagement events (sent, delivered, opened, clicked, bounced) and the send itself are recorded against that person. Defaults to the recipient's email address. The template can't read the person's properties, so pass everything it needs in `message_data`. + * @maxLength 400 + * @nullable + */ + distinct_id?: string | null +} + +export interface TemplateReferenceApi { + /** Key of the template, as uploaded with `PUT /api/projects/@current/messaging_templates/by_key/{key}/`. Give either `key` or `id`. */ + key?: string + /** Content hash of the template you hold, as `v1:` and 64 hex characters. When PostHog never published that content, the send answers `template_outdated` and sends nothing. Only with `key`. */ + hash?: string + /** ID of the template, for templates made in PostHog. Give either `key` or `id`. */ + id?: string +} + +export interface SendEmailApi { + /** Email address of the recipient. */ + to: string + /** Who the email is for, used to attribute engagement events. */ + identifiers?: TransactionalEmailIdentifiersApi + /** Data for the template's Liquid placeholders, available as `variables`. For example `{"reset_link": "https://..."}` fills `{{ variables.reset_link }}`. */ + message_data?: SendEmailApiMessageData + /** The template to send, by key or by id. */ + template: TemplateReferenceApi +} + +export interface SendEmailWarningApi { + /** Stable warning code to branch on, such as `template_superseded`. */ + code: string + /** What happened. */ + detail: string + /** What you can change, if the warning is not what you want. */ + fix: string +} + +export interface SendEmailResponseApi { + /** ID of the queued send. Delivery and engagement are reported against the template asynchronously. */ + message_id: string + /** Things to know about the queued email. Empty in the normal case. */ + warnings: SendEmailWarningApi[] +} + +export interface TransactionalEmailErrorApi { + /** Error class, such as `invalid_request`. */ + type: string + /** Stable error code to branch on, such as `invalid_template_key`. */ + code: string + /** What went wrong. */ + detail: string + /** + * The request field the error is about, if any. + * @nullable + */ + attr: string | null + /** Why PostHog refuses the request. */ + why?: string + /** What to change before you retry. */ + fix?: string + /** Documentation for this error code. */ + docs_url?: string +} + /** * * `marketing` - Marketing * * `transactional` - Transactional @@ -588,15 +658,6 @@ export interface MakeTransactionalApi { */ export type SendTransactionalEmailApiMessageData = { [key: string]: unknown } -export interface TransactionalEmailIdentifiersApi { - /** - * Distinct ID of the recipient's person. The email's engagement events (sent, delivered, opened, clicked, bounced) and the send itself are recorded against that person. Defaults to the recipient's email address. The template can't read the person's properties, so pass everything it needs in `message_data`. - * @maxLength 400 - * @nullable - */ - distinct_id?: string | null -} - export interface SendTransactionalEmailApi { /** Email address of the recipient. */ to: string @@ -687,26 +748,6 @@ export interface UploadTemplateResponseApi { transactional_hog_flow_id: string } -export interface TemplateUploadErrorApi { - /** Error class, such as `invalid_request`. */ - type: string - /** Stable error code to branch on, such as `invalid_template_key`. */ - code: string - /** What went wrong. */ - detail: string - /** - * The request field the error is about, if any. - * @nullable - */ - attr: string | null - /** Why PostHog refuses the request. */ - why?: string - /** What to change before you retry. */ - fix?: string - /** Documentation for this error code. */ - docs_url?: string -} - export type MessagingCategoriesListParams = { /** * Number of results to return per page. diff --git a/products/messaging/frontend/generated/api.ts b/products/messaging/frontend/generated/api.ts index 22387a4d629a..d04e362df5b8 100644 --- a/products/messaging/frontend/generated/api.ts +++ b/products/messaging/frontend/generated/api.ts @@ -33,6 +33,8 @@ import type { PatchedMessageTemplateApi, PreferencesLinkApi, RemoveOptOutRequestApi, + SendEmailApi, + SendEmailResponseApi, SendTransactionalEmailApi, SendTransactionalEmailResponseApi, UploadTemplateApi, @@ -57,6 +59,27 @@ type NonReadonly = [T] extends [UnionToIntersection] } : DistributeReadOnlyOverUnions +export const getMessagingSendEmailCreateUrl = (projectId: string) => { + return `/api/projects/${projectId}/messaging/send/email/` +} + +/** + * Send one transactional email to one recipient from a template named by its key or id. `message_data` fills the template's `{{ variables.* }}` placeholders. With `template.hash`, PostHog checks that it holds the content you hold: content it never published sends nothing, and content that newer content replaced sends the current content with a `template_superseded` warning. Transactional emails skip opt-outs and frequency caps. + * @summary Send a transactional email + */ +export const messagingSendEmailCreate = async ( + projectId: string, + sendEmailApi: SendEmailApi, + options?: RequestInit +): Promise => { + return apiMutator(getMessagingSendEmailCreateUrl(projectId), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(sendEmailApi), + }) +} + export const getMessagingCategoriesListUrl = (projectId: string, params?: MessagingCategoriesListParams) => { const normalizedParams = new URLSearchParams() @@ -612,6 +635,9 @@ export const getMessagingTemplatesListUrl = (projectId: string, params?: Messagi : `/api/projects/${projectId}/messaging_templates/` } +/** + * What every route of the transactional email API shares: the flag, workflow access and the send. + */ export const messagingTemplatesList = async ( projectId: string, params?: MessagingTemplatesListParams, @@ -627,6 +653,9 @@ export const getMessagingTemplatesCreateUrl = (projectId: string) => { return `/api/projects/${projectId}/messaging_templates/` } +/** + * What every route of the transactional email API shares: the flag, workflow access and the send. + */ export const messagingTemplatesCreate = async ( projectId: string, messageTemplateApi: NonReadonly, @@ -644,6 +673,9 @@ export const getMessagingTemplatesRetrieveUrl = (projectId: string, id: string) return `/api/projects/${projectId}/messaging_templates/${id}/` } +/** + * What every route of the transactional email API shares: the flag, workflow access and the send. + */ export const messagingTemplatesRetrieve = async ( projectId: string, id: string, @@ -659,6 +691,9 @@ export const getMessagingTemplatesUpdateUrl = (projectId: string, id: string) => return `/api/projects/${projectId}/messaging_templates/${id}/` } +/** + * What every route of the transactional email API shares: the flag, workflow access and the send. + */ export const messagingTemplatesUpdate = async ( projectId: string, id: string, @@ -677,6 +712,9 @@ export const getMessagingTemplatesPartialUpdateUrl = (projectId: string, id: str return `/api/projects/${projectId}/messaging_templates/${id}/` } +/** + * What every route of the transactional email API shares: the flag, workflow access and the send. + */ export const messagingTemplatesPartialUpdate = async ( projectId: string, id: string, @@ -713,6 +751,9 @@ export const getMessagingTemplatesDesignPartialUpdateUrl = (projectId: string, i return `/api/projects/${projectId}/messaging_templates/${id}/design/` } +/** + * What every route of the transactional email API shares: the flag, workflow access and the send. + */ export const messagingTemplatesDesignPartialUpdate = async ( projectId: string, id: string, diff --git a/products/messaging/frontend/generated/api.zod.ts b/products/messaging/frontend/generated/api.zod.ts index d2b6b87a2df2..590c634fd596 100644 --- a/products/messaging/frontend/generated/api.zod.ts +++ b/products/messaging/frontend/generated/api.zod.ts @@ -9,6 +9,54 @@ */ import * as zod from 'zod' +/** + * Send one transactional email to one recipient from a template named by its key or id. `message_data` fills the template's `{{ variables.* }}` placeholders. With `template.hash`, PostHog checks that it holds the content you hold: content it never published sends nothing, and content that newer content replaced sends the current content with a `template_superseded` warning. Transactional emails skip opt-outs and frequency caps. + * @summary Send a transactional email + */ +export const messagingSendEmailCreateBodyIdentifiersOneDistinctIdMax = 400 + +export const MessagingSendEmailCreateBody = /* @__PURE__ */ zod.object({ + to: zod.email().describe('Email address of the recipient.'), + identifiers: zod + .object({ + distinct_id: zod + .string() + .max(messagingSendEmailCreateBodyIdentifiersOneDistinctIdMax) + .nullish() + .describe( + "Distinct ID of the recipient's person. The email's engagement events (sent, delivered, opened, clicked, bounced) and the send itself are recorded against that person. Defaults to the recipient's email address. The template can't read the person's properties, so pass everything it needs in `message_data`." + ), + }) + .optional() + .describe('Who the email is for, used to attribute engagement events.'), + message_data: zod + .record(zod.string(), zod.unknown()) + .optional() + .describe( + 'Data for the template\'s Liquid placeholders, available as `variables`. For example `{\"reset_link\": \"https:\/\/...\"}` fills `{{ variables.reset_link }}`.' + ), + template: zod + .object({ + key: zod + .string() + .optional() + .describe( + 'Key of the template, as uploaded with `PUT \/api\/projects\/@current\/messaging_templates\/by_key\/{key}\/`. Give either `key` or `id`.' + ), + hash: zod + .string() + .optional() + .describe( + 'Content hash of the template you hold, as `v1:` and 64 hex characters. When PostHog never published that content, the send answers `template_outdated` and sends nothing. Only with `key`.' + ), + id: zod + .uuid() + .optional() + .describe('ID of the template, for templates made in PostHog. Give either `key` or `id`.'), + }) + .describe('The template to send, by key or by id.'), +}) + export const messagingCategoriesCreateBodyKeyMax = 64 export const messagingCategoriesCreateBodyNameMax = 128 @@ -251,6 +299,9 @@ export const MessagingSuppressionsRemoveSuppressionCreateBody = /* @__PURE__ */ .describe('The email address to suppress. Will not receive any messages until removed.'), }) +/** + * What every route of the transactional email API shares: the flag, workflow access and the send. + */ export const messagingTemplatesCreateBodyNameMax = 400 export const messagingTemplatesCreateBodyContentOneTemplatingDefault = `liquid` @@ -344,6 +395,9 @@ export const MessagingTemplatesCreateBody = /* @__PURE__ */ zod.object({ deleted: zod.boolean().optional().describe('Soft-delete flag. Set true to remove the template from the library.'), }) +/** + * What every route of the transactional email API shares: the flag, workflow access and the send. + */ export const messagingTemplatesUpdateBodyNameMax = 400 export const messagingTemplatesUpdateBodyContentOneTemplatingDefault = `liquid` @@ -437,6 +491,9 @@ export const MessagingTemplatesUpdateBody = /* @__PURE__ */ zod.object({ deleted: zod.boolean().optional().describe('Soft-delete flag. Set true to remove the template from the library.'), }) +/** + * What every route of the transactional email API shares: the flag, workflow access and the send. + */ export const messagingTemplatesPartialUpdateBodyNameMax = 400 export const messagingTemplatesPartialUpdateBodyContentOneTemplatingDefault = `liquid` @@ -531,6 +588,9 @@ export const MessagingTemplatesPartialUpdateBody = /* @__PURE__ */ zod.object({ deleted: zod.boolean().optional().describe('Soft-delete flag. Set true to remove the template from the library.'), }) +/** + * What every route of the transactional email API shares: the flag, workflow access and the send. + */ export const MessagingTemplatesDesignPartialUpdateBody = /* @__PURE__ */ zod.object({ operations: zod .array( diff --git a/products/workflows/mcp/email_templates.yaml b/products/workflows/mcp/email_templates.yaml index 7659bf49b1c3..07fb2f8d31b9 100644 --- a/products/workflows/mcp/email_templates.yaml +++ b/products/workflows/mcp/email_templates.yaml @@ -140,6 +140,24 @@ tools: publish changed content at once and answer unchanged when nothing changed. Uploading replaces edits made in PostHog. A plain 404 means the transactional email API is not enabled for this project: stop and tell the user. + workflows-send-email: + operation: messaging_send_email_create + enabled: true + feature_flag: workflows-transactional-email-api + annotations: + readOnly: false + destructive: false + idempotent: false + title: Send transactional email + description: > + Send one transactional email to one recipient from a template, named by template.key (as uploaded with + workflows-upload-email-template) or template.id (a template made in PostHog). Put every value the + template needs into message_data, read as {{ variables.* }}; the template can't read person properties. + Set identifiers.distinct_id to record the email on that person. Pass template.hash, the content_hash of the + last upload, to check that PostHog holds that content: template_outdated means nothing was sent, so upload + the template and send again; a template_superseded warning means newer content was sent. Each call sends + an email, so never repeat a call that succeeded. A plain 404 without a code means the transactional email + API is not enabled for this project: stop and tell the user. workflows-update-email-template: operation: messaging_templates_partial_update enabled: true diff --git a/services/mcp/schema/generated-tool-definitions.json b/services/mcp/schema/generated-tool-definitions.json index f3c6547dd7aa..35746b63570e 100644 --- a/services/mcp/schema/generated-tool-definitions.json +++ b/services/mcp/schema/generated-tool-definitions.json @@ -15229,6 +15229,21 @@ "readOnlyHint": false } }, + "workflows-send-email": { + "description": "Send one transactional email to one recipient from a template, named by template.key (as uploaded with workflows-upload-email-template) or template.id (a template made in PostHog). Put every value the template needs into message_data, read as {{ variables.* }}; the template can't read person properties. Set identifiers.distinct_id to record the email on that person. Pass template.hash, the content_hash of the last upload, to check that PostHog holds that content: template_outdated means nothing was sent, so upload the template and send again; a template_superseded warning means newer content was sent. Each call sends an email, so never repeat a call that succeeded. A plain 404 without a code means the transactional email API is not enabled for this project: stop and tell the user.", + "category": "Workflows", + "feature": "workflows", + "summary": "Send transactional email", + "title": "Send transactional email", + "required_scopes": ["hog_flow:write"], + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false + }, + "feature_flag": "workflows-transactional-email-api" + }, "workflows-show-email-template": { "description": "Render an email template as an inline preview for the user. Call after creating or updating a template, and whenever the user asks to see one. The response carries the final rendered html — read it before describing the result. For fetching a template to edit it, use workflows-get-email-template.", "category": "Workflows", diff --git a/services/mcp/schema/tool-definitions-all.json b/services/mcp/schema/tool-definitions-all.json index bc32edef6d66..81ee25b6721b 100644 --- a/services/mcp/schema/tool-definitions-all.json +++ b/services/mcp/schema/tool-definitions-all.json @@ -15974,6 +15974,21 @@ "readOnlyHint": false } }, + "workflows-send-email": { + "description": "Send one transactional email to one recipient from a template, named by template.key (as uploaded with workflows-upload-email-template) or template.id (a template made in PostHog). Put every value the template needs into message_data, read as {{ variables.* }}; the template can't read person properties. Set identifiers.distinct_id to record the email on that person. Pass template.hash, the content_hash of the last upload, to check that PostHog holds that content: template_outdated means nothing was sent, so upload the template and send again; a template_superseded warning means newer content was sent. Each call sends an email, so never repeat a call that succeeded. A plain 404 without a code means the transactional email API is not enabled for this project: stop and tell the user.", + "category": "Workflows", + "feature": "workflows", + "summary": "Send transactional email", + "title": "Send transactional email", + "required_scopes": ["hog_flow:write"], + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false + }, + "feature_flag": "workflows-transactional-email-api" + }, "workflows-show-email-template": { "description": "Render an email template as an inline preview for the user. Call after creating or updating a template, and whenever the user asks to see one. The response carries the final rendered html — read it before describing the result. For fetching a template to edit it, use workflows-get-email-template.", "category": "Workflows", diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 97708cdfe086..df58c7062047 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -101733,7 +101733,7 @@ export namespace Schemas { /** * Data for the template's Liquid placeholders, available as `variables`. For example `{"reset_link": "https://..."}` fills `{{ variables.reset_link }}`. */ - export type SendTransactionalEmailMessageData = {[key: string]: unknown}; + export type SendEmailMessageData = {[key: string]: unknown}; export interface TransactionalEmailIdentifiers { /** @@ -101744,6 +101744,47 @@ export namespace Schemas { distinct_id?: string | null; } + export interface TemplateReference { + /** Key of the template, as uploaded with `PUT /api/projects/@current/messaging_templates/by_key/{key}/`. Give either `key` or `id`. */ + key?: string; + /** Content hash of the template you hold, as `v1:` and 64 hex characters. When PostHog never published that content, the send answers `template_outdated` and sends nothing. Only with `key`. */ + hash?: string; + /** ID of the template, for templates made in PostHog. Give either `key` or `id`. */ + id?: string; + } + + export interface SendEmail { + /** Email address of the recipient. */ + to: string; + /** Who the email is for, used to attribute engagement events. */ + identifiers?: TransactionalEmailIdentifiers; + /** Data for the template's Liquid placeholders, available as `variables`. For example `{"reset_link": "https://..."}` fills `{{ variables.reset_link }}`. */ + message_data?: SendEmailMessageData; + /** The template to send, by key or by id. */ + template: TemplateReference; + } + + export interface SendEmailWarning { + /** Stable warning code to branch on, such as `template_superseded`. */ + code: string; + /** What happened. */ + detail: string; + /** What you can change, if the warning is not what you want. */ + fix: string; + } + + export interface SendEmailResponse { + /** ID of the queued send. Delivery and engagement are reported against the template asynchronously. */ + message_id: string; + /** Things to know about the queued email. Empty in the normal case. */ + warnings: SendEmailWarning[]; + } + + /** + * Data for the template's Liquid placeholders, available as `variables`. For example `{"reset_link": "https://..."}` fills `{{ variables.reset_link }}`. + */ + export type SendTransactionalEmailMessageData = {[key: string]: unknown}; + export interface SendTransactionalEmail { /** Email address of the recipient. */ to: string; @@ -112226,26 +112267,6 @@ export namespace Schemas { ref?: string | null; } - export interface TemplateUploadError { - /** Error class, such as `invalid_request`. */ - type: string; - /** Stable error code to branch on, such as `invalid_template_key`. */ - code: string; - /** What went wrong. */ - detail: string; - /** - * The request field the error is about, if any. - * @nullable - */ - attr: string | null; - /** Why PostHog refuses the request. */ - why?: string; - /** What to change before you retry. */ - fix?: string; - /** Documentation for this error code. */ - docs_url?: string; - } - /** * * `created` - Created * * `updated` - Updated @@ -112983,6 +113004,26 @@ export namespace Schemas { runs: TrainingRunHistoryEntry[]; } + export interface TransactionalEmailError { + /** Error class, such as `invalid_request`. */ + type: string; + /** Stable error code to branch on, such as `invalid_template_key`. */ + code: string; + /** What went wrong. */ + detail: string; + /** + * The request field the error is about, if any. + * @nullable + */ + attr: string | null; + /** Why PostHog refuses the request. */ + why?: string; + /** What to change before you retry. */ + fix?: string; + /** Documentation for this error code. */ + docs_url?: string; + } + export interface TranslateRequest { /** * The text to translate diff --git a/services/mcp/src/generated/email_templates/api.ts b/services/mcp/src/generated/email_templates/api.ts index 520649dad9ac..02a9d9833cab 100644 --- a/services/mcp/src/generated/email_templates/api.ts +++ b/services/mcp/src/generated/email_templates/api.ts @@ -3,11 +3,70 @@ * MCP service uses these Zod schemas for generated tool handlers. * To regenerate: hogli build:openapi * - * PostHog API - MCP 6 enabled ops + * PostHog API - MCP 7 enabled ops * OpenAPI spec version: 1.0.0 */ import * as zod from 'zod' +/** + * Send one transactional email to one recipient from a template named by its key or id. `message_data` fills the template's `{{ variables.* }}` placeholders. With `template.hash`, PostHog checks that it holds the content you hold: content it never published sends nothing, and content that newer content replaced sends the current content with a `template_superseded` warning. Transactional emails skip opt-outs and frequency caps. + * @summary Send a transactional email + */ +export const MessagingSendEmailCreateParams = () => zod.object({ + project_id: zod + .string() + .describe( + "Project ID of the project you're trying to access. To find the ID of the project, make a call to \/api\/projects\/." + ), +}) + +export const messagingSendEmailCreateBodyIdentifiersOneDistinctIdMax = 400 + +export const MessagingSendEmailCreateBody = () => zod.object({ + to: zod.email().describe('Email address of the recipient.'), + identifiers: zod + .object({ + distinct_id: zod + .string() + .max(messagingSendEmailCreateBodyIdentifiersOneDistinctIdMax) + .nullish() + .describe( + "Distinct ID of the recipient's person. The email's engagement events (sent, delivered, opened, clicked, bounced) and the send itself are recorded against that person. Defaults to the recipient's email address. The template can't read the person's properties, so pass everything it needs in `message_data`." + ), + }) + .optional() + .describe('Who the email is for, used to attribute engagement events.'), + message_data: zod + .record(zod.string(), zod.unknown()) + .optional() + .describe( + 'Data for the template\'s Liquid placeholders, available as `variables`. For example `{\"reset_link\": \"https:\/\/...\"}` fills `{{ variables.reset_link }}`.' + ), + template: zod + .object({ + key: zod + .string() + .optional() + .describe( + 'Key of the template, as uploaded with `PUT \/api\/projects\/@current\/messaging_templates\/by_key\/{key}\/`. Give either `key` or `id`.' + ), + hash: zod + .string() + .optional() + .describe( + 'Content hash of the template you hold, as `v1:` and 64 hex characters. When PostHog never published that content, the send answers `template_outdated` and sends nothing. Only with `key`.' + ), + id: zod + .string() + .optional() + .describe('ID of the template, for templates made in PostHog. Give either `key` or `id`.'), + }) + .describe('The template to send, by key or by id.'), +}) + +/** + * What every route of the transactional email API shares: the flag, workflow access and the send. + */ export const MessagingTemplatesListParams = () => zod.object({ project_id: zod .string() @@ -21,6 +80,9 @@ export const MessagingTemplatesListQueryParams = () => zod.object({ offset: zod.number().optional().describe('The initial index from which to return the results.'), }) +/** + * What every route of the transactional email API shares: the flag, workflow access and the send. + */ export const MessagingTemplatesCreateParams = () => zod.object({ project_id: zod .string() @@ -116,6 +178,9 @@ export const MessagingTemplatesCreateBody = () => zod.object({ deleted: zod.boolean().optional().describe('Soft-delete flag. Set true to remove the template from the library.'), }) +/** + * What every route of the transactional email API shares: the flag, workflow access and the send. + */ export const MessagingTemplatesRetrieveParams = () => zod.object({ id: zod.string().describe('A UUID string identifying this message template.'), project_id: zod @@ -125,6 +190,9 @@ export const MessagingTemplatesRetrieveParams = () => zod.object({ ), }) +/** + * What every route of the transactional email API shares: the flag, workflow access and the send. + */ export const MessagingTemplatesPartialUpdateParams = () => zod.object({ id: zod.string().describe('A UUID string identifying this message template.'), project_id: zod @@ -222,6 +290,9 @@ export const MessagingTemplatesPartialUpdateBody = () => zod.object({ deleted: zod.boolean().optional().describe('Soft-delete flag. Set true to remove the template from the library.'), }) +/** + * What every route of the transactional email API shares: the flag, workflow access and the send. + */ export const MessagingTemplatesDesignPartialUpdateParams = () => zod.object({ id: zod.string().describe('A UUID string identifying this message template.'), project_id: zod diff --git a/services/mcp/src/tools/generated/email_templates.ts b/services/mcp/src/tools/generated/email_templates.ts index f215f8aa5204..54e1a90eb16f 100644 --- a/services/mcp/src/tools/generated/email_templates.ts +++ b/services/mcp/src/tools/generated/email_templates.ts @@ -201,6 +201,38 @@ const workflowsUploadEmailTemplate = (): ToolBase< }, }) +const WorkflowsSendEmailSchema = () => { + const MessagingSendEmailCreateBody = orvalSchemas.MessagingSendEmailCreateBody() + return MessagingSendEmailCreateBody +} + +const workflowsSendEmail = (): ToolBase, unknown> => ({ + name: 'workflows-send-email', + schema: WorkflowsSendEmailSchema(), + handler: async (context: Context, params: z.infer>) => { + const projectId = await context.stateManager.getProjectId() + const body: Record = {} + if (params.to !== undefined) { + body['to'] = params.to + } + if (params.identifiers !== undefined) { + body['identifiers'] = params.identifiers + } + if (params.message_data !== undefined) { + body['message_data'] = params.message_data + } + if (params.template !== undefined) { + body['template'] = params.template + } + const result = await context.api.request({ + method: 'POST', + path: `/api/projects/${encodeURIComponent(String(projectId))}/messaging/send/email/`, + body, + }) + return result + }, +}) + const WorkflowsUpdateEmailTemplateSchema = () => { const MessagingTemplatesPartialUpdateBody = orvalSchemas.MessagingTemplatesPartialUpdateBody() const MessagingTemplatesPartialUpdateParams = orvalSchemas.MessagingTemplatesPartialUpdateParams() @@ -253,5 +285,6 @@ export const GENERATED_TOOLS: Record ToolBase> = { 'workflows-patch-email-template': workflowsPatchEmailTemplate, 'workflows-show-email-template': workflowsShowEmailTemplate, 'workflows-upload-email-template': workflowsUploadEmailTemplate, + 'workflows-send-email': workflowsSendEmail, 'workflows-update-email-template': workflowsUpdateEmailTemplate, } diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-send-email.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-send-email.json new file mode 100644 index 000000000000..26f6e25afa6b --- /dev/null +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-send-email.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "identifiers": { + "description": "Who the email is for, used to attribute engagement events.", + "properties": { + "distinct_id": { + "anyOf": [ + { + "maxLength": 400, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Distinct ID of the recipient's person. The email's engagement events (sent, delivered, opened, clicked, bounced) and the send itself are recorded against that person. Defaults to the recipient's email address. The template can't read the person's properties, so pass everything it needs in `message_data`." + } + }, + "type": "object" + }, + "message_data": { + "additionalProperties": {}, + "description": "Data for the template's Liquid placeholders, available as `variables`. For example `{\"reset_link\": \"https://...\"}` fills `{{ variables.reset_link }}`.", + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + "template": { + "description": "The template to send, by key or by id.", + "properties": { + "hash": { + "description": "Content hash of the template you hold, as `v1:` and 64 hex characters. When PostHog never published that content, the send answers `template_outdated` and sends nothing. Only with `key`.", + "type": "string" + }, + "id": { + "description": "ID of the template, for templates made in PostHog. Give either `key` or `id`.", + "type": "string" + }, + "key": { + "description": "Key of the template, as uploaded with `PUT /api/projects/@current/messaging_templates/by_key/{key}/`. Give either `key` or `id`.", + "type": "string" + } + }, + "type": "object" + }, + "to": { + "description": "Email address of the recipient.", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "type": "string" + } + }, + "required": ["to", "template"], + "type": "object" +} From 50c19098ea60d4f1f12c97c7d02142af2279ec6d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:51:46 +0000 Subject: [PATCH 06/30] feat(messaging): adopt an email template by giving it a key A PATCH whose only field is key gives a keyless template its key, once, so the next upload under that key takes it over and keeps its id, workflow and sender. Refuses key_already_set, key_in_use, invalid_template_key, and a key change sent with other fields. Project secret API keys still cannot reach the PATCH. The MCP update tool exposes the field. Co-Authored-By: Claude Opus 5.5 --- .../messaging/backend/facade/contracts.py | 12 ++ .../messaging/backend/facade/templates.py | 31 +++++ .../presentation/views/message_templates.py | 105 +++++++++++----- .../backend/services/template_keys.py | 83 ++++++++++++ .../tests/api/test_template_adoption.py | 118 ++++++++++++++++++ .../backend/tests/api/test_template_upload.py | 22 ++-- .../frontend/generated/api.schemas.ts | 10 +- products/messaging/frontend/generated/api.ts | 6 +- .../messaging/frontend/generated/api.zod.ts | 9 ++ products/workflows/mcp/email_templates.yaml | 3 + .../schema/generated-tool-definitions.json | 2 +- services/mcp/schema/tool-definitions-all.json | 2 +- services/mcp/src/api/generated.ts | 10 +- .../mcp/src/generated/email_templates/api.ts | 9 ++ .../src/tools/generated/email_templates.ts | 3 + .../workflows-update-email-template.json | 5 + 16 files changed, 374 insertions(+), 56 deletions(-) create mode 100644 products/messaging/backend/services/template_keys.py create mode 100644 products/messaging/backend/tests/api/test_template_adoption.py diff --git a/products/messaging/backend/facade/contracts.py b/products/messaging/backend/facade/contracts.py index 5e86edc4ecad..45965fb3ad22 100644 --- a/products/messaging/backend/facade/contracts.py +++ b/products/messaging/backend/facade/contracts.py @@ -113,6 +113,18 @@ def __init__(self, *, detail: str, why: str, fix: str) -> None: self.fix = fix +class TemplateKeyRefused(Exception): + """The template can't take this key. `code` is stable for API clients; `detail`, `why` and `fix` are written for + the caller.""" + + def __init__(self, *, code: str, detail: str, why: str, fix: str) -> None: + super().__init__(detail) + self.code = code + self.detail = detail + self.why = why + self.fix = fix + + @frozen class TemplateSource: """Where the uploaded file lives. A part that is None leaves the stored part as it is.""" diff --git a/products/messaging/backend/facade/templates.py b/products/messaging/backend/facade/templates.py index efac450d93b9..d4ecf06cfba7 100644 --- a/products/messaging/backend/facade/templates.py +++ b/products/messaging/backend/facade/templates.py @@ -18,6 +18,7 @@ from products.messaging.backend.models.message_template import MessageTemplate from products.messaging.backend.services import ( message_templates as templates_service, + template_keys, template_ownership, template_saves, template_uploads, @@ -182,6 +183,36 @@ def release_template( ) +def is_adoption(intent: TemplateWriteIntent) -> bool: + """Whether `intent` gives a template its key: a PATCH whose only field is `key`.""" + return template_keys.is_adoption(intent) + + +def check_template_key_write(template: MessageTemplateRow, intent: TemplateWriteIntent) -> None: + """Raise TemplateKeyRefused when `intent` is not an adoption and sets or changes the template's key.""" + template_keys.check_key_unchanged(template, intent) + + +def adopt_template( + team_id: int, + template_id: UUID, + key: str, + *, + intent: TemplateWriteIntent, + require_workflow_editor: Callable[[UUID], None], +) -> MessageTemplateRow: + """Give a template that has no key the key `key`, so the next upload under it takes the template over. + + Raises TemplateKeyRefused when the template already has a key or another template has this one, and + TemplateManagedByCode when the template refuses `intent`. + """ + return _to_contract( + template_keys.adopt_template( + team_id, template_id, key, intent=intent, require_workflow_editor=require_workflow_editor + ) + ) + + def upload_template( team_id: int, upload: TemplateUpload, diff --git a/products/messaging/backend/presentation/views/message_templates.py b/products/messaging/backend/presentation/views/message_templates.py index 87aa84a07028..e7e2620de1f6 100644 --- a/products/messaging/backend/presentation/views/message_templates.py +++ b/products/messaging/backend/presentation/views/message_templates.py @@ -43,6 +43,7 @@ from products.messaging.backend.facade.contracts import ( TEMPLATE_KEY_MAX_LENGTH, TEMPLATE_KEY_PATTERN, + TemplateKeyRefused, TemplateManagedBy, TemplateManagedByCode, TemplateSource, @@ -55,9 +56,12 @@ MessageTemplateMissing, MessageTemplateRow, UploadedTemplate, + adopt_template, + check_template_key_write, check_template_write, create_template, get_template, + is_adoption, is_release, list_templates, release_template, @@ -226,7 +230,7 @@ class MessageTemplateSerializer(serializers.Serializer): read_only=True, allow_null=True, help_text="Stable identifier code uses to upload and send this template, unique within the project. Null " - "for templates that have no key.", + "for templates that have no key. Set once, by the first upload or by a PATCH whose only field is `key`.", ) managed_by = serializers.ChoiceField( choices=TemplateManagedBy.choices, @@ -289,6 +293,16 @@ def validate(self, data: Any) -> Any: return data +class MessageTemplateUpdateSerializer(MessageTemplateSerializer): + key = serializers.CharField( # type: ignore[assignment] + required=False, + max_length=TEMPLATE_KEY_MAX_LENGTH, + help_text="Give a template that has no key one, so code can take it over: the next upload under this key " + "updates this template and keeps its id, workflow and sender. Send it as the only field of the PATCH. Letters, " + "digits, hyphens (-) and underscores (_). Set once: a template that has a key keeps it.", + ) + + class DesignPatchSerializer(serializers.Serializer): operations = serializers.ListField( child=DesignOperationSerializer(), @@ -458,8 +472,8 @@ class FieldError: message: str -def invalid_template_key(key: str) -> TemplateRefusal | None: - if len(key) <= TEMPLATE_KEY_MAX_LENGTH and re.match(TEMPLATE_KEY_PATTERN, key): +def invalid_template_key(key: object) -> TemplateRefusal | None: + if isinstance(key, str) and len(key) <= TEMPLATE_KEY_MAX_LENGTH and re.match(TEMPLATE_KEY_PATTERN, key): return None return TemplateRefusal( code="invalid_template_key", @@ -544,12 +558,25 @@ class TransactionalEmailTeamSustainedThrottle(ProjectSecretApiKeyTeamRateThrottl TEMPLATE_UPDATE_SCHEMA = extend_schema( parameters=[TEMPLATE_ID_PARAMETER], responses={200: MessageTemplateSerializer, 403: MANAGED_BY_CODE_RESPONSE} ) +TEMPLATE_PARTIAL_UPDATE_SCHEMA = extend_schema( + parameters=[TEMPLATE_ID_PARAMETER], + request=MessageTemplateUpdateSerializer, + responses={ + 200: MessageTemplateSerializer, + 400: OpenApiResponse( + response=TemplateErrorSerializer, + description="A field is invalid. For `key`: `invalid_template_key`, `key_already_set`, `key_in_use`, or " + "`invalid_request` when it is sent with other fields", + ), + 403: MANAGED_BY_CODE_RESPONSE, + }, +) @extend_schema_view( retrieve=extend_schema(parameters=[TEMPLATE_ID_PARAMETER]), update=TEMPLATE_UPDATE_SCHEMA, - partial_update=TEMPLATE_UPDATE_SCHEMA, + partial_update=TEMPLATE_PARTIAL_UPDATE_SCHEMA, destroy=extend_schema(parameters=[TEMPLATE_ID_PARAMETER]), ) class MessageTemplatesViewSet( @@ -611,6 +638,9 @@ def handle_exception(self, exc: Exception) -> Response: if isinstance(exc, TemplateManagedByCode): refusal = TemplateRefusal(code=exc.code, detail=exc.detail, attr=None, why=exc.why, fix=exc.fix) return Response(TemplateErrorSerializer(refusal).data, status=status.HTTP_403_FORBIDDEN) + if isinstance(exc, TemplateKeyRefused): + refusal = TemplateRefusal(code=exc.code, detail=exc.detail, attr="key", why=exc.why, fix=exc.fix) + return Response(TemplateErrorSerializer(refusal).data, status=status.HTTP_400_BAD_REQUEST) return super().handle_exception(exc) def _with_creators(self, templates: Sequence[MessageTemplateRow]) -> list[SimpleNamespace]: @@ -642,25 +672,10 @@ def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) template = create_template(self.team_id, request.user.id, serializer.validated_data) - # report_user_action injects source and MCP-client properties from the request, so a create from the - # visual editor and one from the agent count in the same metric. Capture must never break the request. - try: - report_user_action( - self.request.user, - "message_template_created", - { - "template_id": str(template.id), - "team_id": str(self.team_id), - "organization_id": str(self.organization_id), - }, - team=self.team, - # Without this the event groups by the person's current organization, which can differ - # from the route's when they belong to more than one. - organization=self.organization, - request=self.request, - ) - except Exception as e: - logger.warning("Failed to capture message template usage event", error=str(e)) + self._report_user_action( + "message_template_created", + {"template_id": str(template.id), "organization_id": str(self.organization_id)}, + ) data = self._serialize(template) return Response(data, status=status.HTTP_201_CREATED, headers=self.get_success_headers(data)) @@ -674,6 +689,9 @@ def update(self, request: Request, *args: Any, **kwargs: Any) -> Response: ) self._emit_resource_edited(released) return Response(self._serialize(released)) + if is_adoption(intent): + return self._adopt(template, intent) + check_template_key_write(template, intent) serializer = self.get_serializer(self._with_creators([template])[0], data=request.data, partial=partial) serializer.is_valid(raise_exception=True) try: @@ -690,6 +708,35 @@ def update(self, request: Request, *args: Any, **kwargs: Any) -> Response: self._emit_resource_edited(updated) return Response(self._serialize(updated)) + def _adopt(self, template: MessageTemplateRow, intent: TemplateWriteIntent) -> Response: + key = intent.payload["key"] + refused = invalid_template_key(key) + if refused is not None: + return Response(TemplateErrorSerializer(refused).data, status=status.HTTP_400_BAD_REQUEST) + adopted = adopt_template( + self.team_id, template.id, key, intent=intent, require_workflow_editor=self._require_workflow_editor + ) + self._emit_resource_edited(adopted) + self._report_user_action("message template adopted", {"template_id": str(adopted.id)}) + return Response(self._serialize(adopted)) + + def _report_user_action(self, event: str, properties: dict[str, Any]) -> None: + # report_user_action injects source and MCP-client properties from the request, so a change from the + # visual editor and one from the agent count in the same metric. Capture must never break the request. + try: + report_user_action( + self.request.user, + event, + {"team_id": str(self.team_id), **properties}, + team=self.team, + # Without this the event groups by the person's current organization, which can differ + # from the route's when they belong to more than one. + organization=self.organization, + request=self.request, + ) + except Exception as e: + logger.warning("Failed to capture message template usage event", error=str(e)) + def _require_workflow_editor(self, hog_flow_id: Any) -> None: # A project secret API key has no user to check, and its scope already limits it to sending and uploads. if is_authenticated_via_project_secret_api_key(self.request): @@ -998,17 +1045,7 @@ def make_transactional(self, request: Any, *args: Any, **kwargs: Any) -> Respons updated = self.get_object() if not was_transactional: self._emit_resource_edited(updated) - try: - report_user_action( - self.request.user, - "message template made transactional", - {"template_id": str(template.id), "team_id": str(self.team_id)}, - team=self.team, - organization=self.organization, - request=self.request, - ) - except Exception as e: - logger.warning("Failed to capture message template usage event", error=str(e)) + self._report_user_action("message template made transactional", {"template_id": str(template.id)}) return Response(self._serialize(updated)) def _report_transactional_email_send( diff --git a/products/messaging/backend/services/template_keys.py b/products/messaging/backend/services/template_keys.py new file mode 100644 index 000000000000..7922fc902c58 --- /dev/null +++ b/products/messaging/backend/services/template_keys.py @@ -0,0 +1,83 @@ +"""Give a template made in PostHog a key, so the next upload under that key takes it over. A key is set once.""" + +from typing import Final, Protocol +from uuid import UUID + +from django.db import IntegrityError, transaction + +from products.messaging.backend.facade.contracts import TemplateKeyRefused, TemplateWriteIntent +from products.messaging.backend.models import MessageTemplate +from products.messaging.backend.services.template_ownership import check_write +from products.messaging.backend.services.template_saves import WorkflowEditorCheck, lock_template +from products.messaging.backend.services.template_uploads import KEY_CONSTRAINT + +ADOPTION_ACTION: Final = "partial_update" + + +class KeyedTemplate(Protocol): + @property + def key(self) -> str | None: ... + + +def is_adoption(intent: TemplateWriteIntent) -> bool: + """A PATCH whose only field is `key`.""" + return intent.action == ADOPTION_ACTION and set(intent.payload.keys()) == {"key"} + + +def check_key_unchanged(template: KeyedTemplate, intent: TemplateWriteIntent) -> None: + """Raise TemplateKeyRefused when a save that is not an adoption tries to set or change the key. A form sends the + stored key back with every save, so the stored key passes.""" + if "key" not in intent.payload or intent.payload["key"] == template.key: + return + if template.key: + raise _key_already_set(template.key) + raise TemplateKeyRefused( + code="invalid_request", + detail="`key` can't be set together with other fields.", + why="A key prepares the template for uploads from code, so it is set in a request of its own.", + fix="Send a PATCH whose only field is `key`, then send the other changes.", + ) + + +def adopt_template( + team_id: int, + template_id: UUID, + key: str, + *, + intent: TemplateWriteIntent, + require_workflow_editor: WorkflowEditorCheck, +) -> MessageTemplate: + """Set the key of a template that has none. The content, the owner and the workflow stay as they are.""" + try: + with transaction.atomic(): + template = lock_template(team_id, template_id) + check_write(template, intent) + if template.transactional_hog_flow_id: + require_workflow_editor(template.transactional_hog_flow_id) + if template.key: + raise _key_already_set(template.key) + template.key = key + template.save(update_fields=["key", "updated_at"]) + except IntegrityError as error: + if KEY_CONSTRAINT not in str(error): + raise + raise _key_in_use(key) from error + return template + + +def _key_already_set(key: str) -> TemplateKeyRefused: + return TemplateKeyRefused( + code="key_already_set", + detail=f"This template already has the key `{key}`.", + why="Code names the template by its key, so a key never changes.", + fix=f"Use `{key}` in your code, or duplicate the template and give the copy a key.", + ) + + +def _key_in_use(key: str) -> TemplateKeyRefused: + return TemplateKeyRefused( + code="key_in_use", + detail=f"Another template in this project has the key `{key}`.", + why="A key names one template in a project.", + fix="Pick another key, or upload your file to the template that has this key.", + ) diff --git a/products/messaging/backend/tests/api/test_template_adoption.py b/products/messaging/backend/tests/api/test_template_adoption.py new file mode 100644 index 000000000000..e0db15137ba0 --- /dev/null +++ b/products/messaging/backend/tests/api/test_template_adoption.py @@ -0,0 +1,118 @@ +from typing import Any + +from posthog.test.base import APIBaseTest + +from parameterized import parameterized +from rest_framework import status +from rest_framework.test import APIClient + +from posthog.models import ProjectSecretAPIKey +from posthog.models.personal_api_key import PersonalAPIKey, hash_key_value +from posthog.models.utils import generate_random_token_personal, generate_random_token_secret + +from products.messaging.backend.models.message_template import MessageTemplate + +DOCS_URL = "https://posthog.com/docs/workflows/transactional-email-api" + + +class TestTemplateAdoption(APIBaseTest): + def setUp(self): + super().setUp() + self.template = self._template_made_in_posthog("Welcome") + + def _template_made_in_posthog(self, name: str, key: str | None = None) -> MessageTemplate: + return MessageTemplate.objects.create( + team=self.team, + name=name, + key=key, + content={"email": {"subject": "Hello", "html": "

Hello

"}}, + type="email", + ) + + def _url(self, template: MessageTemplate) -> str: + return f"/api/projects/{self.team.id}/messaging_templates/{template.id}/" + + def _adopt(self, key: Any, client: APIClient | None = None) -> Any: + return (client or self.client).patch(self._url(self.template), {"key": key}, format="json") + + def _stored_key(self) -> str | None: + return self.client.get(self._url(self.template)).json()["key"] + + def _personal_api_key_client(self) -> APIClient: + token = generate_random_token_personal() + PersonalAPIKey.objects.create( + label="MCP", user=self.user, secure_value=hash_key_value(token), scopes=["hog_flow:write"] + ) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_X_POSTHOG_CLIENT="mcp") + return client + + def _project_secret_api_key_client(self) -> APIClient: + token = generate_random_token_secret() + ProjectSecretAPIKey.objects.create( + team=self.team, + label="adoption", + secure_value=hash_key_value(token), + scopes=["hog_flow:write"], + mask_value=f"{token[:4]}...{token[-4:]}", + ) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + return client + + @parameterized.expand([("session", None), ("personal_api_key", "_personal_api_key_client")]) + def test_gives_a_template_without_a_key_its_key_and_keeps_its_content(self, _name, client_factory): + client = getattr(self, client_factory)() if client_factory else None + + response = self._adopt("welcome", client) + + assert response.status_code == status.HTTP_200_OK, response.json() + adopted = response.json() + assert (adopted["id"], adopted["key"], adopted["managed_by"]) == (str(self.template.id), "welcome", None) + assert adopted["content"]["email"]["subject"] == "Hello" + assert self._stored_key() == "welcome" + + def test_a_project_secret_api_key_cannot_adopt(self): + response = self._adopt("welcome", self._project_secret_api_key_client()) + + assert response.status_code == status.HTTP_403_FORBIDDEN, response.json() + assert self._stored_key() is None + + @parameterized.expand( + [ + ("twice", "welcome", {"key": "welcome"}, "key_already_set", "welcome"), + ("taken_key", None, {"key": "taken"}, "key_in_use", None), + ("invalid_key", None, {"key": "wel come"}, "invalid_template_key", None), + ("null_key", None, {"key": None}, "invalid_template_key", None), + ("key_with_other_fields", None, {"key": "welcome", "name": "Renamed"}, "invalid_request", None), + ( + "changed_key_with_other_fields", + "welcome", + {"key": "other", "name": "Renamed"}, + "key_already_set", + "welcome", + ), + ] + ) + def test_refuses_a_key_it_cannot_set_and_says_how_to_fix_it(self, _name, adopted_key, body, code, stored_key): + self._template_made_in_posthog("Taken", key="taken") + if adopted_key: + assert self._adopt(adopted_key).status_code == status.HTTP_200_OK + + response = self.client.patch(self._url(self.template), body, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json() + refusal = response.json() + assert (refusal["code"], refusal["attr"], refusal["docs_url"]) == (code, "key", f"{DOCS_URL}#{code}") + assert refusal["detail"] and refusal["why"] and refusal["fix"] + template = self.client.get(self._url(self.template)).json() + assert (template["key"], template["name"]) == (stored_key, "Welcome") + + def test_a_form_save_that_sends_the_stored_key_back_keeps_working(self): + assert self._adopt("welcome").status_code == status.HTTP_200_OK + form = self.client.get(self._url(self.template)).json() + + response = self.client.patch(self._url(self.template), {**form, "name": "Renamed"}, format="json") + + assert response.status_code == status.HTTP_200_OK, response.json() + assert (response.json()["name"], response.json()["key"]) == ("Renamed", "welcome") diff --git a/products/messaging/backend/tests/api/test_template_upload.py b/products/messaging/backend/tests/api/test_template_upload.py index 6456a67bff06..6ce776c2ca30 100644 --- a/products/messaging/backend/tests/api/test_template_upload.py +++ b/products/messaging/backend/tests/api/test_template_upload.py @@ -150,29 +150,37 @@ def test_a_changed_upload_is_what_the_next_send_delivers(self, _reload, post: Ma ) assert self._workflow(hog_flow_id)["version"] == 2 - def test_an_upload_claims_a_keyed_template_and_keeps_its_sender_and_paused_workflow(self, _post, _flag, _capture): + def test_an_upload_claims_an_adopted_template_and_keeps_its_id_sender_and_paused_workflow( + self, _post, _flag, _capture + ): other_sender = self._verified_sender("team@example.com") - adopted = MessageTemplate.objects.create( + made_in_posthog = MessageTemplate.objects.create( team=self.team, name="Welcome", - key="welcome", content={"email": {"subject": "Hello", "html": "

Hello

"}}, type="email", ) hog_flow_id = make_transactional( - self.team.id, adopted.id, user_id=self.user.id, from_integration_id=other_sender.id + self.team.id, made_in_posthog.id, user_id=self.user.id, from_integration_id=other_sender.id ) paused = self.client.patch(f"/api/projects/{self.team.id}/hog_flows/{hog_flow_id}", {"status": "archived"}) assert paused.status_code == status.HTTP_200_OK, paused.json() - response = self.client.put(self.url, WELCOME, format="json") + adopted = self.client.patch( + f"/api/projects/{self.team.id}/messaging_templates/{made_in_posthog.id}/", {"key": "welcome"}, format="json" + ) + response = self.psak.put(self.url, WELCOME, format="json") + assert adopted.status_code == status.HTTP_200_OK, adopted.json() + assert (adopted.json()["key"], adopted.json()["managed_by"]) == ("welcome", None) assert response.status_code == status.HTTP_200_OK, response.json() - assert (response.json()["result"], response.json()["id"]) == ("updated", str(adopted.id)) + assert (response.json()["result"], response.json()["id"]) == ("updated", str(made_in_posthog.id)) assert response.json()["managed_by"] == "code" + assert response.json()["transactional_hog_flow_id"] == str(hog_flow_id) workflow = self._workflow(str(hog_flow_id)) assert workflow["status"] == "archived" - assert self._sent_email(str(hog_flow_id))["from"]["integrationId"] == other_sender.id + email = self._sent_email(str(hog_flow_id)) + assert (email["subject"], email["from"]["integrationId"]) == (WELCOME["subject"], other_sender.id) def test_without_a_verified_default_sender_an_upload_writes_nothing(self, _post, _flag, _capture): self.sender.config = {"verified": False} diff --git a/products/messaging/frontend/generated/api.schemas.ts b/products/messaging/frontend/generated/api.schemas.ts index 315dedf2450a..9dd412eb588f 100644 --- a/products/messaging/frontend/generated/api.schemas.ts +++ b/products/messaging/frontend/generated/api.schemas.ts @@ -414,7 +414,7 @@ export interface MessageTemplateApi { */ readonly transactional_hog_flow_id: string | null /** - * Stable identifier code uses to upload and send this template, unique within the project. Null for templates that have no key. + * Stable identifier code uses to upload and send this template, unique within the project. Null for templates that have no key. Set once, by the first upload or by a PATCH whose only field is `key`. * @nullable */ readonly key: string | null @@ -474,7 +474,7 @@ export interface TemplateErrorApi { docs_url?: string } -export interface PatchedMessageTemplateApi { +export interface PatchedMessageTemplateUpdateApi { readonly id?: string /** * Human-readable template name shown in the library. @@ -506,10 +506,10 @@ export interface PatchedMessageTemplateApi { */ readonly transactional_hog_flow_id?: string | null /** - * Stable identifier code uses to upload and send this template, unique within the project. Null for templates that have no key. - * @nullable + * Give a template that has no key one, so code can take it over: the next upload under this key updates this template and keeps its id, workflow and sender. Send it as the only field of the PATCH. Letters, digits, hyphens (-) and underscores (_). Set once: a template that has a key keeps it. + * @maxLength 400 */ - readonly key?: string | null + key?: string /** Who owns the content. `code` when it was uploaded from a repository: PostHog then refuses edits, and the template changes through its next upload. To edit it in PostHog, send a PATCH whose only field is `managed_by` set to `gui`. `gui` or null when it is edited in PostHog. * * * `gui` - GUI diff --git a/products/messaging/frontend/generated/api.ts b/products/messaging/frontend/generated/api.ts index 22387a4d629a..131ec82357e5 100644 --- a/products/messaging/frontend/generated/api.ts +++ b/products/messaging/frontend/generated/api.ts @@ -30,7 +30,7 @@ import type { PaginatedOptOutsApi, PatchedDesignPatchApi, PatchedMessageCategoryApi, - PatchedMessageTemplateApi, + PatchedMessageTemplateUpdateApi, PreferencesLinkApi, RemoveOptOutRequestApi, SendTransactionalEmailApi, @@ -680,14 +680,14 @@ export const getMessagingTemplatesPartialUpdateUrl = (projectId: string, id: str export const messagingTemplatesPartialUpdate = async ( projectId: string, id: string, - patchedMessageTemplateApi?: NonReadonly, + patchedMessageTemplateUpdateApi?: NonReadonly, options?: RequestInit ): Promise => { return apiMutator(getMessagingTemplatesPartialUpdateUrl(projectId, id), { ...options, method: 'PATCH', headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(patchedMessageTemplateApi), + body: JSON.stringify(patchedMessageTemplateUpdateApi), }) } diff --git a/products/messaging/frontend/generated/api.zod.ts b/products/messaging/frontend/generated/api.zod.ts index d2b6b87a2df2..785b9176bd94 100644 --- a/products/messaging/frontend/generated/api.zod.ts +++ b/products/messaging/frontend/generated/api.zod.ts @@ -442,6 +442,8 @@ export const messagingTemplatesPartialUpdateBodyNameMax = 400 export const messagingTemplatesPartialUpdateBodyContentOneTemplatingDefault = `liquid` export const messagingTemplatesPartialUpdateBodyTypeMax = 24 +export const messagingTemplatesPartialUpdateBodyKeyMax = 400 + export const MessagingTemplatesPartialUpdateBody = /* @__PURE__ */ zod.object({ name: zod .string() @@ -529,6 +531,13 @@ export const MessagingTemplatesPartialUpdateBody = /* @__PURE__ */ zod.object({ .nullish() .describe('Message category ID to file the template under. Must belong to the same project.'), deleted: zod.boolean().optional().describe('Soft-delete flag. Set true to remove the template from the library.'), + key: zod + .string() + .max(messagingTemplatesPartialUpdateBodyKeyMax) + .optional() + .describe( + 'Give a template that has no key one, so code can take it over: the next upload under this key updates this template and keeps its id, workflow and sender. Send it as the only field of the PATCH. Letters, digits, hyphens (-) and underscores (_). Set once: a template that has a key keeps it.' + ), }) export const MessagingTemplatesDesignPartialUpdateBody = /* @__PURE__ */ zod.object({ diff --git a/products/workflows/mcp/email_templates.yaml b/products/workflows/mcp/email_templates.yaml index 2b42e1cd575a..247e3e73597e 100644 --- a/products/workflows/mcp/email_templates.yaml +++ b/products/workflows/mcp/email_templates.yaml @@ -161,6 +161,9 @@ tools: design on save. After updating, call workflows-show-email-template so the user sees the result, and share the returned _posthogUrl edit link. A template whose managed_by is code refuses every update with 403 immutable: change its file in the repository and upload it with workflows-upload-email-template instead. + To move a template made in PostHog into code, send key (such as welcome) as the only field. A key is set + once and never changes. The next workflows-upload-email-template with that key updates this template and + keeps its id, workflow and sender. exclude_params: - content.email.html response: diff --git a/services/mcp/schema/generated-tool-definitions.json b/services/mcp/schema/generated-tool-definitions.json index 88f181868815..69b6ad43fb56 100644 --- a/services/mcp/schema/generated-tool-definitions.json +++ b/services/mcp/schema/generated-tool-definitions.json @@ -15301,7 +15301,7 @@ } }, "workflows-update-email-template": { - "description": "Replace an email template's content as a whole. For small edits to an existing design (change a heading, a button color, one paragraph), prefer workflows-patch-email-template — it sends just the changed fields instead of the entire design JSON. Use this tool to set content from scratch or replace it wholesale: call workflows-get-email-template first (the design may carry fresh human edits from the visual editor) and send back the complete content.email with your edited design. The server re-renders the sent email from the design on save. After updating, call workflows-show-email-template so the user sees the result, and share the returned _posthogUrl edit link. A template whose managed_by is code refuses every update with 403 immutable: change its file in the repository and upload it with workflows-upload-email-template instead.", + "description": "Replace an email template's content as a whole. For small edits to an existing design (change a heading, a button color, one paragraph), prefer workflows-patch-email-template — it sends just the changed fields instead of the entire design JSON. Use this tool to set content from scratch or replace it wholesale: call workflows-get-email-template first (the design may carry fresh human edits from the visual editor) and send back the complete content.email with your edited design. The server re-renders the sent email from the design on save. After updating, call workflows-show-email-template so the user sees the result, and share the returned _posthogUrl edit link. A template whose managed_by is code refuses every update with 403 immutable: change its file in the repository and upload it with workflows-upload-email-template instead. To move a template made in PostHog into code, send key (such as welcome) as the only field. A key is set once and never changes. The next workflows-upload-email-template with that key updates this template and keeps its id, workflow and sender.", "category": "Workflows", "feature": "workflows", "summary": "Update email template", diff --git a/services/mcp/schema/tool-definitions-all.json b/services/mcp/schema/tool-definitions-all.json index b39d704accac..dfc59a6f0fa9 100644 --- a/services/mcp/schema/tool-definitions-all.json +++ b/services/mcp/schema/tool-definitions-all.json @@ -16046,7 +16046,7 @@ } }, "workflows-update-email-template": { - "description": "Replace an email template's content as a whole. For small edits to an existing design (change a heading, a button color, one paragraph), prefer workflows-patch-email-template — it sends just the changed fields instead of the entire design JSON. Use this tool to set content from scratch or replace it wholesale: call workflows-get-email-template first (the design may carry fresh human edits from the visual editor) and send back the complete content.email with your edited design. The server re-renders the sent email from the design on save. After updating, call workflows-show-email-template so the user sees the result, and share the returned _posthogUrl edit link. A template whose managed_by is code refuses every update with 403 immutable: change its file in the repository and upload it with workflows-upload-email-template instead.", + "description": "Replace an email template's content as a whole. For small edits to an existing design (change a heading, a button color, one paragraph), prefer workflows-patch-email-template — it sends just the changed fields instead of the entire design JSON. Use this tool to set content from scratch or replace it wholesale: call workflows-get-email-template first (the design may carry fresh human edits from the visual editor) and send back the complete content.email with your edited design. The server re-renders the sent email from the design on save. After updating, call workflows-show-email-template so the user sees the result, and share the returned _posthogUrl edit link. A template whose managed_by is code refuses every update with 403 immutable: change its file in the repository and upload it with workflows-upload-email-template instead. To move a template made in PostHog into code, send key (such as welcome) as the only field. A key is set once and never changes. The next workflows-upload-email-template with that key updates this template and keeps its id, workflow and sender.", "category": "Workflows", "feature": "workflows", "summary": "Update email template", diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 53f9f8d4adf4..074e0ae8b0cf 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -67191,7 +67191,7 @@ export namespace Schemas { */ readonly transactional_hog_flow_id: string | null; /** - * Stable identifier code uses to upload and send this template, unique within the project. Null for templates that have no key. + * Stable identifier code uses to upload and send this template, unique within the project. Null for templates that have no key. Set once, by the first upload or by a PATCH whose only field is `key`. * @nullable */ readonly key: string | null; @@ -83114,7 +83114,7 @@ export namespace Schemas { deleted?: boolean; } - export interface PatchedMessageTemplate { + export interface PatchedMessageTemplateUpdate { readonly id?: string; /** * Human-readable template name shown in the library. @@ -83146,10 +83146,10 @@ export namespace Schemas { */ readonly transactional_hog_flow_id?: string | null; /** - * Stable identifier code uses to upload and send this template, unique within the project. Null for templates that have no key. - * @nullable + * Give a template that has no key one, so code can take it over: the next upload under this key updates this template and keeps its id, workflow and sender. Send it as the only field of the PATCH. Letters, digits, hyphens (-) and underscores (_). Set once: a template that has a key keeps it. + * @maxLength 400 */ - readonly key?: string | null; + key?: string; /** Who owns the content. `code` when it was uploaded from a repository: PostHog then refuses edits, and the template changes through its next upload. To edit it in PostHog, send a PATCH whose only field is `managed_by` set to `gui`. `gui` or null when it is edited in PostHog. * * * `gui` - GUI diff --git a/services/mcp/src/generated/email_templates/api.ts b/services/mcp/src/generated/email_templates/api.ts index 520649dad9ac..7577834e64a5 100644 --- a/services/mcp/src/generated/email_templates/api.ts +++ b/services/mcp/src/generated/email_templates/api.ts @@ -139,6 +139,8 @@ export const messagingTemplatesPartialUpdateBodyNameMax = 400 export const messagingTemplatesPartialUpdateBodyContentOneTemplatingDefault = `liquid` export const messagingTemplatesPartialUpdateBodyTypeMax = 24 +export const messagingTemplatesPartialUpdateBodyKeyMax = 400 + export const MessagingTemplatesPartialUpdateBody = () => zod.object({ name: zod .string() @@ -220,6 +222,13 @@ export const MessagingTemplatesPartialUpdateBody = () => zod.object({ .nullish() .describe('Message category ID to file the template under. Must belong to the same project.'), deleted: zod.boolean().optional().describe('Soft-delete flag. Set true to remove the template from the library.'), + key: zod + .string() + .max(messagingTemplatesPartialUpdateBodyKeyMax) + .optional() + .describe( + 'Give a template that has no key one, so code can take it over: the next upload under this key updates this template and keeps its id, workflow and sender. Send it as the only field of the PATCH. Letters, digits, hyphens (-) and underscores (_). Set once: a template that has a key keeps it.' + ), }) export const MessagingTemplatesDesignPartialUpdateParams = () => zod.object({ diff --git a/services/mcp/src/tools/generated/email_templates.ts b/services/mcp/src/tools/generated/email_templates.ts index f215f8aa5204..c5d039aad9db 100644 --- a/services/mcp/src/tools/generated/email_templates.ts +++ b/services/mcp/src/tools/generated/email_templates.ts @@ -236,6 +236,9 @@ const workflowsUpdateEmailTemplate = (): ToolBase< if (params.deleted !== undefined) { body['deleted'] = params.deleted } + if (params.key !== undefined) { + body['key'] = params.key + } const result = await context.api.request({ method: 'PATCH', path: `/api/projects/${encodeURIComponent(String(projectId))}/messaging_templates/${encodeURIComponent(String(params.id))}/`, 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 5475ac1d2fb7..11cd08673044 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 @@ -108,6 +108,11 @@ "description": "A UUID string identifying this message template.", "type": "string" }, + "key": { + "description": "Give a template that has no key one, so code can take it over: the next upload under this key updates this template and keeps its id, workflow and sender. Send it as the only field of the PATCH. Letters, digits, hyphens (-) and underscores (_). Set once: a template that has a key keeps it.", + "maxLength": 400, + "type": "string" + }, "message_category": { "anyOf": [ { From 0062c7672a81ed62deb3f1e8e6eab508b28e56c4 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:57:27 +0000 Subject: [PATCH 07/30] feat(workflows): show code-managed email templates, their source and key A template uploaded from code gets a "Managed by code" tag on its card and page, a source line that links its file, repository and commit on known hosts, and a notice that edits here can't be saved. A keyed template shows its key, and the API tab sends through the send route by key or id. Co-Authored-By: Claude Opus 5.5 --- .../components/Git/gitMetadataParser.spec.ts | 29 ++++++ .../lib/components/Git/gitMetadataParser.ts | 56 +++++++++++- .../CodeManagedSource.stories.tsx | 55 ++++++++++++ .../TemplateLibrary/CodeManagedSource.tsx | 42 +++++++++ .../TemplateLibrary/CodeManagedTag.tsx | 25 ++++++ .../TemplateLibrary/MessageTemplate.tsx | 49 +++++++++-- .../TemplateLibrary/MessageTemplateCard.tsx | 2 + .../TransactionalEmailApiExample.tsx | 24 ++++- .../TemplateLibrary/codeManagedTemplate.ts | 40 +++++++++ .../messageTemplateLogic.test.ts | 88 ++++++++++++++++++- .../TemplateLibrary/messageTemplateLogic.ts | 17 ++++ .../frontend/TemplateLibrary/types.ts | 5 ++ 12 files changed, 421 insertions(+), 11 deletions(-) create mode 100644 products/workflows/frontend/TemplateLibrary/CodeManagedSource.stories.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/CodeManagedSource.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/CodeManagedTag.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/codeManagedTemplate.ts diff --git a/frontend/src/lib/components/Git/gitMetadataParser.spec.ts b/frontend/src/lib/components/Git/gitMetadataParser.spec.ts index 0ff93905ac42..5816a5c58788 100644 --- a/frontend/src/lib/components/Git/gitMetadataParser.spec.ts +++ b/frontend/src/lib/components/Git/gitMetadataParser.spec.ts @@ -80,4 +80,33 @@ describe('GitMetadataParser', () => { expect(GitMetadataParser.getGitHubRepositorySlug(remote_url)).toBe(expected) }) }) + + describe('getFileLink', () => { + it.each([ + { + description: 'a GitHub remote without a scheme', + remote_url: 'github.com/example/app', + expected: 'https://github.com/example/app/blob/main/emails/welcome.html', + }, + { + description: 'a GitLab SSH remote', + remote_url: 'git@gitlab.com:example/app.git', + expected: 'https://gitlab.com/example/app/-/blob/main/emails/welcome.html', + }, + { + description: 'a Bitbucket HTTPS remote', + remote_url: 'https://bitbucket.org/example/app', + expected: 'https://bitbucket.org/example/app/src/main/emails/welcome.html', + }, + { description: 'an unknown host', remote_url: 'git.example.com/example/app', expected: undefined }, + ])('links the file for $description', ({ remote_url, expected }) => { + expect(GitMetadataParser.getFileLink(remote_url, 'main', './emails/welcome.html')).toBe(expected) + }) + + it('encodes each part of the ref and the path', () => { + expect(GitMetadataParser.getFileLink('github.com/example/app', 'feat/a b', 'emails/a#b.html')).toBe( + 'https://github.com/example/app/blob/feat/a%20b/emails/a%23b.html' + ) + }) + }) }) diff --git a/frontend/src/lib/components/Git/gitMetadataParser.ts b/frontend/src/lib/components/Git/gitMetadataParser.ts index 6c6f198438c9..3acb87917f09 100644 --- a/frontend/src/lib/components/Git/gitMetadataParser.ts +++ b/frontend/src/lib/components/Git/gitMetadataParser.ts @@ -9,6 +9,11 @@ export type ParsedRemoteUrl = { } const GITHUB_SLUG_PATTERN = /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/ +const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,40}$/i + +function encodePathSegments(value: string): string { + return value.split('/').map(encodeURIComponent).join('/') +} export class GitMetadataParser { static getCommitLink(remote_url?: string, commit_id?: string): string | undefined { @@ -33,6 +38,28 @@ export class GitMetadataParser { return this.buildBranchLink(parsedRemoteUrl, branch) } + static getRefLink(remote_url?: string, ref?: string): string | undefined { + if (!ref) { + return undefined + } + return this.isCommitSha(ref) ? this.getCommitLink(remote_url, ref) : this.getBranchLink(remote_url, ref) + } + + static getFileLink(remote_url?: string, ref?: string, path?: string): string | undefined { + if (!remote_url || !ref || !path) { + return undefined + } + const parsedRemoteUrl = this.parseRemoteUrl(remote_url) + if (!parsedRemoteUrl) { + return undefined + } + return this.buildFileLink(parsedRemoteUrl, ref, path) + } + + static isCommitSha(ref: string): boolean { + return COMMIT_SHA_PATTERN.test(ref) + } + static getRepoLink(remote_url?: string): string | undefined { if (!remote_url) { return undefined @@ -57,7 +84,11 @@ export class GitMetadataParser { } static parseRemoteUrl(remoteUrl: string): ParsedRemoteUrl | undefined { - return this.parseSshRemoteUrl(remoteUrl) || this.parseHttpsRemoteUrl(remoteUrl) + return ( + this.parseSshRemoteUrl(remoteUrl) || + this.parseHttpsRemoteUrl(remoteUrl) || + this.parseSchemelessRemoteUrl(remoteUrl) + ) } private static buildRemoteLink(parsedUrl: ParsedRemoteUrl): string | undefined { @@ -95,6 +126,21 @@ export class GitMetadataParser { } } + private static buildFileLink(parsedUrl: ParsedRemoteUrl, ref: string, path: string): string | undefined { + const base = `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}` + const refAndPath = `${encodePathSegments(ref)}/${encodePathSegments(path.replace(/^\.?\//, ''))}` + switch (parsedUrl.provider) { + case 'github': + return `${base}/blob/${refAndPath}` + case 'gitlab': + return `${base}/-/blob/${refAndPath}` + case 'bitbucket': + return `${base}/src/${refAndPath}` + default: + return undefined + } + } + private static parseSshRemoteUrl(remoteUrl: string): ParsedRemoteUrl | undefined { // git@github.com:user/repo.git @@ -153,6 +199,14 @@ export class GitMetadataParser { return { provider, owner, repository, providerUrl } } + private static parseSchemelessRemoteUrl(remoteUrl: string): ParsedRemoteUrl | undefined { + // github.com/user/repo + if (remoteUrl.includes('://') || remoteUrl.includes('@')) { + return undefined + } + return this.parseHttpsRemoteUrl(`https://${remoteUrl}`) + } + private static parseDomain(domain: string): [GitProvider, string | undefined] { switch (domain) { case 'github.com': diff --git a/products/workflows/frontend/TemplateLibrary/CodeManagedSource.stories.tsx b/products/workflows/frontend/TemplateLibrary/CodeManagedSource.stories.tsx new file mode 100644 index 000000000000..9938c90b05eb --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/CodeManagedSource.stories.tsx @@ -0,0 +1,55 @@ +import { Meta, StoryFn } from '@storybook/react' + +import { CodeManagedSource } from './CodeManagedSource' +import { CodeManagedTag } from './CodeManagedTag' +import { templateSource } from './codeManagedTemplate' +import { NEW_TEMPLATE } from './constants' +import { MessageTemplate } from './types' + +const meta: Meta = { + title: 'Products/Workflows/Template library/Code managed source', + component: CodeManagedSource, +} +export default meta + +function template(overrides: Partial = {}): MessageTemplate { + return { + ...NEW_TEMPLATE, + id: 'template-1', + name: 'Welcome', + key: 'welcome', + managed_by: 'code', + source_repository: 'github.com/example/app', + source_path: 'emails/welcome.html', + source_ref: '3f2c1ab9d4e5f60718293a4b5c6d7e8f90a1b2c3', + ...overrides, + } +} + +const Template: StoryFn<{ template: MessageTemplate }> = ({ template }) => ( +
+
+

{template.name}

+ +
+ +
+) + +export const UploadedFromACommit = Template.bind({}) +UploadedFromACommit.args = { template: template() } + +export const LongPathOnGitLabBranch = Template.bind({}) +LongPathOnGitLabBranch.args = { + template: template({ + source_repository: 'gitlab.com/example/customer-lifecycle-emails', + source_path: 'emails/onboarding/workspace-owners/welcome-to-your-new-workspace.html', + source_ref: 'main', + }), +} + +export const UnknownHost = Template.bind({}) +UnknownHost.args = { template: template({ source_repository: 'git.example.com/team/app' }) } + +export const NoRecordedSource = Template.bind({}) +NoRecordedSource.args = { template: template({ source_repository: null, source_path: null, source_ref: null }) } diff --git a/products/workflows/frontend/TemplateLibrary/CodeManagedSource.tsx b/products/workflows/frontend/TemplateLibrary/CodeManagedSource.tsx new file mode 100644 index 000000000000..e1c26c23a594 --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/CodeManagedSource.tsx @@ -0,0 +1,42 @@ +import { Link } from '@posthog/lemon-ui' + +import { cn } from 'lib/utils/css-classes' + +import { TemplateSource } from './codeManagedTemplate' + +function SourcePart({ label, url }: { label: string; url: string | undefined }): JSX.Element { + return url ? ( + + {label} + + ) : ( + {label} + ) +} + +export function CodeManagedSource({ + source, + className, +}: { + source: TemplateSource | null + className?: string +}): JSX.Element | null { + if (!source) { + return null + } + const { path, fileUrl, repository, repositoryUrl, ref, refUrl } = source + + return ( +

+ {path && } + {path && repository && in } + {repository && } + {ref && ( + <> + {path || repository ? ', last uploaded from ' : 'Last uploaded from '} + + + )} +

+ ) +} diff --git a/products/workflows/frontend/TemplateLibrary/CodeManagedTag.tsx b/products/workflows/frontend/TemplateLibrary/CodeManagedTag.tsx new file mode 100644 index 000000000000..0bf9585acc16 --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/CodeManagedTag.tsx @@ -0,0 +1,25 @@ +import { IconCode } from '@posthog/icons' +import { LemonTag, LemonTagProps, Tooltip } from '@posthog/lemon-ui' + +import { isCodeManagedTemplate } from './codeManagedTemplate' +import { MessageTemplate } from './types' + +export function CodeManagedTag({ + template, + size, +}: { + template: Pick | null | undefined + size?: LemonTagProps['size'] +}): JSX.Element | null { + if (!isCodeManagedTemplate(template)) { + return null + } + + return ( + + } data-attr="message-template-managed-by-code"> + Managed by code + + + ) +} diff --git a/products/workflows/frontend/TemplateLibrary/MessageTemplate.tsx b/products/workflows/frontend/TemplateLibrary/MessageTemplate.tsx index 9b2d9147420e..f4bb1f35c756 100644 --- a/products/workflows/frontend/TemplateLibrary/MessageTemplate.tsx +++ b/products/workflows/frontend/TemplateLibrary/MessageTemplate.tsx @@ -3,6 +3,7 @@ import { Form } from 'kea-forms' import { LemonBanner, LemonButton, LemonDivider, LemonTag, Spinner, SpinnerOverlay } from '@posthog/lemon-ui' +import { CopyToClipboardInline } from 'lib/components/CopyToClipboard' import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' import { More } from 'lib/lemon-ui/LemonButton/More' import { LemonTabs } from 'lib/lemon-ui/LemonTabs' @@ -21,6 +22,8 @@ import { WorkflowInvocations } from '../Workflows/WorkflowInvocations' import { workflowLogic } from '../Workflows/workflowLogic' import { WorkflowMetrics } from '../Workflows/WorkflowMetrics' import { WorkflowRevisions } from '../Workflows/WorkflowRevisions' +import { CodeManagedSource } from './CodeManagedSource' +import { CodeManagedTag } from './CodeManagedTag' import { MakeTransactionalPrompt } from './MakeTransactionalPrompt' import { messageTemplateLogic } from './messageTemplateLogic' import { MessageTemplateSceneLogicProps, messageTemplateSceneLogic } from './messageTemplateSceneLogic' @@ -67,6 +70,8 @@ export function MessageTemplate(props: MessageTemplateSceneLogicProps): JSX.Elem isSyncingExternalEdit, transactionalHogFlowId, transactionalTab, + isCodeManaged, + templateSource, } = useValues(logic) const transactionalEmailsEnabled = useFeatureFlag('WORKFLOWS_TRANSACTIONAL_EMAIL_API') const showTransactional = transactionalEmailsEnabled && props.id !== 'new' @@ -152,7 +157,7 @@ export function MessageTemplate(props: MessageTemplateSceneLogicProps): JSX.Elem key: 'api', label: 'API', content: transactionalHogFlowId ? ( - + ) : ( ), @@ -179,11 +184,14 @@ export function MessageTemplate(props: MessageTemplateSceneLogicProps): JSX.Elem onNameChange={(name) => setTemplateValue('name', name)} onDescriptionChange={(description) => setTemplateValue('description', description)} nameSuffix={ - showTransactional && transactionalHogFlowId ? ( - - Transactional - - ) : undefined + <> + {showTransactional && transactionalHogFlowId && ( + + Transactional + + )} + + } actions={ <> @@ -267,6 +275,35 @@ export function MessageTemplate(props: MessageTemplateSceneLogicProps): JSX.Elem } /> + {(originalTemplate.key || templateSource) && ( +
+ {originalTemplate.key && ( + + Key + + {originalTemplate.key} + + + )} + +
+ )} + + {isCodeManaged && ( + + This template is managed by code, so changes made here can't be saved. To change it, edit the + file and upload it again. To edit it here, duplicate it. + + )} + setTemplatePickerOpen(false)} /> diff --git a/products/workflows/frontend/TemplateLibrary/MessageTemplateCard.tsx b/products/workflows/frontend/TemplateLibrary/MessageTemplateCard.tsx index 4e96a05cde49..a86fedb6f2b9 100644 --- a/products/workflows/frontend/TemplateLibrary/MessageTemplateCard.tsx +++ b/products/workflows/frontend/TemplateLibrary/MessageTemplateCard.tsx @@ -4,6 +4,7 @@ import { FallbackCoverImage } from 'lib/components/FallbackCoverImage/FallbackCo import { TZLabel } from 'lib/components/TZLabel' import { ProfilePicture } from 'lib/lemon-ui/ProfilePicture' +import { CodeManagedTag } from './CodeManagedTag' import { MessageTemplate } from './types' export function MessageTemplateCard({ @@ -48,6 +49,7 @@ export function MessageTemplateCard({ Transactional )} + {template.description && (

{template.description}

diff --git a/products/workflows/frontend/TemplateLibrary/TransactionalEmailApiExample.tsx b/products/workflows/frontend/TemplateLibrary/TransactionalEmailApiExample.tsx index ed76413c1fe6..7fe4107a2179 100644 --- a/products/workflows/frontend/TemplateLibrary/TransactionalEmailApiExample.tsx +++ b/products/workflows/frontend/TemplateLibrary/TransactionalEmailApiExample.tsx @@ -7,12 +7,20 @@ import { apiHostOrigin } from 'lib/utils/apiHost' import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' -export function TransactionalEmailApiExample({ templateId }: { templateId: string }): JSX.Element { +export function TransactionalEmailApiExample({ + templateId, + templateKey, +}: { + templateId: string + templateKey?: string | null +}): JSX.Element { const { currentTeamIdStrict } = useValues(teamLogic) - const snippet = `curl -X POST ${apiHostOrigin()}/api/projects/${currentTeamIdStrict}/messaging_templates/${templateId}/send/ \\ + const templateReference = templateKey ? `{ "key": "${templateKey}" }` : `{ "id": "${templateId}" }` + const snippet = `curl -X POST ${apiHostOrigin()}/api/projects/${currentTeamIdStrict}/messaging/send/email/ \\ -H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" \\ -H "Content-Type: application/json" \\ -d '{ + "template": ${templateReference}, "to": "jane@example.com", "identifiers": { "distinct_id": "jane-distinct-id" }, "message_data": { "first_name": "Jane", "reset_link": "https://example.com/reset" } @@ -30,6 +38,18 @@ export function TransactionalEmailApiExample({ templateId }: { templateId: strin project secret API key that has the{' '} hog_flow:write scope. +
  • + {templateKey ? ( + <> + template.key names this template. The key is the same in every project that has + the template. + + ) : ( + <> + template.id names this template in this project. + + )} +
  • message_data fills the template's {'{{ variables.* }}'} placeholders. The template can't read person properties, so pass everything it needs here. diff --git a/products/workflows/frontend/TemplateLibrary/codeManagedTemplate.ts b/products/workflows/frontend/TemplateLibrary/codeManagedTemplate.ts new file mode 100644 index 000000000000..877324be9699 --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/codeManagedTemplate.ts @@ -0,0 +1,40 @@ +import { GitMetadataParser } from 'lib/components/Git/gitMetadataParser' + +import { MessageTemplate } from './types' + +export interface TemplateSource { + path: string | null + fileUrl: string | undefined + repository: string | null + repositoryUrl: string | undefined + ref: string | null + refUrl: string | undefined +} + +export function isCodeManagedTemplate(template: Pick | null | undefined): boolean { + return template?.managed_by === 'code' +} + +export function templateSource(template: MessageTemplate | null | undefined): TemplateSource | null { + if (!isCodeManagedTemplate(template)) { + return null + } + const repository = template?.source_repository || undefined + const path = template?.source_path || undefined + const ref = template?.source_ref || undefined + if (!repository && !path && !ref) { + return null + } + return { + path: path ?? null, + fileUrl: GitMetadataParser.getFileLink(repository, ref, path), + repository: repository ?? null, + repositoryUrl: GitMetadataParser.getRepoLink(repository), + ref: ref ? shortRef(ref) : null, + refUrl: GitMetadataParser.getRefLink(repository, ref), + } +} + +function shortRef(ref: string): string { + return GitMetadataParser.isCommitSha(ref) ? ref.slice(0, 7) : ref +} diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplateLogic.test.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateLogic.test.ts index 789f407f9c22..ef2c43315717 100644 --- a/products/workflows/frontend/TemplateLibrary/messageTemplateLogic.test.ts +++ b/products/workflows/frontend/TemplateLibrary/messageTemplateLogic.test.ts @@ -112,10 +112,17 @@ describe('messageTemplateLogic', () => { toast: 'Failed to save template. Please try again.', withButton: false, }, - ])('$description', async ({ status, toast, withButton }) => { + { + description: 'a code-managed template shows why the server refused the edit', + status: 403, + code: 'immutable', + toast: 'nope', + withButton: false, + }, + ])('$description', async ({ status, code, toast, withButton }) => { useMocks({ patch: { - '/api/environments/:team_id/messaging_templates/:id/': () => [status, { detail: 'nope' }], + '/api/environments/:team_id/messaging_templates/:id/': () => [status, { code, detail: 'nope' }], }, }) @@ -331,6 +338,83 @@ describe('messageTemplateLogic', () => { }) }) + describe('code-managed source', () => { + const CODE_MANAGED = { + id: 'existing-id', + name: 'Welcome', + key: 'welcome', + managed_by: 'code', + source_repository: 'github.com/example/app', + source_path: 'emails/welcome.html', + source_ref: '3f2c1ab9d4e5f60718293a4b5c6d7e8f90a1b2c3', + } + + it.each([ + { + description: 'a template made in PostHog has no source', + served: { id: 'existing-id', name: 'Existing', managed_by: null }, + isCodeManaged: false, + templateSource: null, + }, + { + description: 'a keyed template that PostHog still manages has no source', + served: { ...CODE_MANAGED, managed_by: 'gui' }, + isCodeManaged: false, + templateSource: null, + }, + { + description: 'a known host links the file, the repository and the shortened commit', + served: CODE_MANAGED, + isCodeManaged: true, + templateSource: { + path: 'emails/welcome.html', + fileUrl: + 'https://github.com/example/app/blob/3f2c1ab9d4e5f60718293a4b5c6d7e8f90a1b2c3/emails/welcome.html', + repository: 'github.com/example/app', + repositoryUrl: 'https://github.com/example/app', + ref: '3f2c1ab', + refUrl: 'https://github.com/example/app/commit/3f2c1ab9d4e5f60718293a4b5c6d7e8f90a1b2c3', + }, + }, + { + description: 'a branch links to the branch', + served: { ...CODE_MANAGED, source_ref: 'main' }, + isCodeManaged: true, + templateSource: expect.objectContaining({ + ref: 'main', + refUrl: 'https://github.com/example/app/tree/main', + }), + }, + { + description: 'an unknown host stays plain text', + served: { ...CODE_MANAGED, source_repository: 'git.example.com/team/app' }, + isCodeManaged: true, + templateSource: { + path: 'emails/welcome.html', + fileUrl: undefined, + repository: 'git.example.com/team/app', + repositoryUrl: undefined, + ref: '3f2c1ab', + refUrl: undefined, + }, + }, + { + description: 'an upload without a source shows none', + served: { ...CODE_MANAGED, source_repository: null, source_path: null, source_ref: null }, + isCodeManaged: true, + templateSource: null, + }, + ])('$description', async ({ served, isCodeManaged, templateSource }) => { + useMocks({ get: { '/api/environments/:team_id/messaging_templates/:id/': served } }) + logic = messageTemplateLogic({ id: 'existing-id' }) + logic.mount() + + await expectLogic(logic) + .toDispatchActions(['loadTemplateSuccess']) + .toMatchValues({ isCodeManaged, templateSource }) + }) + }) + describe('make transactional', () => { it('keeps edits made while the conversion runs', async () => { useMocks({ diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplateLogic.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateLogic.ts index d3520d761dd9..e3acc995e036 100644 --- a/products/workflows/frontend/TemplateLibrary/messageTemplateLogic.ts +++ b/products/workflows/frontend/TemplateLibrary/messageTemplateLogic.ts @@ -18,6 +18,7 @@ import { resourceEditedLogic } from 'products/notifications/frontend/resourceEdi import type { EmailTemplate } from '../../../../frontend/src/scenes/hog-functions/email-templater/types' import type { HogFunctionType, ResourceEditedEvent, UserBasicType } from '../../../../frontend/src/types' import { workflowLogic } from '../Workflows/workflowLogic' +import { TemplateSource, isCodeManagedTemplate, templateSource } from './codeManagedTemplate' import { NEW_TEMPLATE } from './constants' import { newTemplateAgentLogic } from './newTemplateAgentLogic' import { MessageTemplate } from './types' @@ -35,6 +36,7 @@ export interface messageTemplateLogicValues { currentTeamIdStrict: number | string // teamLogic deferredExternalEdit: ResourceEditedEvent | null externallyEdited: boolean + isCodeManaged: boolean isMakingTransactional: boolean isSyncingExternalEdit: boolean isTemplateSubmitting: boolean @@ -85,6 +87,7 @@ export interface messageTemplateLogicValues { }, ValidationErrorType > + templateSource: TemplateSource | null transactionalHogFlowId: string | null transactionalTab: TransactionalTemplateTab } @@ -324,6 +327,8 @@ export interface messageTemplateLogicMeta { key: string __keaTypeGenInternalSelectorTypes: { logicProps: (arg: MessageTemplateLogicProps) => MessageTemplateLogicProps + isCodeManaged: (originalTemplate: MessageTemplate) => boolean + templateSource: (originalTemplate: MessageTemplate) => TemplateSource | null transactionalHogFlowId: (originalTemplate: MessageTemplate) => string | null } } @@ -449,6 +454,14 @@ export const messageTemplateLogic = kea([ (s) => [s.originalTemplate], (originalTemplate: MessageTemplate): string | null => originalTemplate.transactional_hog_flow_id ?? null, ], + isCodeManaged: [ + (s) => [s.originalTemplate], + (originalTemplate: MessageTemplate): boolean => isCodeManagedTemplate(originalTemplate), + ], + templateSource: [ + (s) => [s.originalTemplate], + (originalTemplate: MessageTemplate): TemplateSource | null => templateSource(originalTemplate), + ], }), loaders(({ props }) => ({ template: { @@ -579,6 +592,10 @@ export const messageTemplateLogic = kea([ }) return } + if (errorObject?.code === 'immutable') { + lemonToast.error(errorObject.detail) + return + } lemonToast.error('Failed to save template. Please try again.') }, saveTemplateSuccess: async ({ template }) => { diff --git a/products/workflows/frontend/TemplateLibrary/types.ts b/products/workflows/frontend/TemplateLibrary/types.ts index 975f2c20c3d0..3c5fd23a2a37 100644 --- a/products/workflows/frontend/TemplateLibrary/types.ts +++ b/products/workflows/frontend/TemplateLibrary/types.ts @@ -16,4 +16,9 @@ export interface MessageTemplate { updated_at: string | null created_by: UserBasicType | null transactional_hog_flow_id?: MessageTemplateApi['transactional_hog_flow_id'] + key?: MessageTemplateApi['key'] + managed_by?: MessageTemplateApi['managed_by'] + source_repository?: MessageTemplateApi['source_repository'] + source_path?: MessageTemplateApi['source_path'] + source_ref?: MessageTemplateApi['source_ref'] } From 4b119fc69b8da7c519052bb9d511ffbf173309cc Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:57:46 +0000 Subject: [PATCH 08/30] fix(messaging): drop an unused type ignore on the adoption key field Co-Authored-By: Claude Opus 5.5 --- .../messaging/backend/presentation/views/message_templates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/products/messaging/backend/presentation/views/message_templates.py b/products/messaging/backend/presentation/views/message_templates.py index 55d6b40cf5ba..09bf483dc46d 100644 --- a/products/messaging/backend/presentation/views/message_templates.py +++ b/products/messaging/backend/presentation/views/message_templates.py @@ -297,7 +297,7 @@ def validate(self, data: Any) -> Any: class MessageTemplateUpdateSerializer(MessageTemplateSerializer): - key = serializers.CharField( # type: ignore[assignment] + key = serializers.CharField( required=False, max_length=TEMPLATE_KEY_MAX_LENGTH, help_text="Give a template that has no key one, so code can take it over: the next upload under this key " From daf70f92f6abed81fbea11f25cd529667b044ff5 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:41:59 +0000 Subject: [PATCH 09/30] feat(messaging): let an automatic upload refuse a superseded hash An upload with `if_not_superseded` set whose content hash is in the template's superseded list writes nothing and answers `200 superseded`, naming the current hash. Without the option an upload publishes as before, so an explicit upload stays the rollback path. The MCP upload tool describes the option. Co-Authored-By: Claude Opus 5.5 --- .../messaging/backend/facade/contracts.py | 3 +++ .../presentation/views/message_templates.py | 26 ++++++++++++++----- .../backend/services/template_uploads.py | 11 +++++++- .../backend/tests/api/test_template_upload.py | 26 +++++++++++++++++++ .../frontend/generated/api.schemas.ts | 13 +++++++--- products/messaging/frontend/generated/api.ts | 2 +- .../messaging/frontend/generated/api.zod.ts | 10 ++++++- products/workflows/mcp/email_templates.yaml | 4 ++- .../schema/generated-tool-definitions.json | 2 +- services/mcp/schema/tool-definitions-all.json | 2 +- services/mcp/src/api/generated.ts | 13 +++++++--- .../mcp/src/generated/email_templates/api.ts | 10 ++++++- .../src/tools/generated/email_templates.ts | 3 +++ .../workflows-upload-email-template.json | 5 ++++ 14 files changed, 109 insertions(+), 21 deletions(-) diff --git a/products/messaging/backend/facade/contracts.py b/products/messaging/backend/facade/contracts.py index 56445578e77c..1ec279fdb074 100644 --- a/products/messaging/backend/facade/contracts.py +++ b/products/messaging/backend/facade/contracts.py @@ -90,6 +90,7 @@ class TemplateUploadResult(LabeledStrEnum): CREATED = "created", "Created" UPDATED = "updated", "Updated" UNCHANGED = "unchanged", "Unchanged" + SUPERSEDED = "superseded", "Superseded" class ContentHashState(LabeledStrEnum): @@ -163,3 +164,5 @@ class TemplateUpload: # The editor's design for the html, so the template opens in the visual editor. design: dict[str, Any] source: TemplateSource | None + # An automatic upload sets it, so it never publishes content that newer content already replaced. + if_not_superseded: bool diff --git a/products/messaging/backend/presentation/views/message_templates.py b/products/messaging/backend/presentation/views/message_templates.py index 09bf483dc46d..a1b676e1c022 100644 --- a/products/messaging/backend/presentation/views/message_templates.py +++ b/products/messaging/backend/presentation/views/message_templates.py @@ -418,6 +418,13 @@ class UploadTemplateSerializer(serializers.Serializer): help_text="Where the template file lives, so people in PostHog can find it. Leaving it out, or a part of " "it, keeps what is stored.", ) + if_not_superseded = serializers.BooleanField( + required=False, + default=False, + help_text="Set it on uploads a program makes on its own, such as after a send answered `template_outdated`. " + "When newer content already replaced this content, the upload changes nothing and answers `superseded`. " + "Leave it out to publish the content in any case, such as to roll back to earlier content.", + ) class UploadTemplateResponseSerializer(serializers.Serializer): @@ -426,13 +433,17 @@ class UploadTemplateResponseSerializer(serializers.Serializer): result = serializers.ChoiceField( choices=TemplateUploadResult.choices, help_text="`created`: the upload made a new template. `updated`: it published the uploaded content. " - "`unchanged`: the template already had this content, so nothing was published.", + "`unchanged`: the template already had this content, so nothing was published. `superseded`: the upload " + "set `if_not_superseded` and newer content already replaced this content, so nothing was published.", ) content_hash = serializers.CharField( help_text="Hash of the published content, as `v1:` and 64 hex characters. Send it with an email to check " - "PostHog holds the content you hold." + "PostHog holds the content you hold. After `superseded` it is the hash of the newer content." + ) + managed_by = serializers.ChoiceField( + choices=TemplateManagedBy.choices, + help_text="`code` after every upload except `superseded`, which changes nothing.", ) - managed_by = serializers.ChoiceField(choices=TemplateManagedBy.choices, help_text="Always `code` after an upload.") source = TemplateSourceSerializer( # type: ignore[assignment] # field named `source` shadows DRF Field.source help_text="Where the template file lives, as stored after the upload." ) @@ -935,7 +946,8 @@ def send(self, request: Any, *args: Any, **kwargs: Any) -> Response: request=UploadTemplateSerializer, responses={ 200: OpenApiResponse( - response=UploadTemplateResponseSerializer, description="The template was updated, or is unchanged" + response=UploadTemplateResponseSerializer, + description="The template was updated, is unchanged, or newer content superseded the upload", ), 201: OpenApiResponse(response=UploadTemplateResponseSerializer, description="The template was created"), 400: OpenApiResponse( @@ -951,7 +963,8 @@ def send(self, request: Any, *args: Any, **kwargs: Any) -> Response: "Create or update the email template with this key from HTML, for templates kept as files in a " "repository. The first upload creates the template and makes it transactional with the project's " "default verified sender. A later upload publishes changed content at once, and answers `unchanged` " - "when the content is the same. An upload keeps the template's sender and never turns a paused " + "when the content is the same. With `if_not_superseded`, an upload of content that newer content " + "already replaced changes nothing and answers `superseded`. An upload keeps the template's sender and never turns a paused " "template back on." ), parameters=[TEMPLATE_KEY_PARAMETER], @@ -982,7 +995,7 @@ def upload(self, request: Request, key: str, *args: Any, **kwargs: Any) -> Respo raise TransactionalEmailRejected(e) from None self._report_template_upload(request, {"result": uploaded.result, "template_id": str(uploaded.template.id)}) - if uploaded.result != TemplateUploadResult.UNCHANGED: + if uploaded.result in (TemplateUploadResult.CREATED, TemplateUploadResult.UPDATED): self._emit_resource_edited(uploaded.template) created = uploaded.result == TemplateUploadResult.CREATED return Response( @@ -1005,6 +1018,7 @@ def _template_upload(key: str, data: dict[str, Any]) -> TemplateUpload: source=TemplateSource(repository=source.get("repository"), path=source.get("path"), ref=source.get("ref")) if source else None, + if_not_superseded=data["if_not_superseded"], ) @staticmethod diff --git a/products/messaging/backend/services/template_uploads.py b/products/messaging/backend/services/template_uploads.py index 8fb5a9dda681..5cead47ca8a1 100644 --- a/products/messaging/backend/services/template_uploads.py +++ b/products/messaging/backend/services/template_uploads.py @@ -8,6 +8,7 @@ from posthog.dataclasses import frozen from products.messaging.backend.facade.contracts import ( + ContentHashState, SenderUnavailable, TemplateManagedBy, TemplateSource, @@ -15,7 +16,7 @@ TemplateUploadResult, ) from products.messaging.backend.models import MessageTemplate -from products.messaging.backend.services.content_hash import HASHED_FIELDS +from products.messaging.backend.services.content_hash import HASHED_FIELDS, email_content_hash, sent_hash_state from products.messaging.backend.services.message_templates import record_content_hash from products.messaging.backend.services.template_saves import WorkflowEditorCheck, save_locked_template from products.messaging.backend.services.transactional_email import is_transactional, make_transactional @@ -71,6 +72,8 @@ def _upload( if template is None: require_workflow_create() return Uploaded(result=TemplateUploadResult.CREATED, template=_create(team_id, upload, user_id=user_id)) + if upload.if_not_superseded and _is_superseded(template, upload): + return Uploaded(result=TemplateUploadResult.SUPERSEDED, template=template) if _is_unchanged(template, upload): _save_source(template, upload.source) return Uploaded(result=TemplateUploadResult.UNCHANGED, template=template) @@ -140,6 +143,12 @@ def _is_unchanged(template: MessageTemplate, upload: TemplateUpload) -> bool: ) +def _is_superseded(template: MessageTemplate, upload: TemplateUpload) -> bool: + uploaded_hash = email_content_hash(_email(upload)) + state = sent_hash_state(uploaded_hash, template.content_hash, template.superseded_content_hashes or []) + return state == ContentHashState.SUPERSEDED + + def _write_source(template: MessageTemplate, source: TemplateSource | None) -> list[str]: changed: list[str] = [] for part in SOURCE_PARTS: diff --git a/products/messaging/backend/tests/api/test_template_upload.py b/products/messaging/backend/tests/api/test_template_upload.py index 6ae9882c5229..b600b31b6a64 100644 --- a/products/messaging/backend/tests/api/test_template_upload.py +++ b/products/messaging/backend/tests/api/test_template_upload.py @@ -272,6 +272,32 @@ def test_a_publish_moves_the_replaced_hash_to_the_superseded_list(self, _post, _ assert (rolled_back["result"], rolled_back["content_hash"]) == ("updated", WELCOME_HASH) assert self._superseded_hashes(first["id"]) == [edited_hash] + @parameterized.expand( + [ + ("superseded_content", WELCOME, "superseded", "Welcome aboard, {{ variables.first_name }}", "9e8d7c6", 2), + ("new_content", GREETING, "updated", GREETING["subject"], "3f2c1ab", 3), + ] + ) + def test_an_upload_if_not_superseded_never_puts_replaced_content_back( + self, _post, _flag, _capture, _name, body, expected_result, expected_subject, expected_ref, expected_version + ): + self._upload(WELCOME) + newer = self._upload( + {**WELCOME, "subject": "Welcome aboard, {{ variables.first_name }}", "source": {"ref": "9e8d7c6"}} + ) + + response = self.psak.put( + self.url, {**body, "source": {"ref": "3f2c1ab"}, "if_not_superseded": True}, format="json" + ) + + assert response.status_code == status.HTTP_200_OK, response.json() + uploaded = response.json() + assert (uploaded["result"], uploaded["id"]) == (expected_result, newer["id"]) + template = self._template(newer["id"]) + assert uploaded["content_hash"] == template["content_hash"] + assert (template["content"]["email"]["subject"], template["source_ref"]) == (expected_subject, expected_ref) + assert self._workflow(newer["transactional_hog_flow_id"])["version"] == expected_version + @parameterized.expand( [ ("current", None, status.HTTP_202_ACCEPTED, []), diff --git a/products/messaging/frontend/generated/api.schemas.ts b/products/messaging/frontend/generated/api.schemas.ts index d503cd83c1dc..ebca9bcfe216 100644 --- a/products/messaging/frontend/generated/api.schemas.ts +++ b/products/messaging/frontend/generated/api.schemas.ts @@ -709,12 +709,15 @@ export interface UploadTemplateApi { preheader?: string /** Where the template file lives, so people in PostHog can find it. Leaving it out, or a part of it, keeps what is stored. */ source?: TemplateSourceApi + /** Set it on uploads a program makes on its own, such as after a send answered `template_outdated`. When newer content already replaced this content, the upload changes nothing and answers `superseded`. Leave it out to publish the content in any case, such as to roll back to earlier content. */ + if_not_superseded?: boolean } /** * * `created` - Created * * `updated` - Updated * * `unchanged` - Unchanged + * * `superseded` - Superseded */ export type TemplateUploadResultEnumApi = (typeof TemplateUploadResultEnumApi)[keyof typeof TemplateUploadResultEnumApi] @@ -722,6 +725,7 @@ export const TemplateUploadResultEnumApi = { Created: 'created', Updated: 'updated', Unchanged: 'unchanged', + Superseded: 'superseded', } as const export interface UploadTemplateResponseApi { @@ -729,15 +733,16 @@ export interface UploadTemplateResponseApi { id: string /** Key of the template. */ key: string - /** `created`: the upload made a new template. `updated`: it published the uploaded content. `unchanged`: the template already had this content, so nothing was published. + /** `created`: the upload made a new template. `updated`: it published the uploaded content. `unchanged`: the template already had this content, so nothing was published. `superseded`: the upload set `if_not_superseded` and newer content already replaced this content, so nothing was published. * * * `created` - Created * * `updated` - Updated - * * `unchanged` - Unchanged */ + * * `unchanged` - Unchanged + * * `superseded` - Superseded */ result: TemplateUploadResultEnumApi - /** Hash of the published content, as `v1:` and 64 hex characters. Send it with an email to check PostHog holds the content you hold. */ + /** Hash of the published content, as `v1:` and 64 hex characters. Send it with an email to check PostHog holds the content you hold. After `superseded` it is the hash of the newer content. */ content_hash: string - /** Always `code` after an upload. + /** `code` after every upload except `superseded`, which changes nothing. * * * `gui` - GUI * * `code` - Code */ diff --git a/products/messaging/frontend/generated/api.ts b/products/messaging/frontend/generated/api.ts index 02919f878b67..faa9b7347141 100644 --- a/products/messaging/frontend/generated/api.ts +++ b/products/messaging/frontend/generated/api.ts @@ -817,7 +817,7 @@ export const getMessagingTemplatesByKeyUpdateUrl = (projectId: string, key: stri } /** - * Create or update the email template with this key from HTML, for templates kept as files in a repository. The first upload creates the template and makes it transactional with the project's default verified sender. A later upload publishes changed content at once, and answers `unchanged` when the content is the same. An upload keeps the template's sender and never turns a paused template back on. + * Create or update the email template with this key from HTML, for templates kept as files in a repository. The first upload creates the template and makes it transactional with the project's default verified sender. A later upload publishes changed content at once, and answers `unchanged` when the content is the same. With `if_not_superseded`, an upload of content that newer content already replaced changes nothing and answers `superseded`. An upload keeps the template's sender and never turns a paused template back on. * @summary Upload a template by key */ export const messagingTemplatesByKeyUpdate = async ( diff --git a/products/messaging/frontend/generated/api.zod.ts b/products/messaging/frontend/generated/api.zod.ts index ad7e10549617..65592c114c36 100644 --- a/products/messaging/frontend/generated/api.zod.ts +++ b/products/messaging/frontend/generated/api.zod.ts @@ -704,7 +704,7 @@ export const MessagingTemplatesSendCreateBody = /* @__PURE__ */ zod.object({ }) /** - * Create or update the email template with this key from HTML, for templates kept as files in a repository. The first upload creates the template and makes it transactional with the project's default verified sender. A later upload publishes changed content at once, and answers `unchanged` when the content is the same. An upload keeps the template's sender and never turns a paused template back on. + * Create or update the email template with this key from HTML, for templates kept as files in a repository. The first upload creates the template and makes it transactional with the project's default verified sender. A later upload publishes changed content at once, and answers `unchanged` when the content is the same. With `if_not_superseded`, an upload of content that newer content already replaced changes nothing and answers `superseded`. An upload keeps the template's sender and never turns a paused template back on. * @summary Upload a template by key */ export const messagingTemplatesByKeyUpdateBodyNameMax = 400 @@ -715,6 +715,8 @@ export const messagingTemplatesByKeyUpdateBodySourceOnePathMax = 400 export const messagingTemplatesByKeyUpdateBodySourceOneRefMax = 400 +export const messagingTemplatesByKeyUpdateBodyIfNotSupersededDefault = false + export const MessagingTemplatesByKeyUpdateBody = /* @__PURE__ */ zod.object({ name: zod .string() @@ -754,4 +756,10 @@ export const MessagingTemplatesByKeyUpdateBody = /* @__PURE__ */ zod.object({ .describe( 'Where the template file lives, so people in PostHog can find it. Leaving it out, or a part of it, keeps what is stored.' ), + if_not_superseded: zod + .boolean() + .default(messagingTemplatesByKeyUpdateBodyIfNotSupersededDefault) + .describe( + 'Set it on uploads a program makes on its own, such as after a send answered `template_outdated`. When newer content already replaced this content, the upload changes nothing and answers `superseded`. Leave it out to publish the content in any case, such as to roll back to earlier content.' + ), }) diff --git a/products/workflows/mcp/email_templates.yaml b/products/workflows/mcp/email_templates.yaml index 59b32e462136..3c461ab4671a 100644 --- a/products/workflows/mcp/email_templates.yaml +++ b/products/workflows/mcp/email_templates.yaml @@ -139,7 +139,9 @@ tools: can't read person properties. The first upload creates the template and makes it sendable over the transactional email API with the project's default verified sender, so verify a sender first. Later uploads publish changed content at once and answer unchanged when nothing changed. Uploading replaces edits made - in PostHog. A plain 404 means the transactional email API is not enabled for this project: stop and tell + in PostHog. When you upload only because a send answered template_outdated, set if_not_superseded: an + answer of superseded means newer content is already published and nothing changed. Leave it out to publish + this content even when newer content replaced it, such as to roll back. A plain 404 means the transactional email API is not enabled for this project: stop and tell the user. workflows-send-email: operation: messaging_send_email_create diff --git a/services/mcp/schema/generated-tool-definitions.json b/services/mcp/schema/generated-tool-definitions.json index db97384c323b..7789d72efc25 100644 --- a/services/mcp/schema/generated-tool-definitions.json +++ b/services/mcp/schema/generated-tool-definitions.json @@ -15344,7 +15344,7 @@ } }, "workflows-upload-email-template": { - "description": "Upload an email template kept as an HTML file in a repository, by a key you choose (such as welcome or password-reset). Use this for templates that live in code; use workflows-create-email-template for templates designed in PostHog. Send name, subject, html, and optionally text, preheader and source (repository, path, ref of the file). Use {{ variables.* }} for every value the email needs; templates can't read person properties. The first upload creates the template and makes it sendable over the transactional email API with the project's default verified sender, so verify a sender first. Later uploads publish changed content at once and answer unchanged when nothing changed. Uploading replaces edits made in PostHog. A plain 404 means the transactional email API is not enabled for this project: stop and tell the user.", + "description": "Upload an email template kept as an HTML file in a repository, by a key you choose (such as welcome or password-reset). Use this for templates that live in code; use workflows-create-email-template for templates designed in PostHog. Send name, subject, html, and optionally text, preheader and source (repository, path, ref of the file). Use {{ variables.* }} for every value the email needs; templates can't read person properties. The first upload creates the template and makes it sendable over the transactional email API with the project's default verified sender, so verify a sender first. Later uploads publish changed content at once and answer unchanged when nothing changed. Uploading replaces edits made in PostHog. When you upload only because a send answered template_outdated, set if_not_superseded: an answer of superseded means newer content is already published and nothing changed. Leave it out to publish this content even when newer content replaced it, such as to roll back. A plain 404 means the transactional email API is not enabled for this project: stop and tell the user.", "category": "Workflows", "feature": "workflows", "summary": "Upload email template by key", diff --git a/services/mcp/schema/tool-definitions-all.json b/services/mcp/schema/tool-definitions-all.json index a6ae6c27bac0..ba5ed694a123 100644 --- a/services/mcp/schema/tool-definitions-all.json +++ b/services/mcp/schema/tool-definitions-all.json @@ -16089,7 +16089,7 @@ } }, "workflows-upload-email-template": { - "description": "Upload an email template kept as an HTML file in a repository, by a key you choose (such as welcome or password-reset). Use this for templates that live in code; use workflows-create-email-template for templates designed in PostHog. Send name, subject, html, and optionally text, preheader and source (repository, path, ref of the file). Use {{ variables.* }} for every value the email needs; templates can't read person properties. The first upload creates the template and makes it sendable over the transactional email API with the project's default verified sender, so verify a sender first. Later uploads publish changed content at once and answer unchanged when nothing changed. Uploading replaces edits made in PostHog. A plain 404 means the transactional email API is not enabled for this project: stop and tell the user.", + "description": "Upload an email template kept as an HTML file in a repository, by a key you choose (such as welcome or password-reset). Use this for templates that live in code; use workflows-create-email-template for templates designed in PostHog. Send name, subject, html, and optionally text, preheader and source (repository, path, ref of the file). Use {{ variables.* }} for every value the email needs; templates can't read person properties. The first upload creates the template and makes it sendable over the transactional email API with the project's default verified sender, so verify a sender first. Later uploads publish changed content at once and answer unchanged when nothing changed. Uploading replaces edits made in PostHog. When you upload only because a send answered template_outdated, set if_not_superseded: an answer of superseded means newer content is already published and nothing changed. Leave it out to publish this content even when newer content replaced it, such as to roll back. A plain 404 means the transactional email API is not enabled for this project: stop and tell the user.", "category": "Workflows", "feature": "workflows", "summary": "Upload email template by key", diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 0483c8ba60cf..eea1abf8dd06 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -112291,6 +112291,7 @@ export namespace Schemas { * * `created` - Created * * `updated` - Updated * * `unchanged` - Unchanged + * * `superseded` - Superseded */ export type TemplateUploadResultEnum = typeof TemplateUploadResultEnum[keyof typeof TemplateUploadResultEnum]; @@ -112299,6 +112300,7 @@ export namespace Schemas { Created: 'created', Updated: 'updated', Unchanged: 'unchanged', + Superseded: 'superseded', } as const; /** @@ -113269,6 +113271,8 @@ export namespace Schemas { preheader?: string; /** Where the template file lives, so people in PostHog can find it. Leaving it out, or a part of it, keeps what is stored. */ source?: TemplateSource; + /** Set it on uploads a program makes on its own, such as after a send answered `template_outdated`. When newer content already replaced this content, the upload changes nothing and answers `superseded`. Leave it out to publish the content in any case, such as to roll back to earlier content. */ + if_not_superseded?: boolean; } export interface UploadTemplateResponse { @@ -113276,15 +113280,16 @@ export namespace Schemas { id: string; /** Key of the template. */ key: string; - /** `created`: the upload made a new template. `updated`: it published the uploaded content. `unchanged`: the template already had this content, so nothing was published. + /** `created`: the upload made a new template. `updated`: it published the uploaded content. `unchanged`: the template already had this content, so nothing was published. `superseded`: the upload set `if_not_superseded` and newer content already replaced this content, so nothing was published. * * * `created` - Created * * `updated` - Updated - * * `unchanged` - Unchanged */ + * * `unchanged` - Unchanged + * * `superseded` - Superseded */ result: TemplateUploadResultEnum; - /** Hash of the published content, as `v1:` and 64 hex characters. Send it with an email to check PostHog holds the content you hold. */ + /** Hash of the published content, as `v1:` and 64 hex characters. Send it with an email to check PostHog holds the content you hold. After `superseded` it is the hash of the newer content. */ content_hash: string; - /** Always `code` after an upload. + /** `code` after every upload except `superseded`, which changes nothing. * * * `gui` - GUI * * `code` - Code */ diff --git a/services/mcp/src/generated/email_templates/api.ts b/services/mcp/src/generated/email_templates/api.ts index b7629bad241a..330ad502f379 100644 --- a/services/mcp/src/generated/email_templates/api.ts +++ b/services/mcp/src/generated/email_templates/api.ts @@ -374,7 +374,7 @@ export const MessagingTemplatesDesignPartialUpdateBody = () => zod.object({ }) /** - * Create or update the email template with this key from HTML, for templates kept as files in a repository. The first upload creates the template and makes it transactional with the project's default verified sender. A later upload publishes changed content at once, and answers `unchanged` when the content is the same. An upload keeps the template's sender and never turns a paused template back on. + * Create or update the email template with this key from HTML, for templates kept as files in a repository. The first upload creates the template and makes it transactional with the project's default verified sender. A later upload publishes changed content at once, and answers `unchanged` when the content is the same. With `if_not_superseded`, an upload of content that newer content already replaced changes nothing and answers `superseded`. An upload keeps the template's sender and never turns a paused template back on. * @summary Upload a template by key */ export const MessagingTemplatesByKeyUpdateParams = () => zod.object({ @@ -398,6 +398,8 @@ export const messagingTemplatesByKeyUpdateBodySourceOnePathMax = 400 export const messagingTemplatesByKeyUpdateBodySourceOneRefMax = 400 +export const messagingTemplatesByKeyUpdateBodyIfNotSupersededDefault = false + export const MessagingTemplatesByKeyUpdateBody = () => zod.object({ name: zod .string() @@ -437,4 +439,10 @@ export const MessagingTemplatesByKeyUpdateBody = () => zod.object({ .describe( 'Where the template file lives, so people in PostHog can find it. Leaving it out, or a part of it, keeps what is stored.' ), + if_not_superseded: zod + .boolean() + .default(messagingTemplatesByKeyUpdateBodyIfNotSupersededDefault) + .describe( + 'Set it on uploads a program makes on its own, such as after a send answered `template_outdated`. When newer content already replaced this content, the upload changes nothing and answers `superseded`. Leave it out to publish the content in any case, such as to roll back to earlier content.' + ), }) diff --git a/services/mcp/src/tools/generated/email_templates.ts b/services/mcp/src/tools/generated/email_templates.ts index bb99c9bc4989..ab3d0f708d91 100644 --- a/services/mcp/src/tools/generated/email_templates.ts +++ b/services/mcp/src/tools/generated/email_templates.ts @@ -192,6 +192,9 @@ const workflowsUploadEmailTemplate = (): ToolBase< if (params.source !== undefined) { body['source'] = params.source } + if (params.if_not_superseded !== undefined) { + body['if_not_superseded'] = params.if_not_superseded + } const result = await context.api.request({ method: 'PUT', path: `/api/projects/${encodeURIComponent(String(projectId))}/messaging_templates/by_key/${encodeURIComponent(String(params.key))}/`, diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-upload-email-template.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-upload-email-template.json index 23b96a96e631..207c33f8d92f 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-upload-email-template.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-upload-email-template.json @@ -5,6 +5,11 @@ "description": "Email body as HTML, stored as sent. Supports Liquid, such as `{{ variables.first_name }}`.", "type": "string" }, + "if_not_superseded": { + "default": false, + "description": "Set it on uploads a program makes on its own, such as after a send answered `template_outdated`. When newer content already replaced this content, the upload changes nothing and answers `superseded`. Leave it out to publish the content in any case, such as to roll back to earlier content.", + "type": "boolean" + }, "key": { "description": "Key of the template, chosen by you. Letters, digits, hyphens (-) and underscores (_), at most 400 characters. The same key names the template in every project.", "type": "string" From 33fbbe2bc1d863b09d1fc85ba5317182f7ddf06b Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:16:04 +0000 Subject: [PATCH 10/30] feat(workflows): show code-managed email templates read-only A code-managed template page now shows a read-only preview (subject, preheader, HTML in a sandboxed frame, plain text) in place of the email editor, with no Save, Discard or Save as new template. The form of a code-managed template never diverges from the server copy and its submit sends nothing, so neither a save nor any draft autosave can write it. "Stop managing in code" in the page menu asks for confirmation, sends the release PATCH and reloads the page as an editable template. Delete is disabled on the page and the library card until the template is released. Duplicate on both surfaces shares one copy payload that carries only the name, description and content. Co-Authored-By: Claude Opus 5.5 --- .../TemplateLibrary/EmailHtmlFrame.tsx | 12 ++ .../TemplateLibrary/MessageTemplate.tsx | 97 +++++----------- .../TemplateLibrary/MessageTemplateCard.tsx | 8 +- .../TemplateLibrary/MessageTemplateMenu.tsx | 73 ++++++++++++ .../TemplateLibrary/MessageTemplatesTable.tsx | 4 + .../ReadOnlyTemplatePreview.stories.tsx | 23 ++++ .../ReadOnlyTemplatePreview.tsx | 41 +++++++ .../messageTemplateLogic.test.ts | 92 +++++++++++++-- .../TemplateLibrary/messageTemplateLogic.ts | 106 ++++++++++++++++-- .../TemplateLibrary/messageTemplatesLogic.ts | 7 +- .../TemplateLibrary/releaseTemplateDialog.ts | 17 +++ .../frontend/TemplateLibrary/templateCopy.ts | 9 ++ 12 files changed, 391 insertions(+), 98 deletions(-) create mode 100644 products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/MessageTemplateMenu.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/ReadOnlyTemplatePreview.stories.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/ReadOnlyTemplatePreview.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/releaseTemplateDialog.ts create mode 100644 products/workflows/frontend/TemplateLibrary/templateCopy.ts diff --git a/products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.tsx b/products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.tsx new file mode 100644 index 000000000000..28876fc57dc0 --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.tsx @@ -0,0 +1,12 @@ +import { cn } from 'lib/utils/css-classes' + +export function EmailHtmlFrame({ html, className }: { html: string; className?: string }): JSX.Element { + return ( +