From a7cd02cdc1530563d63eafa6c01cdd4cd7fc22d6 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:05:12 +0000 Subject: [PATCH 01/42] feat(messaging): add code-managed fields to message templates A template uploaded from a repository needs to record who owns its content, which file it came from, and a hash of what it publishes. This adds those columns (all nullable) and the content hash function. No route reads or writes them yet. Co-Authored-By: Claude Opus 5.5 --- .../messaging/backend/facade/contracts.py | 5 ++ ...015_messagetemplate_code_managed_fields.py | 72 +++++++++++++++++++ .../backend/migrations/max_migration.txt | 2 +- .../backend/models/message_template.py | 30 ++++++++ .../backend/services/content_hash.py | 19 +++++ .../backend/tests/test_content_hash.py | 33 +++++++++ 6 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 products/messaging/backend/migrations/0015_messagetemplate_code_managed_fields.py create mode 100644 products/messaging/backend/services/content_hash.py create mode 100644 products/messaging/backend/tests/test_content_hash.py diff --git a/products/messaging/backend/facade/contracts.py b/products/messaging/backend/facade/contracts.py index ca909adcb536..244993502a90 100644 --- a/products/messaging/backend/facade/contracts.py +++ b/products/messaging/backend/facade/contracts.py @@ -193,3 +193,8 @@ class EmailStatus: status_at: datetime # Why a send failed, or the bounce type of a bounced email. reason: str | None = None + + +class TemplateManagedBy(LabeledStrEnum): + GUI = "gui", "GUI" + CODE = "code", "Code" diff --git a/products/messaging/backend/migrations/0015_messagetemplate_code_managed_fields.py b/products/messaging/backend/migrations/0015_messagetemplate_code_managed_fields.py new file mode 100644 index 000000000000..f671e9c6eb5d --- /dev/null +++ b/products/messaging/backend/migrations/0015_messagetemplate_code_managed_fields.py @@ -0,0 +1,72 @@ +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("messaging", "0014_transactional_email_key"), + ] + + 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="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/max_migration.txt b/products/messaging/backend/migrations/max_migration.txt index 9550c176e213..87dded8864f4 100644 --- a/products/messaging/backend/migrations/max_migration.txt +++ b/products/messaging/backend/migrations/max_migration.txt @@ -1 +1 @@ -0014_transactional_email_key +0015_messagetemplate_code_managed_fields diff --git a/products/messaging/backend/models/message_template.py b/products/messaging/backend/models/message_template.py index 0670af8610d2..91119380e3d7 100644 --- a/products/messaging/backend/models/message_template.py +++ b/products/messaging/backend/models/message_template.py @@ -1,8 +1,11 @@ +from django.contrib.postgres.fields import ArrayField from django.db import models from posthog.models.tagged_items_relation import Taggable from posthog.models.utils import UUIDTModel +from products.messaging.backend.facade.contracts import TemplateManagedBy + KEY_CONSTRAINT = "unique_message_template_key_per_team" @@ -37,6 +40,33 @@ class MessageTemplate(Taggable, UUIDTModel): 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=TemplateManagedBy.choices, + 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" diff --git a/products/messaging/backend/services/content_hash.py b/products/messaging/backend/services/content_hash.py new file mode 100644 index 000000000000..6958ad9c0bb1 --- /dev/null +++ b/products/messaging/backend/services/content_hash.py @@ -0,0 +1,19 @@ +import hashlib +from typing import Any + +HASH_PREFIX = "v1:" +HASHED_FIELDS = ("subject", "preheader", "html", "text") + +_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") 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..6b0c90ed2368 --- /dev/null +++ b/products/messaging/backend/tests/test_content_hash.py @@ -0,0 +1,33 @@ +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", + }, + "v1:8112876ee1b2dd7327f571b04b546ed057bfff5a904d9f0ae507b6a449ab6453", + ), + ] + ) + def test_matches_the_spec_test_vectors(self, _name: str, email: dict, expected: str) -> None: + assert email_content_hash(email) == expected From 5ce7197dbb45f5e8049c9a7bf5ff8594c38283bb Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:09:09 +0000 Subject: [PATCH 02/42] feat(messaging): upload email templates from code by key `PUT /api/projects/:id/messaging_templates/by_key/:key/` creates or updates the template with that key from HTML kept in a repository. The first upload makes it transactional with the project's default verified sender; later uploads publish changed content at once, answer `unchanged` for the same content and, with `if_not_superseded`, `superseded` for content a newer upload replaced. Code sends under a key move to the template's workflow once an upload claims the key. The upload sits behind `workflows-email-from-code`, keeps the PSAK bypass and workflow access checks of the send route, gets its own rate limits, and reports a usage event. The MCP tool `workflows-upload-email-template` calls it. Co-Authored-By: Claude Opus 5.5 --- .../messaging/backend/facade/contracts.py | 38 ++ .../messaging/backend/facade/templates.py | 108 ++++- .../backend/facade/transactional_email.py | 4 - .../presentation/views/message_templates.py | 424 ++++++++++++++---- .../presentation/views/template_refusals.py | 83 ++++ .../backend/services/content_hash.py | 19 + .../backend/services/message_templates.py | 55 ++- .../backend/services/template_saves.py | 71 +++ .../backend/services/template_uploads.py | 187 ++++++++ .../backend/services/transactional_email.py | 45 +- .../backend/tests/api/test_template_upload.py | 376 ++++++++++++++++ .../tests/api/test_transactional_email.py | 52 ++- .../frontend/generated/api.schemas.ts | 177 +++++++- products/messaging/frontend/generated/api.ts | 24 + .../messaging/frontend/generated/api.zod.ts | 62 ++- products/workflows/mcp/email_templates.yaml | 24 + .../schema/generated-tool-definitions.json | 16 + services/mcp/schema/tool-definitions-all.json | 16 + services/mcp/src/api/generated.ts | 179 +++++++- .../mcp/src/generated/email_templates/api.ts | 77 +++- .../src/tools/generated/email_templates.ts | 48 ++ .../tool-schemas/workflows-send-email.json | 2 +- .../workflows-upload-email-template.json | 79 ++++ 23 files changed, 2003 insertions(+), 163 deletions(-) create mode 100644 products/messaging/backend/presentation/views/template_refusals.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 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 244993502a90..7744ebe3b122 100644 --- a/products/messaging/backend/facade/contracts.py +++ b/products/messaging/backend/facade/contracts.py @@ -195,6 +195,44 @@ class EmailStatus: reason: str | None = None +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" + SUPERSEDED = "superseded", "Superseded" + + +class ContentHashState(LabeledStrEnum): + NONE = "none", "None" + CURRENT = "current", "Current" + SUPERSEDED = "superseded", "Superseded" + OUTDATED = "outdated", "Outdated" + + +@frozen +class TemplateSource: + repository: str | None + path: str | None + ref: str | None + + +@frozen +class TemplateUpload: + key: str + name: str + subject: str + html: str + text: str + preheader: str + design: dict[str, Any] + source: TemplateSource | None + if_not_superseded: bool diff --git a/products/messaging/backend/facade/templates.py b/products/messaging/backend/facade/templates.py index 24033eb2ec79..01773546093c 100644 --- a/products/messaging/backend/facade/templates.py +++ b/products/messaging/backend/facade/templates.py @@ -10,8 +10,13 @@ from posthog.api.tagged_item import current_tag_names 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 @@ -38,10 +43,22 @@ class MessageTemplateRow: transactional_hog_flow_id: UUID | None draft_content: dict[str, Any] | None sensitive: bool + key: str | None + managed_by: TemplateManagedBy | None + source_repository: str | None + source_path: str | None + source_ref: str | None + content_hash: str | None # Tag names, sorted. Keeps its default last, so every field without one stays above it. tags: tuple[str, ...] = () +@frozen +class UploadedTemplate: + result: TemplateUploadResult + template: MessageTemplateRow + + def _to_contract(row: MessageTemplate) -> MessageTemplateRow: return MessageTemplateRow( id=row.id, @@ -57,6 +74,12 @@ def _to_contract(row: MessageTemplate) -> MessageTemplateRow: transactional_hog_flow_id=row.transactional_hog_flow_id, draft_content=row.draft_content, sensitive=row.sensitive, + 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, tags=tuple(sorted(current_tag_names(row))), ) @@ -90,21 +113,80 @@ 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_tags(team_id: int, template_id: UUID, tags: list[str]) -> MessageTemplateRow: + return _to_contract(templates_service.save_template_tags(team_id, template_id, tags)) -def save_template_draft(team_id: int, template_id: UUID, draft: dict[str, Any] | None) -> MessageTemplateRow: - """Replace only the template's unpublished draft. `None` discards it.""" - return _to_contract(templates_service.save_draft(team_id, template_id, draft)) +def save_template_draft( + team_id: int, + template_id: UUID, + draft: dict[str, Any] | None, + *, + require_workflow_editor: Callable[[UUID], None], +) -> MessageTemplateRow: + return _to_contract( + template_saves.save_locked_draft(team_id, template_id, draft, 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_fields( + team_id: int, + template_id: UUID, + fields: 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. + fields = dict(fields) + tags = fields.pop("tags", None) + + def write(template: MessageTemplate) -> None: + templates_service.apply_fields(template, fields) + templates_service.save_tags(template, tags) + + return _to_contract( + template_saves.save_template( + team_id, + template_id, + write, + user_id=user_id, + require_workflow_editor=require_workflow_editor, + ) + ) - `edit` gets a copy of the stored content. An exception from it rolls the change back. - """ - return _to_contract(templates_service.edit_content_locked(team_id, template_id, edit)) + +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: + 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: + 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=uploaded.result, template=_to_contract(uploaded.template)) diff --git a/products/messaging/backend/facade/transactional_email.py b/products/messaging/backend/facade/transactional_email.py index 2d3c0181cbae..4aca0e3faa71 100644 --- a/products/messaging/backend/facade/transactional_email.py +++ b/products/messaging/backend/facade/transactional_email.py @@ -12,8 +12,6 @@ SENDER_ADDRESS_MAX_LENGTH, SENDER_ADDRESS_PATTERN, TransactionalEmailStage, - check_transactional_update, - lock_transactional_workflow_id, make_transactional, observe_transactional_email_stage, require_code_email_access, @@ -35,9 +33,7 @@ "QueuedEmail", "TransactionalEmailError", "TransactionalEmailStage", - "check_transactional_update", "get_email_status", - "lock_transactional_workflow_id", "make_transactional", "observe_transactional_email_stage", "require_code_email_access", diff --git a/products/messaging/backend/presentation/views/message_templates.py b/products/messaging/backend/presentation/views/message_templates.py index 4a3c2687f404..a18afb2ec84d 100644 --- a/products/messaging/backend/presentation/views/message_templates.py +++ b/products/messaging/backend/presentation/views/message_templates.py @@ -8,7 +8,6 @@ from django.core.exceptions import ValidationError as DjangoValidationError from django.core.validators import validate_email -from django.db import transaction import structlog import posthoganalytics @@ -48,17 +47,26 @@ render_design_html, validate_design, ) +from products.messaging.backend.facade.contracts import ( + 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_draft, + save_template_fields, + save_template_tags, team_category_id, - update_template, + upload_template, ) from products.messaging.backend.facade.transactional_email import ( SENDER_ADDRESS_MAX_LENGTH, @@ -68,18 +76,20 @@ QueuedEmail, TransactionalEmailError, TransactionalEmailStage, - check_transactional_update, - lock_transactional_workflow_id, make_transactional, observe_transactional_email_stage, require_code_email_access, send_code_email, send_transactional_email, sender_address_error, - sync_transactional_workflow, transactional_email_stage, ) from products.messaging.backend.presentation.views.serializers import DesignOperationSerializer +from products.messaging.backend.presentation.views.template_refusals import ( + TemplateErrorSerializer, + invalid_request, + invalid_template_key, +) from products.notifications.backend.facade.api import publish_resource_edited from products.workflows.backend.facade.api import WorkflowAccessDenied, WorkflowNotFound from products.workflows.backend.facade.workflows import get_workflow @@ -326,6 +336,36 @@ class MessageTemplateSerializer(serializers.Serializer): help_text="Unpublished edits to a transactional template's content. Sends keep using `content`. Saving " "`content` publishes it and clears the draft. Set null to discard the draft.", ) + 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. Set once, by the first upload.", + ) + 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, and the template changes " + "through its next upload. `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_tags(self, tags: list[str]) -> list[str]: # The stored name is the tagified one, and lowercasing can lengthen a string: "\u0130" becomes two @@ -453,7 +493,9 @@ class SendEmailSerializer(SendTransactionalEmailSerializer): required=False, help_text="Names an email whose content your code rendered, such as `password-reset`, so its metrics, logs " "and status group under it. Letters, numbers, hyphens (-) and underscores (_) only. Send it with `subject` " - "and `html` or `text`. PostHog sends the content as is and never stores it as a template.", + "and `html` or `text`. PostHog sends the content as is and never stores it as a template. Once you upload a " + "template under this key, these sends run through that template's workflow, so their metrics and logs group " + "with the template from then on.", ) distinct_id = serializers.CharField( required=False, @@ -628,6 +670,31 @@ def _require_flag(team: Any, flag: str) -> None: raise NotFound() +class TemplateUploadBurstThrottle(TransactionalEmailBurstThrottle): + scope = "transactional_email_upload_burst" + + +class TemplateUploadSustainedThrottle(TransactionalEmailSustainedThrottle): + scope = "transactional_email_upload_sustained" + + +class TemplateUploadTeamBurstThrottle(TransactionalEmailTeamBurstThrottle): + scope = "transactional_email_upload_psak_team_burst" + + +class TemplateUploadTeamSustainedThrottle(TransactionalEmailTeamSustainedThrottle): + scope = "transactional_email_upload_psak_team_sustained" + + +def template_upload_throttles() -> list[BaseThrottle]: + return [ + TemplateUploadBurstThrottle(), + TemplateUploadSustainedThrottle(), + TemplateUploadTeamBurstThrottle(), + TemplateUploadTeamSustainedThrottle(), + ] + + def transactional_email_throttles() -> list[BaseThrottle]: # Both send routes share these scopes, so a caller can't double its rate by using both. return [ @@ -786,11 +853,14 @@ def _require_workflow_create(self) -> None: raise exceptions.PermissionDenied("You need editor access to workflows to send under a new key.") def _report_transactional_email_send(self, request: Any, properties: dict[str, Any]) -> None: + self._capture_api_usage(request, "transactional email api send", properties) + + def _capture_api_usage(self, request: Any, event: str, 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", + event=event, properties={ "team_id": self.team_id, "auth_method": "project_secret_api_key" @@ -801,7 +871,116 @@ def _report_transactional_email_send(self, request: Any, properties: dict[str, A groups=groups(self.organization, self.team), ) except Exception as e: - logger.warning("Failed to capture transactional email usage event", error=str(e)) + logger.warning("Failed to capture transactional email usage event", event_name=event, error=str(e)) + + +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.", + ) + + +def with_field_named_source_that_drf_reserves( + fields: dict[str, serializers.Field], source: TemplateSourceSerializer +) -> dict[str, serializers.Field]: + return {**fields, "source": source} + + +class UploadTemplateSerializer(serializers.Serializer): + 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.", + ) + if_not_superseded = serializers.BooleanField( + required=False, + default=False, + help_text="Set it on uploads a program makes on its own, such as a sync that may hold an older copy of the " + "file. When newer content already replaced this content, the upload changes nothing and answers `superseded`. " + "The check covers the last 100 different contents the template replaced. Content outside those 100 " + "publishes. Leave it out to publish the content in any case, such as to roll back to earlier content.", + ) + + def get_fields(self) -> dict[str, serializers.Field]: + return with_field_named_source_that_drf_reserves( + super().get_fields(), + 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. `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. After `superseded` it is the hash " + "of the newer content." + ) + managed_by = serializers.ChoiceField( + choices=TemplateManagedBy.choices, + allow_null=True, + help_text="`code` after every upload except `superseded`, which changes nothing. After `superseded` it is " + "what the template had, which is null for a template made in PostHog that no upload claimed yet.", + ) + transactional_hog_flow_id = serializers.UUIDField( + allow_null=True, + help_text="The workflow that sends this template. Each upload that changes the content writes a new version " + "of it. Null only after `superseded`, on a template that is not transactional yet.", + ) + + def get_fields(self) -> dict[str, serializers.Field]: + return with_field_named_source_that_drf_reserves( + super().get_fields(), + TemplateSourceSerializer(help_text="Where the template file lives, as stored after the upload."), + ) + + +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.", +) @extend_schema_view( @@ -829,10 +1008,10 @@ 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"] + psak_allowed_actions = ["send", "upload"] transactional_email_send_actions = ("send",) serializer_class = MessageTemplateSerializer @@ -840,6 +1019,8 @@ class MessageTemplatesViewSet( def get_throttles(self): if self.action == "send": return transactional_email_throttles() + if self.action == "upload": + return template_upload_throttles() return super().get_throttles() def dangerously_get_object(self) -> MessageTemplateRow: @@ -881,25 +1062,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)) @@ -910,45 +1076,47 @@ def update(self, request: Request, *args: Any, **kwargs: Any) -> Response: serializer.is_valid(raise_exception=True) fields = serializer.validated_data if fields.keys() == {"draft_content"}: - # Sends never read the draft, so it skips the sendability check and the workflow sync. It is still an edit - # of the transactional email, so it needs the same workflow access as a publish. - if template.transactional_hog_flow_id: - self._require_workflow_editor(template.transactional_hog_flow_id) - updated = save_template_draft(self.team_id, template.id, fields["draft_content"]) + updated = save_template_draft( + self.team_id, + template.id, + fields["draft_content"], + require_workflow_editor=self._require_workflow_editor, + ) self._emit_resource_edited(updated) return Response(self._serialize(updated)) if fields.keys() == {"tags"}: # Tags are library metadata. Sends never read them, so a tag edit skips the sendability check # and must not write a new version of the template's transactional workflow. - updated = update_template(self.team_id, template.id, fields) + updated = save_template_tags(self.team_id, template.id, fields["tags"]) self._emit_resource_edited(updated) return Response(self._serialize(updated)) if "content" in fields: fields["draft_content"] = None - # 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, fields) - except TransactionalEmailError as e: - raise TransactionalEmailRejected(e) from None - updated = update_template(self.team_id, template.id, fields) - 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) + try: + updated = save_template_fields( + self.team_id, + template.id, + fields, + 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 _sync_transactional_workflow(self, template_id: Any) -> None: - # Raising rolls back the template save, so the template and its workflow never disagree. + def _report_user_action(self, event: str, properties: dict[str, Any]) -> None: try: - sync_transactional_workflow(self.team_id, template_id, user_id=self._actor_id()) - except TransactionalEmailError as e: - raise TransactionalEmailRejected(e) from None + report_user_action( + self.request.user, + event, + {"team_id": str(self.team_id), **properties}, + 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)) def _actor_id(self) -> int | None: return self.request.user.id if self.request.user.is_authenticated else None @@ -979,13 +1147,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") @@ -1012,19 +1181,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, @@ -1047,6 +1216,107 @@ def send(self, request: Any, *args: Any, **kwargs: Any) -> Response: template = self.get_object() return self._queue_transactional_email(request, template, route="template") + @extend_schema( + request=UploadTemplateSerializer, + responses={ + 200: OpenApiResponse( + 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( + response=TemplateErrorSerializer, + description="The key or a field is invalid, the template can't be sent, or there is no verified " + "default sender", + ), + 404: OpenApiResponse(description="Sending email from code 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. With `if_not_superseded`, an upload of content that is one of the last " + "100 different contents the template 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], + ) + @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: + require_email_from_code(self.team) + refused = invalid_template_key(key) + body = UploadTemplateSerializer(data=request.data) + if refused is None and not body.is_valid(): + 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(TemplateErrorSerializer(refused).data, status=status.HTTP_400_BAD_REQUEST) + + 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 + except exceptions.PermissionDenied: + self._report_template_upload(request, {"result": "rejected", "error_code": "permission_denied"}) + raise + + self._report_template_upload(request, {"result": uploaded.result, "template_id": str(uploaded.template.id)}) + if uploaded.result != TemplateUploadResult.SUPERSEDED: + 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", ""), + 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, + if_not_superseded=data["if_not_superseded"], + ) + + @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 _report_template_upload(self, request: Any, outcome: dict[str, Any]) -> None: + self._capture_api_usage(request, "transactional email template uploaded", outcome) + @validated_request( MakeTransactionalSerializer, responses={ @@ -1084,17 +1354,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)) diff --git a/products/messaging/backend/presentation/views/template_refusals.py b/products/messaging/backend/presentation/views/template_refusals.py new file mode 100644 index 000000000000..037a4d24905b --- /dev/null +++ b/products/messaging/backend/presentation/views/template_refusals.py @@ -0,0 +1,83 @@ +import re +from typing import Any + +from rest_framework import serializers +from rest_framework.settings import api_settings + +from posthog.dataclasses import frozen + +from products.messaging.backend.facade.contracts import TEMPLATE_KEY_MAX_LENGTH, TEMPLATE_KEY_PATTERN + + +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.") + 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 TemplateRefusal: + code: str + detail: str + attr: str | None + why: str + fix: str + type: str = "invalid_request" + + @property + def docs_url(self) -> str: + return f"{TRANSACTIONAL_EMAIL_DOCS_URL}#{self.code}" + + +@frozen +class FieldError: + attr: str | None + message: str + + +def invalid_template_key(key: object, attr: str = "key") -> TemplateRefusal | None: + if isinstance(key, str) and len(key) <= TEMPLATE_KEY_MAX_LENGTH and re.fullmatch(TEMPLATE_KEY_PATTERN, key): + return None + return TemplateRefusal( + code="invalid_template_key", + detail=f"The key can have at most {TEMPLATE_KEY_MAX_LENGTH} letters, digits, hyphens (-) and underscores (_).", + 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_request(errors: Any, *, why: str, retry: str) -> TemplateRefusal: + error = first_field_error(errors) + if error.attr is None: + return TemplateRefusal( + code="invalid_request", + detail=error.message, + attr=None, + why=why, + fix=f"Send the body as a JSON object and {retry}.", + ) + return TemplateRefusal( + code="invalid_request", + detail=f"{error.attr}: {error.message}", + attr=error.attr, + 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())) + 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) or None, message=str(errors[0] if isinstance(errors, list) else errors)) diff --git a/products/messaging/backend/services/content_hash.py b/products/messaging/backend/services/content_hash.py index 6958ad9c0bb1..4c4617bcc917 100644 --- a/products/messaging/backend/services/content_hash.py +++ b/products/messaging/backend/services/content_hash.py @@ -1,8 +1,11 @@ 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 _ENCODING_HEADER = b"posthog-email-template-v1\n" @@ -17,3 +20,19 @@ def email_content_hash(email: dict[str, Any]) -> str: 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]: + 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 0480da5d329e..61ec620c9dfb 100644 --- a/products/messaging/backend/services/message_templates.py +++ b/products/messaging/backend/services/message_templates.py @@ -6,7 +6,6 @@ from django.db import models, transaction from django.db.models.fields.json import KT from django.db.models.functions import Cast -from django.utils import timezone from posthog.api.tagged_item import current_tag_names, replace_tags_on_object, set_tags_on_object from posthog.models import TaggedItem @@ -14,6 +13,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: @@ -90,12 +90,16 @@ 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: + 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 -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) @@ -105,44 +109,39 @@ def create_template(team_id: int, created_by_id: int | None, fields: dict[str, A tags = fields.pop("tags", None) with transaction.atomic(): 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) if tags: set_tags_on_object(tags, template) return template -def update_template(team_id: int, template_id: UUID | str, fields: dict[str, Any]) -> MessageTemplate: - fields = dict(fields) - tags = fields.pop("tags", None) +def save_template_tags(team_id: int, template_id: UUID | str, tags: list[str]) -> MessageTemplate: with transaction.atomic(): template = team_template(team_id, template_id) - # The editor sends the tags with every save, so only write them when they change. - tags_changed = tags is not None and {tagify(tag) for tag in tags} != current_tag_names(template) # Tags live on their own rows. Saving the template too would write back every column this # read saw, so a tag edit would overwrite content a concurrent publish had just committed. - if fields: - _apply_fields(template, fields) - template.save() - if tags_changed: - replace_tags_on_object(tags, template) + save_tags(template, tags) return template -def save_draft(team_id: int, template_id: UUID | str, draft: dict[str, Any] | None) -> MessageTemplate: - # Writes only the draft, so a draft save that races a publish, a delete or a conversion can't undo it. - MessageTemplate.objects.filter(team_id=team_id, deleted=False, pk=template_id).update( - draft_content=draft, updated_at=timezone.now() - ) - return team_template(team_id, template_id) +def save_tags(template: MessageTemplate, tags: list[str] | None) -> None: + if tags is None: + return + tags_changed = {tagify(tag) for tag in tags} != current_tag_names(template) + if tags_changed: + replace_tags_on_object(tags, template) -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 edit_content(template: MessageTemplate, edit: Callable[[dict[str, Any]], dict[str, Any]]) -> None: + template.content = edit(deepcopy(template.content or {})) + + +def record_content_hash(template: MessageTemplate) -> None: + 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..a9d7516fa8cf --- /dev/null +++ b/products/messaging/backend/services/template_saves.py @@ -0,0 +1,71 @@ +from collections.abc import Callable +from typing import Any +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: + 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 save_locked_draft( + team_id: int, + template_id: UUID, + draft: dict[str, Any] | None, + *, + require_workflow_editor: WorkflowEditorCheck, +) -> MessageTemplate: + with transaction.atomic(): + template = lock_template(team_id, template_id) + if template.transactional_hog_flow_id: + require_workflow_editor(template.transactional_hog_flow_id) + template.draft_content = draft + template.save(update_fields=["draft_content", "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) + + +def save_locked_template( + template: MessageTemplate, + write: TemplateWrite, + *, + user_id: int | None, + require_workflow_editor: WorkflowEditorCheck, +) -> None: + hog_flow_id = template.transactional_hog_flow_id + 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_workflow_and_reread_its_link(template, user_id=user_id) + + +def _sync_workflow_and_reread_its_link(template: MessageTemplate, *, user_id: int | None) -> None: + sync_transactional_workflow(template.team_id, template.id, user_id=user_id) + 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..936e430f9fcd --- /dev/null +++ b/products/messaging/backend/services/template_uploads.py @@ -0,0 +1,187 @@ +from collections.abc import Callable +from typing import Any + +from django.db import IntegrityError, transaction + +from posthog.dataclasses import frozen + +from products.messaging.backend.facade.contracts import ( + ContentHashState, + SenderUnavailable, + TemplateManagedBy, + TemplateSource, + TemplateUpload, + TemplateUploadResult, +) +from products.messaging.backend.models import MessageTemplate +from products.messaging.backend.models.message_template import KEY_CONSTRAINT +from products.messaging.backend.services.content_hash import 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 + +SOURCE_PARTS = ("repository", "path", "ref") + +WorkflowCreateCheck = Callable[[], None] + + +@frozen +class Uploaded: + result: TemplateUploadResult + template: MessageTemplate + + +def upload_template( + team_id: int, + upload: TemplateUpload, + *, + user_id: int | None, + require_workflow_editor: WorkflowEditorCheck, + require_workflow_create: WorkflowCreateCheck, +) -> Uploaded: + def attempt() -> Uploaded: + 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 not _another_upload_created_the_key_first(error): + raise + return attempt() + + +def _another_upload_created_the_key_first(error: IntegrityError) -> bool: + return KEY_CONSTRAINT in str(error) + + +def _upload( + team_id: int, + upload: TemplateUpload, + *, + user_id: int | None, + require_workflow_editor: WorkflowEditorCheck, + require_workflow_create: WorkflowCreateCheck, +) -> Uploaded: + template = locked_template_with_key(team_id, upload.key) + if template is None: + require_workflow_create() + return Uploaded(result=TemplateUploadResult.CREATED, template=_create(team_id, upload, user_id=user_id)) + if template.transactional_hog_flow_id: + require_workflow_editor(template.transactional_hog_flow_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) + save_locked_template( + template, + lambda locked: _write_upload(locked, upload), + user_id=user_id, + require_workflow_editor=require_workflow_editor, + ) + adopted_or_lost_its_workflow = not template.transactional_hog_flow_id + if adopted_or_lost_its_workflow: + require_workflow_create() + _make_transactional(template, user_id=user_id) + return Uploaded(result=TemplateUploadResult.UPDATED, template=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(_sender_unavailable_message(template)) from error + template.refresh_from_db() + + +def _sender_unavailable_message(template: MessageTemplate) -> str: + sender = ((template.content or {}).get("email") or {}).get("from") + if isinstance(sender, dict) and sender.get("integrationId"): + return ( + "The template's email sender isn't verified. Verify its sending domain, or pick a verified sender for " + "the template in PostHog, and upload again." + ) + return ( + "The project has no verified default email sender. Verify a sending domain, set it as the default " + "sender, and upload again." + ) + + +def _write_upload(template: MessageTemplate, upload: TemplateUpload) -> None: + template.name = upload.name + template.type = "email" + template.managed_by = TemplateManagedBy.CODE + content = template.content or {} + stored_email = content.get("email") or {} + template.content = {**content, "templating": "liquid", "email": {**stored_email, **_email(upload)}} + _discard_draft_the_upload_makes_stale(template) + _write_source(template, upload.source) + + +def _discard_draft_the_upload_makes_stale(template: MessageTemplate) -> None: + template.draft_content = None + + +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: + stored_email = (template.content or {}).get("email") or {} + uploaded_hash = email_content_hash(_email(upload)) + return ( + template.name == upload.name + and email_content_hash(stored_email) == uploaded_hash + and template.content_hash == uploaded_hash + and template.managed_by == TemplateManagedBy.CODE + and is_transactional(template) + ) + + +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: + 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: + changed = _write_source(template, source) + if changed: + template.save(update_fields=[*changed, "updated_at"]) diff --git a/products/messaging/backend/services/transactional_email.py b/products/messaging/backend/services/transactional_email.py index 4599b018a768..29d22169a525 100644 --- a/products/messaging/backend/services/transactional_email.py +++ b/products/messaging/backend/services/transactional_email.py @@ -288,31 +288,20 @@ 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: + 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, ) @@ -378,19 +367,19 @@ def _require_active(team_id: int, hog_flow_id: UUID) -> None: raise TransactionalEmailPaused("The template's transactional email isn't active.") +def _template_an_upload_claimed(team_id: int, key: str) -> MessageTemplate | None: + return MessageTemplate.objects.filter(team_id=team_id, key=key, deleted=False, managed_by__isnull=False).first() + + def _code_email_workflow_id(team_id: int, key: str) -> UUID: - template_id = _template_id_for_key(team_id, key) - if template_id is not None: - return _transactional_workflow_id(team_id, template_id) + template = _template_an_upload_claimed(team_id, key) + if template is not None: + return _transactional_workflow_id(team_id, template.id) hog_flow_id = _key_workflow_id(team_id, key) _require_active(team_id, hog_flow_id) return hog_flow_id -def _template_id_for_key(team_id: int, key: str) -> UUID | None: - return MessageTemplate.objects.filter(team_id=team_id, key=key, deleted=False).values_list("id", flat=True).first() - - def known_key(team_id: int, key: str) -> TransactionalEmailKey | None: return TransactionalEmailKey.objects.for_team(team_id).filter(key=key).first() @@ -420,7 +409,7 @@ def require_code_email_access( require_workflow_editor: Callable[[UUID], None], require_workflow_create: Callable[[], None], ) -> None: - template = MessageTemplate.objects.filter(team_id=team_id, key=key, deleted=False).first() + template = _template_an_upload_claimed(team_id, key) if template is not None: if template.transactional_hog_flow_id is not None: require_workflow_editor(template.transactional_hog_flow_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..f46956de14ba --- /dev/null +++ b/products/messaging/backend/tests/api/test_template_upload.py @@ -0,0 +1,376 @@ +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.presentation.views.message_templates import ( + EMAIL_FROM_CODE_FLAG, + TRANSACTIONAL_EMAIL_API_FLAG, + TransactionalEmailBurstThrottle, +) +from products.messaging.backend.services import template_uploads +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" +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): + flag.side_effect = lambda key, *_args, **_kwargs: key == EMAIL_FROM_CODE_FLAG + 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, + ) + + @parameterized.expand( + [ + ("same_text", WELCOME, WELCOME, WELCOME_HASH), + ("other_line_endings", {**GREETING, "html": "

ä

\r\n"}, GREETING, GREETING_HASH), + ] + ) + def test_an_identical_upload_is_unchanged_and_writes_no_workflow_version( + self, _post, _flag, _capture, _name, first_body, second_body, expected_hash + ): + first = self._upload(first_body) + before = self._template(first["id"]) + + with patch("products.messaging.backend.presentation.views.message_templates.publish_resource_edited") as edited: + response = self.psak.put(self.url, {**second_body, "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"] == expected_hash + kept_source = {**(first_body.get("source") or {"repository": None, "path": None}), "ref": "9e8d7c6"} + assert response.json()["source"] == kept_source + assert self._workflow(first["transactional_hog_flow_id"])["version"] == 1 + after = self._template(first["id"]) + assert {part: after[f"source_{part}"] for part in ("repository", "path", "ref")} == kept_source + assert after["updated_at"] > before["updated_at"] + assert ( + edited.call_args.kwargs["updated_at"] == MessageTemplate.objects.get(id=first["id"]).updated_at.isoformat() + ) + + def test_an_upload_repairs_a_stored_hash_that_does_not_describe_the_content(self, _post, _flag, _capture): + first = self._upload(WELCOME) + MessageTemplate.objects.filter(id=first["id"]).update(content_hash=GREETING_HASH) + + repaired = self._upload(WELCOME) + + assert (repaired["result"], repaired["content_hash"]) == ("updated", WELCOME_HASH) + assert self._template(first["id"])["content_hash"] == WELCOME_HASH + + @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 _adopted_template_sending_from(self, sender: Integration) -> MessageTemplate: + return MessageTemplate.objects.create( + team=self.team, + name="Welcome", + key="welcome", + content={ + "email": { + "subject": "Hello", + "html": "

Hello

", + "from": {"integrationId": sender.id, "email": "team@example.com", "name": "Example team"}, + } + }, + type="email", + ) + + def test_an_upload_makes_an_adopted_template_without_a_workflow_send_from_its_own_sender( + self, _post, _flag, _capture + ): + own_sender = self._verified_sender("team@example.com") + adopted = self._adopted_template_sending_from(own_sender) + + body = self._upload(WELCOME) + + assert (body["result"], body["id"]) == ("updated", str(adopted.id)) + assert self._sent_email(body["transactional_hog_flow_id"])["from"] == { + "integrationId": own_sender.id, + "email": "team@example.com", + "name": "Example team", + } + + @parameterized.expand( + [ + ("new_template_without_a_verified_default", False, "The project has no verified default email sender."), + ("adopted_template_with_an_unverified_sender", True, "The template's email sender isn't verified."), + ] + ) + def test_without_a_verified_sender_an_upload_names_the_sender_and_writes_nothing( + self, _post, _flag, _capture, _name, adopted, expected_detail_start + ): + if adopted: + unverified = Integration.objects.create( + team=self.team, kind="email", integration_id="team@example.com", config={"verified": False} + ) + self._adopted_template_sending_from(unverified) + else: + 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 response.json()["detail"].startswith(expected_detail_start) + stored = MessageTemplate.objects.filter(team=self.team) + assert [(t.managed_by, t.transactional_hog_flow_id) for t in stored] == ([(None, None)] if adopted else []) + + @parameterized.expand( + [ + ("invalid_key", "wel!come", WELCOME, "invalid_template_key", "key", "Use a key like `password-reset`."), + ( + "missing_subject", + "welcome", + {"name": "Welcome", "html": "

Hi

"}, + "invalid_request", + "subject", + "Fix `subject` and upload again.", + ), + ( + "long_source_path", + "welcome", + WELCOME | {"source": {"path": "a" * 401}}, + "invalid_request", + "source__path", + "Fix `source__path` and upload again.", + ), + ( + "list_body", + "welcome", + [WELCOME], + "invalid_request", + None, + "Send the body as a JSON object and upload again.", + ), + ] + ) + def test_refuses_an_invalid_upload_with_the_field_to_fix( + self, _post, _flag, _capture, _name, key, body, expected_code, expected_attr, expected_fix + ): + 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"], response.json()["fix"]) == (expected_attr, expected_fix) + 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, {EMAIL_FROM_CODE_FLAG}, status.HTTP_403_FORBIDDEN), + ("another_projects_key", ["hog_flow:write"], True, {EMAIL_FROM_CODE_FLAG}, status.HTTP_403_FORBIDDEN), + ( + "only_the_transactional_email_api_flag", + ["hog_flow:write"], + False, + {TRANSACTIONAL_EMAIL_API_FLAG}, + status.HTTP_404_NOT_FOUND, + ), + ] + ) + def test_refuses_an_upload_it_does_not_allow( + self, _post, flag, _capture, _name, scopes, other_project, enabled_flags, expected_status + ): + flag.side_effect = lambda key, *_args, **_kwargs: key in enabled_flags + 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() + + @patch("posthog.rate_limit.is_rate_limit_enabled", return_value=True) + def test_uploads_leave_the_send_rate_limit_to_sends(self, _rate_limit, post: MagicMock, _flag, _capture): + post.return_value = MagicMock(status_code=202, json=lambda: {"invocation_id": "invocation-1"}) + + with patch.object(TransactionalEmailBurstThrottle, "rate", "1/minute"): + uploaded = self.psak.put(self.url, WELCOME, format="json") + sent = self.psak.post( + f"/api/projects/{self.team.id}/messaging_templates/{uploaded.json()['id']}/send/", + {"to": "user@example.com"}, + format="json", + ) + + assert uploaded.status_code == status.HTTP_201_CREATED, uploaded.json() + assert sent.status_code == status.HTTP_202_ACCEPTED, sent.json() + + 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] + + @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 diff --git a/products/messaging/backend/tests/api/test_transactional_email.py b/products/messaging/backend/tests/api/test_transactional_email.py index 9d72794200f0..4bf913551ce1 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 posthog.models.utils import generate_random_token_secret from products.access_control.backend.facade.testing import create_access_control +from products.messaging.backend.facade.contracts import TemplateManagedBy from products.messaging.backend.models.message_category import MessageCategory, MessageCategoryType from products.messaging.backend.models.message_template import MessageTemplate from products.messaging.backend.models.transactional_email_key import TransactionalEmailKey @@ -32,6 +33,8 @@ from products.workflows.backend.facade.api import set_default_email_sender_if_unset from products.workflows.backend.models import HogFlow, TeamWorkflowsConfig +PASSWORD_RESET_HASH = "v1:973d41bbe3fdca5c52bb951cd4b5fd7159ae630b56c8f3997cbb31afd1ea76fd" + def _stage_counts() -> dict[str, float]: return { @@ -75,6 +78,9 @@ def _template(self, content: dict | None = None) -> MessageTemplate: type="email", ) + def _claim_by_upload(self, template: MessageTemplate) -> None: + MessageTemplate.objects.filter(id=template.id).update(managed_by=TemplateManagedBy.CODE) + 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() @@ -381,10 +387,11 @@ def _code_send(self, **fields: Any) -> dict[str, Any]: **fields, } - @parameterized.expand([("new_key", "welcome"), ("key_that_names_a_template", "password-reset")]) + @parameterized.expand([("new_key", "welcome"), ("key_of_an_uploaded_template", "password-reset")]) def test_a_code_send_goes_out_as_is_under_the_keys_workflow(self, mock_post, _flag, capture, _name, key): MessageTemplate.objects.filter(id=self.template.id).update(key="password-reset") self._cdp_accepts(mock_post) + self._claim_by_upload(self.template) templates_before = MessageTemplate.objects.filter(team=self.team).count() psak = self._psak_client(["hog_flow:write"]) @@ -835,6 +842,7 @@ def setUp(self): self.template = MessageTemplate.objects.create( team=self.team, name="Password reset", + key="password-reset", content={"email": {"subject": "Reset your password", "html": "

Reset

"}}, type="email", ) @@ -872,7 +880,9 @@ def test_a_member_denied_the_workflow_cannot_send_or_change_it(self, mock_post, mock_post.assert_not_called() def test_a_member_denied_the_workflow_cannot_send_code_under_its_template_key(self, mock_post, _flag): - MessageTemplate.objects.filter(id=self.template.id).update(key="password-reset") + MessageTemplate.objects.filter(id=self.template.id).update( + key="password-reset", managed_by=TemplateManagedBy.CODE + ) response = self.client.post( f"/api/projects/{self.team.id}/messaging/send/email/", @@ -898,6 +908,44 @@ def test_a_member_who_cannot_create_workflows_cannot_send_code_under_a_new_key(s assert not TransactionalEmailKey.objects.for_team(self.team.id).exists() mock_post.assert_not_called() + @parameterized.expand( + [ + ("unchanged_content", False, {"content_hash": PASSWORD_RESET_HASH}), + ( + "superseded_content", + True, + {"content_hash": "v1:" + "0" * 64, "superseded_content_hashes": [PASSWORD_RESET_HASH]}, + ), + ] + ) + def test_a_member_denied_the_workflow_cannot_upload_to_it( + self, mock_post, _flag, _name, if_not_superseded, stored_hashes + ): + MessageTemplate.objects.filter(id=self.template.id).update(managed_by="code", **stored_hashes) + + with patch( + "products.messaging.backend.presentation.views.message_templates.posthoganalytics.capture" + ) as capture: + response = self.client.put( + f"/api/projects/{self.team.id}/messaging_templates/by_key/password-reset/", + { + "name": "Password reset", + "subject": "Reset your password", + "html": "

Reset

", + "source": {"repository": "github.com/example/other"}, + "if_not_superseded": if_not_superseded, + }, + format="json", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN, response.json() + self.template.refresh_from_db() + assert self.template.source_repository is None + uploaded_events = [ + c for c in capture.call_args_list if c.kwargs["event"] == "transactional email template uploaded" + ] + assert [e.kwargs["properties"]["error_code"] for e in uploaded_events] == ["permission_denied"] + @parameterized.expand([("unlinked", None), ("linked_to_a_deleted_workflow", uuid4())]) def test_editor_access_to_one_workflow_cannot_create_another(self, mock_post, _flag, _name, linked_hog_flow_id): self._login_member("viewer@example.com", {None: "viewer", str(self.hog_flow_id): "editor"}) diff --git a/products/messaging/frontend/generated/api.schemas.ts b/products/messaging/frontend/generated/api.schemas.ts index c2589162ff0b..91c5e4e810db 100644 --- a/products/messaging/frontend/generated/api.schemas.ts +++ b/products/messaging/frontend/generated/api.schemas.ts @@ -78,7 +78,7 @@ export interface SendEmailApi { /** The transactional email template to send. Send either `template` or `template_key`. */ template?: SendEmailTemplateApi /** - * Names an email whose content your code rendered, such as `password-reset`, so its metrics, logs and status group under it. Letters, numbers, hyphens (-) and underscores (_) only. Send it with `subject` and `html` or `text`. PostHog sends the content as is and never stores it as a template. + * Names an email whose content your code rendered, such as `password-reset`, so its metrics, logs and status group under it. Letters, numbers, hyphens (-) and underscores (_) only. Send it with `subject` and `html` or `text`. PostHog sends the content as is and never stores it as a template. Once you upload a template under this key, these sends run through that template's workflow, so their metrics and logs group with the template from then on. * @maxLength 400 * @pattern ^[a-zA-Z0-9_-]+$ */ @@ -757,6 +757,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 /** @@ -798,6 +809,36 @@ export interface MessageTemplateApi { readonly transactional_hog_flow_id: string | null /** Unpublished edits to a transactional template's content. Sends keep using `content`. Saving `content` publishes it and clears the draft. Set null to discard the draft. */ draft_content?: MessageTemplateContentApi | null + /** + * 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. + * @nullable + */ + readonly key: string | null + /** Who owns the content. `code` when it was uploaded from a repository, and the template changes through its next upload. `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 { @@ -850,6 +891,36 @@ export interface PatchedMessageTemplateApi { readonly transactional_hog_flow_id?: string | null /** Unpublished edits to a transactional template's content. Sends keep using `content`. Saving `content` publishes it and clears the draft. Set null to discard the draft. */ draft_content?: MessageTemplateContentApi | null + /** + * 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. + * @nullable + */ + readonly key?: string | null + /** Who owns the content. `code` when it was uploaded from a repository, and the template changes through its next upload. `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 } /** @@ -932,6 +1003,110 @@ export interface SendTransactionalEmailApi { message_data?: SendTransactionalEmailApiMessageData } +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 + /** Set it on uploads a program makes on its own, such as a sync that may hold an older copy of the file. When newer content already replaced this content, the upload changes nothing and answers `superseded`. The check covers the last 100 different contents the template replaced. Content outside those 100 publishes. Leave it out to publish the content in any case, such as to roll back to earlier content. */ + if_not_superseded?: boolean + /** 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 + * * `superseded` - Superseded + */ +export type TemplateUploadResultEnumApi = (typeof TemplateUploadResultEnumApi)[keyof typeof TemplateUploadResultEnumApi] + +export const TemplateUploadResultEnumApi = { + Created: 'created', + Updated: 'updated', + Unchanged: 'unchanged', + Superseded: 'superseded', +} 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. `superseded`: the upload set `if_not_superseded` and newer content already replaced this content, so nothing was published. + * + * * `created` - Created + * * `updated` - Updated + * * `unchanged` - Unchanged + * * `superseded` - Superseded */ + result: TemplateUploadResultEnumApi + /** Hash of the published content, as `v1:` and 64 hex characters. After `superseded` it is the hash of the newer content. */ + content_hash: string + /** `code` after every upload except `superseded`, which changes nothing. After `superseded` it is what the template had, which is null for a template made in PostHog that no upload claimed yet. + * + * * `gui` - GUI + * * `code` - Code */ + managed_by: TemplateManagedByEnumApi | null + /** + * The workflow that sends this template. Each upload that changes the content writes a new version of it. Null only after `superseded`, on a template that is not transactional yet. + * @nullable + */ + transactional_hog_flow_id: string | null + /** Where the template file lives, as stored after the upload. */ + source: TemplateSourceApi +} + +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 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 9a664794b377..95621f803b8b 100644 --- a/products/messaging/frontend/generated/api.ts +++ b/products/messaging/frontend/generated/api.ts @@ -42,6 +42,8 @@ import type { SendTransactionalEmailResponseApi, StartProviderImportApi, SupabaseAuthEmailSettingsApi, + UploadTemplateApi, + UploadTemplateResponseApi, WebhookUrlApi, } from './api.schemas' @@ -977,3 +979,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. With `if_not_superseded`, an upload of content that is one of the last 100 different contents the template 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 ( + 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 eb0657a06905..035e47924c17 100644 --- a/products/messaging/frontend/generated/api.zod.ts +++ b/products/messaging/frontend/generated/api.zod.ts @@ -54,7 +54,7 @@ export const MessagingSendEmailCreateBody = /* @__PURE__ */ zod.object({ .regex(messagingSendEmailCreateBodyTemplateKeyRegExp) .optional() .describe( - 'Names an email whose content your code rendered, such as `password-reset`, so its metrics, logs and status group under it. Letters, numbers, hyphens (-) and underscores (_) only. Send it with `subject` and `html` or `text`. PostHog sends the content as is and never stores it as a template.' + "Names an email whose content your code rendered, such as `password-reset`, so its metrics, logs and status group under it. Letters, numbers, hyphens (-) and underscores (_) only. Send it with `subject` and `html` or `text`. PostHog sends the content as is and never stores it as a template. Once you upload a template under this key, these sends run through that template's workflow, so their metrics and logs group with the template from then on." ), distinct_id: zod .string() @@ -1399,3 +1399,63 @@ 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. With `if_not_superseded`, an upload of content that is one of the last 100 different contents the template 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 + +export const messagingTemplatesByKeyUpdateBodyIfNotSupersededDefault = false +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.'), + if_not_superseded: zod + .boolean() + .default(messagingTemplatesByKeyUpdateBodyIfNotSupersededDefault) + .describe( + 'Set it on uploads a program makes on its own, such as a sync that may hold an older copy of the file. When newer content already replaced this content, the upload changes nothing and answers `superseded`. The check covers the last 100 different contents the template replaced. Content outside those 100 publishes. Leave it out to publish the content in any case, such as to roll back to earlier content.' + ), + 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/workflows/mcp/email_templates.yaml b/products/workflows/mcp/email_templates.yaml index 98f6995beab7..f9f67c862a28 100644 --- a/products/workflows/mcp/email_templates.yaml +++ b/products/workflows/mcp/email_templates.yaml @@ -186,6 +186,30 @@ tools: - content.email.design - created_by - draft_content + workflows-upload-email-template: + operation: messaging_templates_by_key_update + enabled: true + feature_flag: workflows-email-from-code + 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. Set if_not_superseded when an older copy of the file must not replace newer content: an + answer of superseded means newer content is already published and nothing changed. The check covers the + last 100 different contents the template replaced; content outside those 100 publishes. Leave it out to publish + this content even when newer content replaced it, such as to roll back. A 404 with code not_found means + sending email from code is not enabled for this project, or the project does not exist: stop and tell + the user. 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 dfe1cbc1ba77..97693ceef442 100644 --- a/services/mcp/schema/generated-tool-definitions.json +++ b/services/mcp/schema/generated-tool-definitions.json @@ -17360,6 +17360,22 @@ "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. Set if_not_superseded when an older copy of the file must not replace newer content: an answer of superseded means newer content is already published and nothing changed. The check covers the last 100 different contents the template replaced; content outside those 100 publishes. Leave it out to publish this content even when newer content replaced it, such as to roll back. A 404 with code not_found means sending email from code is not enabled for this project, or the project does not exist: stop and tell the user.", + "category": "Workflows", + "feature": "workflows", + "summary": "Upload email template by key", + "title": "Upload email template by key", + "required_scopes": ["hog_flow:write"], + "api_operations": ["PUT /api/projects/{project_id}/messaging_templates/by_key/{key}/"], + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "readOnlyHint": false + }, + "feature_flag": "workflows-email-from-code" + }, "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 42551aab6a87..ba67e6f8528a 100644 --- a/services/mcp/schema/tool-definitions-all.json +++ b/services/mcp/schema/tool-definitions-all.json @@ -18207,6 +18207,22 @@ "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. Set if_not_superseded when an older copy of the file must not replace newer content: an answer of superseded means newer content is already published and nothing changed. The check covers the last 100 different contents the template replaced; content outside those 100 publishes. Leave it out to publish this content even when newer content replaced it, such as to roll back. A 404 with code not_found means sending email from code is not enabled for this project, or the project does not exist: stop and tell the user.", + "category": "Workflows", + "feature": "workflows", + "summary": "Upload email template by key", + "title": "Upload email template by key", + "required_scopes": ["hog_flow:write"], + "api_operations": ["PUT /api/projects/{project_id}/messaging_templates/by_key/{key}/"], + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "readOnlyHint": false + }, + "feature_flag": "workflows-email-from-code" + }, "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 fbe769cbeea5..70c85f265f0e 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -72925,6 +72925,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; /** @@ -72966,6 +72978,36 @@ export namespace Schemas { readonly transactional_hog_flow_id: string | null; /** Unpublished edits to a transactional template's content. Sends keep using `content`. Saving `content` publishes it and clears the draft. Set null to discard the draft. */ draft_content?: MessageTemplateContent | null; + /** + * 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. + * @nullable + */ + readonly key: string | null; + /** Who owns the content. `code` when it was uploaded from a repository, and the template changes through its next upload. `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 { @@ -90460,6 +90502,36 @@ export namespace Schemas { readonly transactional_hog_flow_id?: string | null; /** Unpublished edits to a transactional template's content. Sends keep using `content`. Saving `content` publishes it and clears the draft. Set null to discard the draft. */ draft_content?: MessageTemplateContent | null; + /** + * 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. + * @nullable + */ + readonly key?: string | null; + /** Who owns the content. `code` when it was uploaded from a repository, and the template changes through its next upload. `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; } /** @@ -110909,7 +110981,7 @@ export namespace Schemas { /** The transactional email template to send. Send either `template` or `template_key`. */ template?: SendEmailTemplate; /** - * Names an email whose content your code rendered, such as `password-reset`, so its metrics, logs and status group under it. Letters, numbers, hyphens (-) and underscores (_) only. Send it with `subject` and `html` or `text`. PostHog sends the content as is and never stores it as a template. + * Names an email whose content your code rendered, such as `password-reset`, so its metrics, logs and status group under it. Letters, numbers, hyphens (-) and underscores (_) only. Send it with `subject` and `html` or `text`. PostHog sends the content as is and never stores it as a template. Once you upload a template under this key, these sends run through that template's workflow, so their metrics and logs group with the template from then on. * @maxLength 400 * @pattern ^[a-zA-Z0-9_-]+$ */ @@ -122060,6 +122132,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. * @@ -122083,6 +122175,43 @@ 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; + } + + /** + * * `created` - Created + * * `updated` - Updated + * * `unchanged` - Unchanged + * * `superseded` - Superseded + */ + export type TemplateUploadResultEnum = typeof TemplateUploadResultEnum[keyof typeof TemplateUploadResultEnum]; + + + export const TemplateUploadResultEnum = { + Created: 'created', + Updated: 'updated', + Unchanged: 'unchanged', + Superseded: 'superseded', + } as const; + /** * Anthropic text, image, or tool content blocks. */ @@ -123398,6 +123527,54 @@ 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; + /** Set it on uploads a program makes on its own, such as a sync that may hold an older copy of the file. When newer content already replaced this content, the upload changes nothing and answers `superseded`. The check covers the last 100 different contents the template replaced. Content outside those 100 publishes. Leave it out to publish the content in any case, such as to roll back to earlier content. */ + if_not_superseded?: boolean; + /** 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. `superseded`: the upload set `if_not_superseded` and newer content already replaced this content, so nothing was published. + * + * * `created` - Created + * * `updated` - Updated + * * `unchanged` - Unchanged + * * `superseded` - Superseded */ + result: TemplateUploadResultEnum; + /** Hash of the published content, as `v1:` and 64 hex characters. After `superseded` it is the hash of the newer content. */ + content_hash: string; + /** `code` after every upload except `superseded`, which changes nothing. After `superseded` it is what the template had, which is null for a template made in PostHog that no upload claimed yet. + * + * * `gui` - GUI + * * `code` - Code */ + managed_by: TemplateManagedByEnum | null; + /** + * The workflow that sends this template. Each upload that changes the content writes a new version of it. Null only after `superseded`, on a template that is not transactional yet. + * @nullable + */ + transactional_hog_flow_id: string | null; + /** Where the template file lives, as stored after the upload. */ + source: TemplateSource; + } + 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 1e1f69891b61..c9c0288b7844 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 7 enabled ops + * PostHog API - MCP 8 enabled ops * OpenAPI spec version: 1.0.0 */ import * as zod from 'zod' @@ -44,7 +44,7 @@ export const MessagingSendEmailCreateBody = () => zod.object({ .regex(messagingSendEmailCreateBodyTemplateKeyRegExp) .optional() .describe( - 'Names an email whose content your code rendered, such as `password-reset`, so its metrics, logs and status group under it. Letters, numbers, hyphens (-) and underscores (_) only. Send it with `subject` and `html` or `text`. PostHog sends the content as is and never stores it as a template.' + "Names an email whose content your code rendered, such as `password-reset`, so its metrics, logs and status group under it. Letters, numbers, hyphens (-) and underscores (_) only. Send it with `subject` and `html` or `text`. PostHog sends the content as is and never stores it as a template. Once you upload a template under this key, these sends run through that template's workflow, so their metrics and logs group with the template from then on." ), distinct_id: zod .string() @@ -488,3 +488,76 @@ export const MessagingTemplatesMakeTransactionalCreateBody = () => zod.object({ "ID of the verified email sender integration the email is sent from. Defaults to the project's default sender." ), }) + +/** + * 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 is one of the last 100 different contents the template 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({ + 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 messagingTemplatesByKeyUpdateBodyIfNotSupersededDefault = false +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.'), + if_not_superseded: zod + .boolean() + .default(messagingTemplatesByKeyUpdateBodyIfNotSupersededDefault) + .describe( + 'Set it on uploads a program makes on its own, such as a sync that may hold an older copy of the file. When newer content already replaced this content, the upload changes nothing and answers `superseded`. The check covers the last 100 different contents the template replaced. Content outside those 100 publishes. Leave it out to publish the content in any case, such as to roll back to earlier content.' + ), + 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 cebec5e645a2..fc5aac13015e 100644 --- a/services/mcp/src/tools/generated/email_templates.ts +++ b/services/mcp/src/tools/generated/email_templates.ts @@ -311,6 +311,53 @@ 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.if_not_superseded !== undefined) { + body['if_not_superseded'] = params.if_not_superseded + } + 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() @@ -371,5 +418,6 @@ export const GENERATED_TOOLS: Record ToolBase> = { 'workflows-make-email-template-transactional-execute': workflowsMakeEmailTemplateTransactionalExecute, '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-send-email.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-send-email.json index f627c1c4b881..ce7b83db975e 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-send-email.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-send-email.json @@ -33,7 +33,7 @@ "type": "string" }, "template_key": { - "description": "Names an email whose content your code rendered, such as `password-reset`, so its metrics, logs and status group under it. Letters, numbers, hyphens (-) and underscores (_) only. Send it with `subject` and `html` or `text`. PostHog sends the content as is and never stores it as a template.", + "description": "Names an email whose content your code rendered, such as `password-reset`, so its metrics, logs and status group under it. Letters, numbers, hyphens (-) and underscores (_) only. Send it with `subject` and `html` or `text`. PostHog sends the content as is and never stores it as a template. Once you upload a template under this key, these sends run through that template's workflow, so their metrics and logs group with the template from then on.", "maxLength": 400, "pattern": "^[a-zA-Z0-9_-]+$", "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..61be523f4553 --- /dev/null +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-upload-email-template.json @@ -0,0 +1,79 @@ +{ + "$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" + }, + "if_not_superseded": { + "default": false, + "description": "Set it on uploads a program makes on its own, such as a sync that may hold an older copy of the file. When newer content already replaced this content, the upload changes nothing and answers `superseded`. The check covers the last 100 different contents the template replaced. Content outside those 100 publishes. 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" + }, + "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" +} From 7a74cedd3d762e14954d212e013cde4cecac500f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:25:45 +0000 Subject: [PATCH 03/42] fix(messaging): check the key's workflow before an upload takes its code sends Co-Authored-By: Claude Opus 5.5 --- .../backend/services/template_uploads.py | 10 +++++- .../backend/tests/api/test_template_upload.py | 33 ++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/products/messaging/backend/services/template_uploads.py b/products/messaging/backend/services/template_uploads.py index 936e430f9fcd..4ad56332ad4f 100644 --- a/products/messaging/backend/services/template_uploads.py +++ b/products/messaging/backend/services/template_uploads.py @@ -18,7 +18,7 @@ from products.messaging.backend.services.content_hash import 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 +from products.messaging.backend.services.transactional_email import is_transactional, known_key, make_transactional SOURCE_PARTS = ("repository", "path", "ref") @@ -70,6 +70,8 @@ def _upload( require_workflow_create: WorkflowCreateCheck, ) -> Uploaded: template = locked_template_with_key(team_id, upload.key) + if template is None or template.managed_by is None: + _require_editor_of_the_key_workflow(team_id, upload.key, require_workflow_editor) if template is None: require_workflow_create() return Uploaded(result=TemplateUploadResult.CREATED, template=_create(team_id, upload, user_id=user_id)) @@ -93,6 +95,12 @@ def _upload( return Uploaded(result=TemplateUploadResult.UPDATED, template=template) +def _require_editor_of_the_key_workflow(team_id: int, key: str, require_workflow_editor: WorkflowEditorCheck) -> None: + key_workflow = known_key(team_id, key) + if key_workflow is not None: + require_workflow_editor(key_workflow.hog_flow_id) + + 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() diff --git a/products/messaging/backend/tests/api/test_template_upload.py b/products/messaging/backend/tests/api/test_template_upload.py index f46956de14ba..fff1fc79e2e7 100644 --- a/products/messaging/backend/tests/api/test_template_upload.py +++ b/products/messaging/backend/tests/api/test_template_upload.py @@ -7,11 +7,13 @@ from rest_framework import status from rest_framework.test import APIClient -from posthog.models import ProjectSecretAPIKey, Team +from posthog.constants import AvailableFeature +from posthog.models import OrganizationMembership, 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.access_control.backend.models.access_control import AccessControl from products.messaging.backend.models.message_template import MessageTemplate from products.messaging.backend.presentation.views.message_templates import ( EMAIL_FROM_CODE_FLAG, @@ -300,6 +302,35 @@ def test_refuses_an_upload_it_does_not_allow( assert response.status_code == expected_status, response.json() assert not MessageTemplate.objects.exists() + def test_refuses_a_first_upload_under_a_key_whose_workflow_the_member_cannot_edit( + self, post: MagicMock, _flag, _capture + ): + post.return_value = MagicMock(status_code=202, json=lambda: {"invocation_id": "invocation-1"}) + code_send = { + "to": "user@example.com", + "distinct_id": "user-1", + "template_key": "welcome", + "subject": "Hi", + "html": "

Hi

", + } + sent = self.psak.post(f"/api/projects/{self.team.id}/messaging/send/email/", code_send, format="json") + assert sent.status_code == status.HTTP_202_ACCEPTED, sent.json() + key_workflow_id = post.call_args.args[0].split("/hog_flows/")[1].split("/")[0] + self.organization.available_product_features = [ + {"name": AvailableFeature.ACCESS_CONTROL, "key": AvailableFeature.ACCESS_CONTROL} + ] + self.organization.save() + self.organization_membership.level = OrganizationMembership.Level.MEMBER + self.organization_membership.save() + AccessControl.objects.create( + team=self.team, resource="hog_flow", resource_id=key_workflow_id, access_level="viewer" + ) + + response = self.client.put(self.url, WELCOME, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN, response.json() + assert not MessageTemplate.objects.exists() + @patch("posthog.rate_limit.is_rate_limit_enabled", return_value=True) def test_uploads_leave_the_send_rate_limit_to_sends(self, _rate_limit, post: MagicMock, _flag, _capture): post.return_value = MagicMock(status_code=202, json=lambda: {"invocation_id": "invocation-1"}) From e8fff1d8895e2ec92b5f32ca6eec30e90fd5f6ef Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:54:32 +0000 Subject: [PATCH 04/42] feat(messaging): lock code-managed templates and adopt templates into code A template managed by code now refuses edits in PostHog with `403 immutable` until someone releases it with `managed_by: gui`, which captures a `message template released` usage event. A template made in PostHog moves into code when someone gives it a `key`: the next upload under that key keeps its id, workflow and sender, and takes over the code sends under the key from then on. Co-Authored-By: Claude Opus 5.5 --- .../messaging/backend/facade/contracts.py | 29 +++ .../messaging/backend/facade/templates.py | 57 ++++- .../presentation/views/message_templates.py | 114 +++++++++- .../backend/services/template_keys.py | 75 +++++++ .../backend/services/template_ownership.py | 53 +++++ .../backend/services/template_saves.py | 19 ++ .../api/test_code_managed_template_lock.py | 209 ++++++++++++++++++ .../tests/api/test_template_adoption.py | 119 ++++++++++ .../backend/tests/api/test_template_upload.py | 87 ++++++++ .../frontend/generated/api.schemas.ts | 68 +++--- products/messaging/frontend/generated/api.ts | 6 +- .../messaging/frontend/generated/api.zod.ts | 16 ++ products/workflows/mcp/email_templates.yaml | 11 +- .../schema/generated-tool-definitions.json | 4 +- services/mcp/schema/tool-definitions-all.json | 4 +- services/mcp/src/api/generated.ts | 29 ++- .../mcp/src/generated/email_templates/api.ts | 9 + .../src/tools/generated/email_templates.ts | 3 + .../workflows-update-email-template.json | 5 + 19 files changed, 859 insertions(+), 58 deletions(-) create mode 100644 products/messaging/backend/services/template_keys.py create mode 100644 products/messaging/backend/services/template_ownership.py create mode 100644 products/messaging/backend/tests/api/test_code_managed_template_lock.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 7744ebe3b122..625b908e1636 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 datetime import datetime from typing import Any @@ -204,6 +205,10 @@ class TemplateManagedBy(LabeledStrEnum): CODE = "code", "Code" +class TemplateReleasedTo(LabeledStrEnum): + GUI = "gui", "GUI" + + class TemplateUploadResult(LabeledStrEnum): CREATED = "created", "Created" UPDATED = "updated", "Updated" @@ -218,6 +223,30 @@ class ContentHashState(LabeledStrEnum): OUTDATED = "outdated", "Outdated" +@frozen +class TemplateWriteIntent: + action: str + payload: Mapping[str, Any] + + +class TemplateWriteRefused(Exception): + 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 + + +class TemplateManagedByCode(TemplateWriteRefused): + def __init__(self, *, detail: str, why: str, fix: str) -> None: + super().__init__(code="immutable", detail=detail, why=why, fix=fix) + + +class TemplateKeyRefused(TemplateWriteRefused): + pass + + @frozen class TemplateSource: repository: str | None diff --git a/products/messaging/backend/facade/templates.py b/products/messaging/backend/facade/templates.py index 01773546093c..eb3190b3e1bc 100644 --- a/products/messaging/backend/facade/templates.py +++ b/products/messaging/backend/facade/templates.py @@ -10,10 +10,17 @@ from posthog.api.tagged_item import current_tag_names 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_keys, + template_ownership, template_saves, template_uploads, ) @@ -122,10 +129,13 @@ def save_template_draft( template_id: UUID, draft: dict[str, Any] | None, *, + intent: TemplateWriteIntent, require_workflow_editor: Callable[[UUID], None], ) -> MessageTemplateRow: return _to_contract( - template_saves.save_locked_draft(team_id, template_id, draft, require_workflow_editor=require_workflow_editor) + template_saves.save_locked_draft( + team_id, template_id, draft, intent=intent, require_workflow_editor=require_workflow_editor + ) ) @@ -134,6 +144,7 @@ def save_template_fields( template_id: UUID, fields: dict[str, Any], *, + intent: TemplateWriteIntent, user_id: int | None, require_workflow_editor: Callable[[UUID], None], ) -> MessageTemplateRow: @@ -149,6 +160,7 @@ def write(template: MessageTemplate) -> None: team_id, template_id, write, + intent=intent, user_id=user_id, require_workflow_editor=require_workflow_editor, ) @@ -160,6 +172,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: @@ -168,12 +181,52 @@ 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: + template_ownership.check_write(template, intent) + + +def is_release(intent: TemplateWriteIntent) -> bool: + return template_ownership.is_release(intent) + + +def release_template( + team_id: int, template_id: UUID, *, require_workflow_editor: Callable[[UUID], None] +) -> MessageTemplateRow: + return _to_contract( + template_saves.release_template(team_id, template_id, require_workflow_editor=require_workflow_editor) + ) + + +def is_adoption(intent: TemplateWriteIntent) -> bool: + return template_keys.is_adoption(intent) + + +def check_template_key_write(template: MessageTemplateRow, intent: TemplateWriteIntent) -> None: + 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: + 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 a18afb2ec84d..049c848b485a 100644 --- a/products/messaging/backend/presentation/views/message_templates.py +++ b/products/messaging/backend/presentation/views/message_templates.py @@ -19,7 +19,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 @@ -48,19 +48,30 @@ validate_design, ) from products.messaging.backend.facade.contracts import ( + TEMPLATE_KEY_MAX_LENGTH, TemplateManagedBy, + TemplateManagedByCode, + TemplateReleasedTo, TemplateSource, TemplateUpload, TemplateUploadResult, + TemplateWriteIntent, + TemplateWriteRefused, ) from products.messaging.backend.facade.templates import ( MessageCategoryNotInTeam, MessageTemplateMissing, MessageTemplateRow, UploadedTemplate, + adopt_template, + check_template_key_write, + check_template_write, create_template, get_template, + is_adoption, + is_release, list_templates, + release_template, save_template_content, save_template_draft, save_template_fields, @@ -87,6 +98,7 @@ from products.messaging.backend.presentation.views.serializers import DesignOperationSerializer from products.messaging.backend.presentation.views.template_refusals import ( TemplateErrorSerializer, + TemplateRefusal, invalid_request, invalid_template_key, ) @@ -340,14 +352,15 @@ 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. Set once, by the first upload.", + "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, read_only=True, allow_null=True, - help_text="Who owns the content. `code` when it was uploaded from a repository, and the template changes " - "through its next upload. `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, @@ -413,6 +426,23 @@ def validate(self, data: Any) -> Any: return data +class MessageTemplateUpdateSerializer(MessageTemplateSerializer): + 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 " + "updates this template and keeps its id, workflow and sender. Code sends that already use this key keep " + "their own workflow until that upload, then run through this template's workflow. Send it as the only field " + "of the PATCH. Letters, digits, hyphens (-) and underscores (_). Set once: a template that has a key keeps it.", + ) + managed_by = serializers.ChoiceField( + choices=TemplateReleasedTo.choices, + required=False, + help_text="Send `gui` as the only field to stop managing a template in code, so it can be edited in PostHog. " + "It keeps its key, so the next upload under that key manages it in code again.", + ) + + class DesignPatchSerializer(serializers.Serializer): operations = serializers.ListField( child=DesignOperationSerializer(), @@ -983,10 +1013,32 @@ def get_fields(self) -> dict[str, serializers.Field]: ) +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} +) +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=extend_schema(parameters=[TEMPLATE_ID_PARAMETER]), - partial_update=extend_schema(parameters=[TEMPLATE_ID_PARAMETER]), + update=TEMPLATE_UPDATE_SCHEMA, + partial_update=TEMPLATE_PARTIAL_UPDATE_SCHEMA, destroy=extend_schema(parameters=[TEMPLATE_ID_PARAMETER]), ) class MessageTemplatesViewSet( @@ -1033,6 +1085,25 @@ 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, TemplateWriteRefused): + managed_by_code = isinstance(exc, TemplateManagedByCode) + refusal = TemplateRefusal( + code=exc.code, detail=exc.detail, attr=None if managed_by_code else "key", why=exc.why, fix=exc.fix + ) + refused_status = status.HTTP_403_FORBIDDEN if managed_by_code else status.HTTP_400_BAD_REQUEST + return Response(TemplateErrorSerializer(refusal).data, status=refused_status) + 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 {} @@ -1072,6 +1143,18 @@ 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) + if template.managed_by == TemplateManagedBy.CODE: + self._report_user_action("message template released", {"template_id": str(released.id)}) + 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) fields = serializer.validated_data @@ -1080,6 +1163,7 @@ def update(self, request: Request, *args: Any, **kwargs: Any) -> Response: self.team_id, template.id, fields["draft_content"], + intent=intent, require_workflow_editor=self._require_workflow_editor, ) self._emit_resource_edited(updated) @@ -1097,6 +1181,7 @@ def update(self, request: Request, *args: Any, **kwargs: Any) -> Response: self.team_id, template.id, fields, + intent=intent, user_id=self._actor_id(), require_workflow_editor=self._require_workflow_editor, ) @@ -1105,6 +1190,18 @@ 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: try: report_user_action( @@ -1139,7 +1236,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: @@ -1186,6 +1285,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, ) diff --git a/products/messaging/backend/services/template_keys.py b/products/messaging/backend/services/template_keys.py new file mode 100644 index 000000000000..256ea32bd832 --- /dev/null +++ b/products/messaging/backend/services/template_keys.py @@ -0,0 +1,75 @@ +from typing import 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.models.message_template import KEY_CONSTRAINT +from products.messaging.backend.services.template_ownership import check_write, patches_only +from products.messaging.backend.services.template_saves import WorkflowEditorCheck, lock_template + + +class KeyedTemplate(Protocol): + @property + def key(self) -> str | None: ... + + +def is_adoption(intent: TemplateWriteIntent) -> bool: + return patches_only(intent, "key") + + +def check_key_unchanged(template: KeyedTemplate, intent: TemplateWriteIntent) -> None: + 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: + 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/services/template_ownership.py b/products/messaging/backend/services/template_ownership.py new file mode 100644 index 000000000000..3b4571f983bd --- /dev/null +++ b/products/messaging/backend/services/template_ownership.py @@ -0,0 +1,53 @@ +from typing import Final, Protocol + +from products.messaging.backend.facade.contracts import TemplateManagedBy, TemplateManagedByCode, TemplateWriteIntent + +CONTENT_NEUTRAL_ACTIONS: Final = frozenset({"upload", "send", "make_transactional"}) +OPERATIONAL_FIELDS: Final = ("sensitive", "tags") + + +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 patches_only(intent: TemplateWriteIntent, field: str) -> bool: + return intent.action == "partial_update" and set(intent.payload.keys()) == {field} + + +def is_release(intent: TemplateWriteIntent) -> bool: + return patches_only(intent, "managed_by") and intent.payload["managed_by"] == TemplateManagedBy.GUI + + +def check_write(template: OwnedTemplate, intent: TemplateWriteIntent) -> None: + if template.managed_by != TemplateManagedBy.CODE: + return + if ( + intent.action in CONTENT_NEUTRAL_ACTIONS + or is_release(intent) + or any(patches_only(intent, f) for f in OPERATIONAL_FIELDS) + ): + 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 a9d7516fa8cf..03641c017ffd 100644 --- a/products/messaging/backend/services/template_saves.py +++ b/products/messaging/backend/services/template_saves.py @@ -4,8 +4,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] @@ -17,11 +19,13 @@ def save_template( template_id: UUID, write: TemplateWrite, *, + intent: TemplateWriteIntent, user_id: int | None, require_workflow_editor: WorkflowEditorCheck, ) -> MessageTemplate: 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 @@ -31,10 +35,12 @@ def save_locked_draft( template_id: UUID, draft: dict[str, Any] | None, *, + intent: TemplateWriteIntent, require_workflow_editor: WorkflowEditorCheck, ) -> MessageTemplate: 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) template.draft_content = draft @@ -42,6 +48,19 @@ def save_locked_draft( return template +def release_template( + team_id: int, template_id: UUID, *, require_workflow_editor: WorkflowEditorCheck +) -> MessageTemplate: + 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..783822b8d34a --- /dev/null +++ b/products/messaging/backend/tests/api/test_code_managed_template_lock.py @@ -0,0 +1,209 @@ +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}), + ("save_draft", "patch", "", {"draft_content": {"email": {"subject": "Edited", "html": "

Edited

"}}}), + ("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"], template["draft_content"]) == ( + "Welcome", + False, + WELCOME_HASH, + None, + ) + 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_takes_the_sensitive_setting_and_tags_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") + protected = self.client.patch(self._url(uploaded["id"]), {"sensitive": True}, format="json") + tagged = self.client.patch(self._url(uploaded["id"]), {"tags": ["onboarding"]}, format="json") + + assert protected.status_code == status.HTTP_200_OK, protected.json() + workflow = self.client.get(f"/api/projects/{self.team.id}/hog_flows/{uploaded['transactional_hog_flow_id']}") + assert [action["config"].get("sensitive") for action in workflow.json()["actions"]] == [None, True, None] + assert tagged.status_code == status.HTTP_200_OK, tagged.json() + assert self._template(uploaded["id"])["tags"] == ["onboarding"] + 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) + + with patch("products.messaging.backend.presentation.views.message_templates.report_user_action") as report: + 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 [(c.args[1], c.args[2]["template_id"]) for c in report.call_args_list] == [ + ("message template released", uploaded["id"]) + ] + 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" + + @parameterized.expand( + [ + ("rename", {"name": "Renamed in PostHog"}), + ("save_draft", {"draft_content": {"email": {"subject": "Edited", "html": "

Edited

"}}}), + ] + ) + def test_an_upload_that_claims_the_template_mid_request_still_refuses_the_edit( + self, _post, _flag, _capture, _name, edit + ): + 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.client.patch(self._url(uploaded["id"]), edit, format="json") + + self._assert_refused(response) + template = self._template(uploaded["id"]) + assert (template["name"], template["draft_content"]) == ("Welcome", None) 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..ce9a8b5f41db --- /dev/null +++ b/products/messaging/backend/tests/api/test_template_adoption.py @@ -0,0 +1,119 @@ +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), + ("trailing_newline", None, {"key": "welcome\n"}, "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 fff1fc79e2e7..17636656c8f9 100644 --- a/products/messaging/backend/tests/api/test_template_upload.py +++ b/products/messaging/backend/tests/api/test_template_upload.py @@ -21,6 +21,7 @@ TransactionalEmailBurstThrottle, ) 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: dict[str, Any] = { @@ -182,6 +183,88 @@ 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_an_adopted_template_and_keeps_its_id_sender_and_paused_workflow( + self, _post, _flag, _capture + ): + other_sender = self._verified_sender("team@example.com") + made_in_posthog = MessageTemplate.objects.create( + team=self.team, + name="Welcome", + content={ + "email": { + "subject": "Hello", + "html": "

Hello

", + "from": {"email": "hello@example.com", "name": "Example team"}, + "replyTo": "replies@example.com", + } + }, + draft_content={"email": {"subject": "Hello draft", "html": "

Hello draft

"}}, + type="email", + ) + hog_flow_id = make_transactional( + 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() + + 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(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" + email = self._sent_email(str(hog_flow_id)) + assert (email["subject"], email["from"], email["replyTo"]) == ( + WELCOME["subject"], + {"integrationId": other_sender.id, "email": "hello@example.com", "name": "Example team"}, + "replies@example.com", + ) + made_in_posthog.refresh_from_db() + assert made_in_posthog.draft_content is None + + def _code_send_workflow_id(self, post: MagicMock) -> str: + sent = self.psak.post( + f"/api/projects/{self.team.id}/messaging/send/email/", + {"to": "user@example.com", "template_key": "welcome", "subject": "Hi", "html": "

Hi

"}, + format="json", + ) + assert sent.status_code == status.HTTP_202_ACCEPTED, sent.json() + return post.call_args.args[0].split("/hog_flows/")[1].split("/")[0] + + @patch("products.messaging.backend.services.transactional_email.reload_hog_flows_on_workers") + def test_code_sends_under_a_key_move_to_its_template_only_once_an_upload_claims_it( + self, _reload, post: MagicMock, _flag, _capture + ): + post.return_value = MagicMock(status_code=202, json=lambda: {"invocation_id": "invocation-1"}) + made_in_posthog = MessageTemplate.objects.create( + team=self.team, + name="Welcome", + content={"email": {"subject": "Hello", "html": "

Hello

"}}, + type="email", + ) + template_url = f"/api/projects/{self.team.id}/messaging_templates/{made_in_posthog.id}/" + + before_adoption = self._code_send_workflow_id(post) + adopted = self.client.patch(template_url, {"key": "welcome"}, format="json") + after_adoption = self._code_send_workflow_id(post) + uploaded = self._upload(WELCOME) + after_upload = self._code_send_workflow_id(post) + released = self.client.patch(template_url, {"managed_by": "gui"}, format="json") + after_release = self._code_send_workflow_id(post) + + assert adopted.status_code == status.HTTP_200_OK, adopted.json() + assert released.status_code == status.HTTP_200_OK, released.json() + template_workflow = uploaded["transactional_hog_flow_id"] + assert template_workflow != before_adoption + assert [after_adoption, after_upload, after_release] == [before_adoption, template_workflow, template_workflow] + def _adopted_template_sending_from(self, sender: Integration) -> MessageTemplate: return MessageTemplate.objects.create( team=self.team, @@ -364,6 +447,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 91c5e4e810db..85181fbe2cbf 100644 --- a/products/messaging/frontend/generated/api.schemas.ts +++ b/products/messaging/frontend/generated/api.schemas.ts @@ -810,11 +810,11 @@ export interface MessageTemplateApi { /** Unpublished edits to a transactional template's content. Sends keep using `content`. Saving `content` publishes it and clears the draft. Set null to discard the draft. */ draft_content?: MessageTemplateContentApi | null /** - * 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. + * 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 - /** Who owns the content. `code` when it was uploaded from a repository, and the template changes through its next upload. `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 */ @@ -850,7 +850,36 @@ export interface PaginatedMessageTemplateListApi { results: MessageTemplateApi[] } -export interface PatchedMessageTemplateApi { +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 +} + +/** + * * `gui` - GUI + */ +export type TemplateReleasedToEnumApi = (typeof TemplateReleasedToEnumApi)[keyof typeof TemplateReleasedToEnumApi] + +export const TemplateReleasedToEnumApi = { + Gui: 'gui', +} as const + +export interface PatchedMessageTemplateUpdateApi { readonly id?: string /** * Human-readable template name shown in the library. @@ -892,15 +921,14 @@ export interface PatchedMessageTemplateApi { /** Unpublished edits to a transactional template's content. Sends keep using `content`. Saving `content` publishes it and clears the draft. Set null to discard the draft. */ draft_content?: MessageTemplateContentApi | null /** - * 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. - * @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. Code sends that already use this key keep their own workflow until that upload, then run through this template's workflow. 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 - /** Who owns the content. `code` when it was uploaded from a repository, and the template changes through its next upload. `gui` or null when it is edited in PostHog. + key?: string + /** Send `gui` as the only field to stop managing a template in code, so it can be edited in PostHog. It keeps its key, so the next upload under that key manages it in code again. * - * * `gui` - GUI - * * `code` - Code */ - readonly managed_by?: TemplateManagedByEnumApi | null + * * `gui` - GUI */ + managed_by?: TemplateReleasedToEnumApi /** * Repository the template was last uploaded from, such as `github.com/example/app`. * @nullable @@ -1087,26 +1115,6 @@ export interface UploadTemplateResponseApi { source: TemplateSourceApi } -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 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 95621f803b8b..041612f8d153 100644 --- a/products/messaging/frontend/generated/api.ts +++ b/products/messaging/frontend/generated/api.ts @@ -32,7 +32,7 @@ import type { PaginatedOptOutsApi, PatchedDesignPatchApi, PatchedMessageCategoryApi, - PatchedMessageTemplateApi, + PatchedMessageTemplateUpdateApi, PatchedSupabaseAuthEmailSettingsUpdateApi, PreferencesLinkApi, ProviderImportRunApi, @@ -889,14 +889,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 035e47924c17..fb381da48dfb 100644 --- a/products/messaging/frontend/generated/api.zod.ts +++ b/products/messaging/frontend/generated/api.zod.ts @@ -1054,6 +1054,8 @@ export const messagingTemplatesPartialUpdateBodyTypeMax = 24 export const messagingTemplatesPartialUpdateBodyDraftContentOneTemplatingDefault = `liquid` export const messagingTemplatesPartialUpdateBodyDraftContentOneEmailOneFromOneEmailMax = 254 +export const messagingTemplatesPartialUpdateBodyKeyMax = 400 + export const MessagingTemplatesPartialUpdateBody = /* @__PURE__ */ zod.object({ name: zod .string() @@ -1295,6 +1297,20 @@ export const MessagingTemplatesPartialUpdateBody = /* @__PURE__ */ zod.object({ .describe( "Unpublished edits to a transactional template's content. Sends keep using `content`. Saving `content` publishes it and clears the draft. Set null to discard the draft." ), + 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. Code sends that already use this key keep their own workflow until that upload, then run through this template's workflow. Send it as the only field of the PATCH. Letters, digits, hyphens (-) and underscores (_). Set once: a template that has a key keeps it." + ), + managed_by: zod + .enum(['gui']) + .describe('\* `gui` - GUI') + .optional() + .describe( + 'Send `gui` as the only field to stop managing a template in code, so it can be edited in PostHog. It keeps its key, so the next upload under that key manages it in code again.\n\n\* `gui` - GUI' + ), }) export const MessagingTemplatesDesignPartialUpdateBody = /* @__PURE__ */ zod.object({ diff --git a/products/workflows/mcp/email_templates.yaml b/products/workflows/mcp/email_templates.yaml index f9f67c862a28..a6a82e4318b1 100644 --- a/products/workflows/mcp/email_templates.yaml +++ b/products/workflows/mcp/email_templates.yaml @@ -159,7 +159,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 @@ -228,10 +229,16 @@ 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. + 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. Emails your code already sends under that key with workflows-send-email + keep their own workflow until that upload, then run through this template's workflow. exclude_params: - content.email.html - draft_content + - managed_by response: exclude: - content diff --git a/services/mcp/schema/generated-tool-definitions.json b/services/mcp/schema/generated-tool-definitions.json index 97693ceef442..29cda664aa7d 100644 --- a/services/mcp/schema/generated-tool-definitions.json +++ b/services/mcp/schema/generated-tool-definitions.json @@ -17116,7 +17116,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", @@ -17331,7 +17331,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. 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. Emails your code already sends under that key with workflows-send-email keep their own workflow until that upload, then run through this template's workflow.", "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 ba67e6f8528a..38069b6b34be 100644 --- a/services/mcp/schema/tool-definitions-all.json +++ b/services/mcp/schema/tool-definitions-all.json @@ -17935,7 +17935,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", @@ -18178,7 +18178,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. 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. Emails your code already sends under that key with workflows-send-email keep their own workflow until that upload, then run through this template's workflow.", "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 70c85f265f0e..396391c2c738 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -72979,11 +72979,11 @@ export namespace Schemas { /** Unpublished edits to a transactional template's content. Sends keep using `content`. Saving `content` publishes it and clears the draft. Set null to discard the draft. */ draft_content?: MessageTemplateContent | null; /** - * 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. + * 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; - /** Who owns the content. `code` when it was uploaded from a repository, and the template changes through its next upload. `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 */ @@ -90461,7 +90461,17 @@ export namespace Schemas { deleted?: boolean; } - export interface PatchedMessageTemplate { + /** + * * `gui` - GUI + */ + export type TemplateReleasedToEnum = typeof TemplateReleasedToEnum[keyof typeof TemplateReleasedToEnum]; + + + export const TemplateReleasedToEnum = { + Gui: 'gui', + } as const; + + export interface PatchedMessageTemplateUpdate { readonly id?: string; /** * Human-readable template name shown in the library. @@ -90503,15 +90513,14 @@ export namespace Schemas { /** Unpublished edits to a transactional template's content. Sends keep using `content`. Saving `content` publishes it and clears the draft. Set null to discard the draft. */ draft_content?: MessageTemplateContent | null; /** - * 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. - * @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. Code sends that already use this key keep their own workflow until that upload, then run through this template's workflow. 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; - /** Who owns the content. `code` when it was uploaded from a repository, and the template changes through its next upload. `gui` or null when it is edited in PostHog. + key?: string; + /** Send `gui` as the only field to stop managing a template in code, so it can be edited in PostHog. It keeps its key, so the next upload under that key manages it in code again. * - * * `gui` - GUI - * * `code` - Code */ - readonly managed_by?: TemplateManagedByEnum | null; + * * `gui` - GUI */ + managed_by?: TemplateReleasedToEnum; /** * Repository the template was last uploaded from, such as `github.com/example/app`. * @nullable diff --git a/services/mcp/src/generated/email_templates/api.ts b/services/mcp/src/generated/email_templates/api.ts index c9c0288b7844..055c10fd8f33 100644 --- a/services/mcp/src/generated/email_templates/api.ts +++ b/services/mcp/src/generated/email_templates/api.ts @@ -267,6 +267,8 @@ export const messagingTemplatesPartialUpdateBodyContentOneEmailOneFromOneEmailMa export const messagingTemplatesPartialUpdateBodyTypeMax = 24 +export const messagingTemplatesPartialUpdateBodyKeyMax = 400 + export const MessagingTemplatesPartialUpdateBody = () => zod.object({ name: zod .string() @@ -394,6 +396,13 @@ export const MessagingTemplatesPartialUpdateBody = () => zod.object({ .describe( "For emails that carry login codes, reset links or other secrets. PostHog keeps no copy of each sent email's body or filled-in subject, and clicked links keep only the site address. Can change at any time." ), + 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. Code sends that already use this key keep their own workflow until that upload, then run through this template's workflow. 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 fc5aac13015e..8271677df731 100644 --- a/services/mcp/src/tools/generated/email_templates.ts +++ b/services/mcp/src/tools/generated/email_templates.ts @@ -399,6 +399,9 @@ const workflowsUpdateEmailTemplate = (): ToolBase< if (params.sensitive !== undefined) { body['sensitive'] = params.sensitive } + 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 aa998c3d50e8..5a9b550bade2 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 @@ -132,6 +132,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. Code sends that already use this key keep their own workflow until that upload, then run through this template's workflow. 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 98e10b5f7ada1b114368daaba8cbaf3b6d50358c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:54:19 +0000 Subject: [PATCH 05/42] chore(messaging): test the key workflow check when an upload adopts a template Co-Authored-By: Claude Opus 5.5 --- .../backend/tests/api/test_template_upload.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/products/messaging/backend/tests/api/test_template_upload.py b/products/messaging/backend/tests/api/test_template_upload.py index 17636656c8f9..e51a2245417b 100644 --- a/products/messaging/backend/tests/api/test_template_upload.py +++ b/products/messaging/backend/tests/api/test_template_upload.py @@ -385,8 +385,9 @@ def test_refuses_an_upload_it_does_not_allow( assert response.status_code == expected_status, response.json() assert not MessageTemplate.objects.exists() - def test_refuses_a_first_upload_under_a_key_whose_workflow_the_member_cannot_edit( - self, post: MagicMock, _flag, _capture + @parameterized.expand([("a_first_upload", False), ("an_upload_that_adopts_a_template", True)]) + def test_refuses_under_a_key_whose_workflow_the_member_cannot_edit( + self, post: MagicMock, _flag, _capture, _name, adopts_a_template ): post.return_value = MagicMock(status_code=202, json=lambda: {"invocation_id": "invocation-1"}) code_send = { @@ -399,6 +400,17 @@ def test_refuses_a_first_upload_under_a_key_whose_workflow_the_member_cannot_edi sent = self.psak.post(f"/api/projects/{self.team.id}/messaging/send/email/", code_send, format="json") assert sent.status_code == status.HTTP_202_ACCEPTED, sent.json() key_workflow_id = post.call_args.args[0].split("/hog_flows/")[1].split("/")[0] + made_in_posthog = ( + MessageTemplate.objects.create( + team=self.team, + name="Welcome", + key="welcome", + type="email", + content={"email": {"subject": "Hello", "html": "

Hello

"}}, + ) + if adopts_a_template + else None + ) self.organization.available_product_features = [ {"name": AvailableFeature.ACCESS_CONTROL, "key": AvailableFeature.ACCESS_CONTROL} ] @@ -412,7 +424,10 @@ def test_refuses_a_first_upload_under_a_key_whose_workflow_the_member_cannot_edi response = self.client.put(self.url, WELCOME, format="json") assert response.status_code == status.HTTP_403_FORBIDDEN, response.json() - assert not MessageTemplate.objects.exists() + assert [ + (template.id, template.managed_by, template.content["email"]["subject"], template.transactional_hog_flow_id) + for template in MessageTemplate.objects.all() + ] == ([(made_in_posthog.id, None, "Hello", None)] if made_in_posthog else []) @patch("posthog.rate_limit.is_rate_limit_enabled", return_value=True) def test_uploads_leave_the_send_rate_limit_to_sends(self, _rate_limit, post: MagicMock, _flag, _capture): From 3d3a661ebf57da497282979b4001998c075dac1f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:33:25 +0000 Subject: [PATCH 06/42] feat(workflows): link a file and ref on any known git host Co-Authored-By: Claude Opus 5.5 --- .../components/Git/gitMetadataParser.spec.ts | 52 +++++++++++++ .../lib/components/Git/gitMetadataParser.ts | 75 ++++++++++++++++--- 2 files changed, 118 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/components/Git/gitMetadataParser.spec.ts b/frontend/src/lib/components/Git/gitMetadataParser.spec.ts index 0ff93905ac42..5f66711f3073 100644 --- a/frontend/src/lib/components/Git/gitMetadataParser.spec.ts +++ b/frontend/src/lib/components/Git/gitMetadataParser.spec.ts @@ -18,6 +18,11 @@ describe('GitMetadataParser', () => { remote_url: 'git@gitlab.com:posthog-bot-group/posthog-bot-project.git', expected: 'https://gitlab.com/posthog-bot-group/posthog-bot-project/-/commit/commit-sha', }, + { + description: 'should create commit link for bitbucket URL', + remote_url: 'https://bitbucket.org/user/repo.git', + expected: 'https://bitbucket.org/user/repo/commits/commit-sha', + }, { description: 'should create commit link for gitlab URL', remote_url: 'invalid_url', @@ -51,6 +56,21 @@ describe('GitMetadataParser', () => { remote_url: 'git@gitlab.com:posthog-bot-group/posthog-bot-project.git', expected: 'https://gitlab.com/posthog-bot-group/posthog-bot-project/-/tree/branch-name', }, + { + description: 'should create branch link for bitbucket URL', + remote_url: 'https://bitbucket.org/user/repo.git', + expected: 'https://bitbucket.org/user/repo/src/branch-name', + }, + { + description: 'should create branch link for a gitlab project in a subgroup', + remote_url: 'gitlab.com/group/subgroup/project', + expected: 'https://gitlab.com/group/subgroup/project/-/tree/branch-name', + }, + { + description: 'should create branch link for a gitlab remote with a trailing slash', + remote_url: 'https://gitlab.com/group/project/', + expected: 'https://gitlab.com/group/project/-/tree/branch-name', + }, ])('$description', ({ remote_url, expected }) => { const result = GitMetadataParser.getBranchLink(remote_url, 'branch-name') expect(result).toBe(expected) @@ -80,4 +100,36 @@ 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' + ) + expect(GitMetadataParser.getRefLink('github.com/example/app', 'fix#12')).toBe( + 'https://github.com/example/app/tree/fix%2312' + ) + }) + }) }) diff --git a/frontend/src/lib/components/Git/gitMetadataParser.ts b/frontend/src/lib/components/Git/gitMetadataParser.ts index 6c6f198438c9..5c1382fb92dc 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 { @@ -74,10 +105,11 @@ export class GitMetadataParser { private static buildBranchLink(parsedUrl: ParsedRemoteUrl, branch: string): string | undefined { switch (parsedUrl.provider) { case 'github': - case 'bitbucket': - return `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}/tree/${branch}` + return `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}/tree/${encodePathSegments(branch)}` case 'gitlab': - return `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}/-/tree/${branch}` + return `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}/-/tree/${encodePathSegments(branch)}` + case 'bitbucket': + return `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}/src/${encodePathSegments(branch)}` default: return undefined } @@ -86,8 +118,9 @@ export class GitMetadataParser { private static buildCommitLink(parsedUrl: ParsedRemoteUrl, commitSha: string): string | undefined { switch (parsedUrl.provider) { case 'github': - case 'bitbucket': return `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}/commit/${commitSha}` + case 'bitbucket': + return `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}/commits/${commitSha}` case 'gitlab': return `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}/-/commit/${commitSha}` default: @@ -95,6 +128,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 @@ -136,23 +184,32 @@ export class GitMetadataParser { } const domain = withoutProtocol.slice(0, firstSlashIdx) - const pathParts = withoutProtocol.slice(firstSlashIdx + 1).split('/') + const pathParts = withoutProtocol + .slice(firstSlashIdx + 1) + .split('/') + .filter((part) => part !== '') if (pathParts.length < 2) { return undefined } + const [provider, providerUrl] = this.parseDomain(domain) const owner = pathParts[0] - let repository = pathParts[1] + let repository = provider === 'gitlab' ? pathParts.slice(1).join('/') : pathParts[1] if (repository.endsWith('.git')) { repository = repository.slice(0, -4) } - const [provider, providerUrl] = this.parseDomain(domain) - return { provider, owner, repository, providerUrl } } + private static parseSchemelessRemoteUrl(remoteUrl: string): ParsedRemoteUrl | undefined { + 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': From ca3de9c476b9ef812b5796534df1b2c3c394240b Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:33:42 +0000 Subject: [PATCH 07/42] feat(workflows): show code-managed email templates read-only A template uploaded from code opens as a read-only preview with its key and source. Edits refused by an upload wait in a banner that offers a duplicate, and the menu can stop managing the template in code. Co-Authored-By: Claude Opus 5.5 --- frontend/src/lib/constants.tsx | 1 + .../CodeManagedSource.stories.tsx | 55 +++ .../TemplateLibrary/CodeManagedSource.tsx | 42 +++ .../TemplateLibrary/CodeManagedTag.tsx | 25 ++ .../TemplateLibrary/EmailHtmlFrame.test.tsx | 47 +++ .../TemplateLibrary/EmailHtmlFrame.tsx | 33 ++ .../TemplateLibrary/MessageTemplate.tsx | 315 +++++++++------- .../TemplateLibrary/MessageTemplateCard.tsx | 10 +- .../MessageTemplateMenu.test.tsx | 91 +++++ .../TemplateLibrary/MessageTemplateMenu.tsx | 82 +++++ .../TemplateLibrary/MessageTemplatesTable.tsx | 4 + .../QuillMessageTemplateActions.tsx | 29 +- .../ReadOnlyTemplatePreview.stories.tsx | 23 ++ .../ReadOnlyTemplatePreview.tsx | 33 ++ .../TemplateLibrary/RefusedEditsBanner.tsx | 34 ++ .../TransactionalEmailApiExample.tsx | 24 +- .../TemplateLibrary/codeManagedTemplate.ts | 50 +++ .../messageTemplateLogic.test.ts | 345 ++++++++++++++++++ .../TemplateLibrary/messageTemplateLogic.ts | 232 ++++++++++-- .../TemplateLibrary/messageTemplatesLogic.ts | 7 +- .../TemplateLibrary/releaseTemplateDialog.ts | 17 + .../frontend/TemplateLibrary/templateCopy.ts | 9 + .../frontend/TemplateLibrary/types.ts | 5 + 23 files changed, 1336 insertions(+), 177 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/EmailHtmlFrame.test.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/MessageTemplateMenu.test.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/RefusedEditsBanner.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/codeManagedTemplate.ts create mode 100644 products/workflows/frontend/TemplateLibrary/releaseTemplateDialog.ts create mode 100644 products/workflows/frontend/TemplateLibrary/templateCopy.ts diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index f18891915b6a..9fe80d32fa06 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -623,6 +623,7 @@ export const FEATURE_FLAGS = { WORKFLOWS_DESTINATION_SUGGESTION: 'workflows-destination-suggestion', // owner: #team-workflows, offers workflows instead of a new email tool destination WORKFLOWS_EMAIL_DOMAIN_AGENT_SETUP: 'workflows-email-domain-agent-setup', // owner: @silthus #team-workflows WORKFLOWS_EMAIL_DOMAIN_WIZARD: 'workflows-email-domain-wizard', // owner: @silthus #team-workflows + WORKFLOWS_EMAIL_FROM_CODE: 'workflows-email-from-code', // owner: @silthus #team-workflows WORKFLOWS_EMAIL_SENDER_ROTATION: 'workflows-email-sender-rotation', // owner: @arthurdedeus #team-workflows WORKFLOWS_INTERNAL_EVENT_FILTERS: 'workflows-internal-event-filters', // owner: @haven #team-workflows WORKFLOWS_LINEAR_VIEW: 'workflows-linear-view', // owner: #team-workflows 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/EmailHtmlFrame.test.tsx b/products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.test.tsx new file mode 100644 index 000000000000..a1be44c16426 --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.test.tsx @@ -0,0 +1,47 @@ +import { cleanup, render } from '@testing-library/react' + +import { EmailHtmlFrame } from './EmailHtmlFrame' + +describe('EmailHtmlFrame', () => { + afterEach(() => cleanup()) + + function renderedDocument(html: string): Document { + const { container } = render() + return new DOMParser().parseFromString( + container.querySelector('iframe')?.getAttribute('srcdoc') ?? '', + 'text/html' + ) + } + + it.each([ + ['a link without a target', '

Go

'], + ['a link that targets the frame itself', '

Go

'], + [ + 'a link in a full document', + 'HiGo', + ], + ])('opens %s in a new tab instead of the preview', (_case, html) => { + const link = renderedDocument(html).querySelector('a') + + expect(link?.getAttribute('target')).toEqual('_blank') + expect(link?.textContent).toEqual('Go') + }) + + it('keeps the doctype, so the email renders in the same mode', () => { + const html = + '

Hi

' + const { container } = render() + + expect(container.querySelector('iframe')?.getAttribute('srcdoc')).toMatch( + /^/ + ) + }) + + it('lets links open a new tab but never runs scripts', () => { + const { container } = render() + const sandbox = container.querySelector('iframe')?.getAttribute('sandbox')?.split(' ') + + expect(sandbox).toEqual(expect.arrayContaining(['allow-popups', 'allow-popups-to-escape-sandbox'])) + expect(sandbox).not.toContain('allow-scripts') + }) +}) diff --git a/products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.tsx b/products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.tsx new file mode 100644 index 000000000000..9361fb86293b --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.tsx @@ -0,0 +1,33 @@ +import { useMemo } from 'react' + +import { cn } from 'lib/utils/css-classes' + +function doctypeOf(parsed: Document): string { + const doctype = parsed.doctype + if (!doctype) { + return '' + } + const publicId = doctype.publicId ? ` PUBLIC "${doctype.publicId}"` : '' + const systemId = doctype.systemId ? `${doctype.publicId ? '' : ' SYSTEM'} "${doctype.systemId}"` : '' + return `` +} + +function withLinksInNewTab(html: string): string { + const parsed = new DOMParser().parseFromString(html, 'text/html') + for (const link of parsed.querySelectorAll('a[href], area[href]')) { + link.setAttribute('target', '_blank') + } + return doctypeOf(parsed) + parsed.documentElement.outerHTML +} + +export function EmailHtmlFrame({ html, className }: { html: string; className?: string }): JSX.Element { + const previewDocument = useMemo(() => withLinksInNewTab(html), [html]) + return ( +