diff --git a/products/messaging/backend/facade/contracts.py b/products/messaging/backend/facade/contracts.py index b487f062dc6a..43d81253f896 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, field from datetime import datetime from typing import Any @@ -321,6 +322,10 @@ class TemplateManagedBy(LabeledStrEnum): CODE = "code", "Code" +class TemplateReleasedTo(LabeledStrEnum): + GUI = "gui", "GUI" + + class TemplateUploadResult(LabeledStrEnum): CREATED = "created", "Created" UPDATED = "updated", "Updated" @@ -335,6 +340,34 @@ 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 + + +class TemplateEditedInPostHog(Exception): + code = "template_edited_in_posthog" + + @frozen class TemplateSource: repository: str | None @@ -353,3 +386,4 @@ class TemplateUpload: design: dict[str, Any] source: TemplateSource | None if_not_superseded: bool + if_code_managed: bool diff --git a/products/messaging/backend/facade/templates.py b/products/messaging/backend/facade/templates.py index 2b6c41c98b4b..da86f0a88a94 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, ) @@ -123,10 +130,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 + ) ) @@ -135,6 +145,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: @@ -150,6 +161,7 @@ def write(template: MessageTemplate) -> None: team_id, template_id, write, + intent=intent, user_id=user_id, require_workflow_editor=require_workflow_editor, ) @@ -161,6 +173,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: @@ -169,12 +182,53 @@ 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_keys.check_key_kept(template, intent) + 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 c07ecb143288..508e81e08e8a 100644 --- a/products/messaging/backend/presentation/views/message_templates.py +++ b/products/messaging/backend/presentation/views/message_templates.py @@ -20,7 +20,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 @@ -53,19 +53,31 @@ validate_design, ) from products.messaging.backend.facade.contracts import ( + TEMPLATE_KEY_MAX_LENGTH, + TemplateEditedInPostHog, 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, @@ -104,8 +116,10 @@ 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, + template_edited_in_posthog, ) from products.notifications.backend.facade.api import publish_resource_edited from products.workflows.backend.facade.api import WorkflowAccessDenied, WorkflowNotFound @@ -360,14 +374,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, @@ -433,6 +448,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(), @@ -1078,6 +1110,14 @@ class UploadTemplateSerializer(serializers.Serializer): "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_code_managed = serializers.BooleanField( + required=False, + default=False, + help_text="Set it so the upload only updates a template that code manages. When the key names a template " + "edited in PostHog, the upload changes nothing and answers `409` with `template_edited_in_posthog`. A new " + "key, or a template managed by code, uploads as usual. Leave it out to take over a template edited in " + "PostHog, so code manages it from then on.", + ) def get_fields(self) -> dict[str, serializers.Field]: return with_field_named_source_that_drf_reserves( @@ -1131,10 +1171,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( @@ -1180,6 +1242,25 @@ def read_object(self) -> MessageTemplateRow | None: except MessageTemplateMissing: return None + 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 {} @@ -1219,6 +1300,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 @@ -1227,6 +1320,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) @@ -1244,6 +1338,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, ) @@ -1252,6 +1347,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( @@ -1286,7 +1393,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: @@ -1333,6 +1442,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, ) @@ -1378,6 +1488,11 @@ def send(self, request: Any, *args: Any, **kwargs: Any) -> Response: "default sender", ), 404: OpenApiResponse(description="Sending email from code is not enabled for this project"), + 409: OpenApiResponse( + response=TemplateErrorSerializer, + description="The upload set `if_code_managed` and the template is edited in PostHog, so nothing " + "changed", + ), 429: OpenApiResponse(description="Rate limited"), }, summary="Upload a template by key", @@ -1386,8 +1501,9 @@ def send(self, request: Any, *args: Any, **kwargs: Any) -> Response: "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." + "100 different contents the template replaced changes nothing and answers `superseded`. With " + "`if_code_managed`, an upload to a template edited in PostHog changes nothing and answers `409`. An " + "upload keeps the template's sender and never turns a paused template back on." ), parameters=[TEMPLATE_KEY_PARAMETER], ) @@ -1412,6 +1528,9 @@ def upload(self, request: Request, key: str, *args: Any, **kwargs: Any) -> Respo require_workflow_editor=self._require_workflow_editor, require_workflow_create=self._require_workflow_create, ) + except TemplateEditedInPostHog as e: + self._report_template_upload(request, {"result": "rejected", "error_code": e.code}) + return Response(TemplateErrorSerializer(template_edited_in_posthog()).data, status=status.HTTP_409_CONFLICT) except TransactionalEmailError as e: self._report_template_upload(request, {"result": "rejected", "error_code": e.code}) raise TransactionalEmailRejected(e) from None @@ -1443,6 +1562,7 @@ def _template_upload(key: str, data: dict[str, Any]) -> TemplateUpload: if source else None, if_not_superseded=data["if_not_superseded"], + if_code_managed=data["if_code_managed"], ) @staticmethod diff --git a/products/messaging/backend/presentation/views/template_refusals.py b/products/messaging/backend/presentation/views/template_refusals.py index 9a54cb830238..c41019504fa1 100644 --- a/products/messaging/backend/presentation/views/template_refusals.py +++ b/products/messaging/backend/presentation/views/template_refusals.py @@ -81,3 +81,14 @@ def first_field_error(errors: Any, path: tuple[str, ...] = ()) -> FieldError: 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)) + + +def template_edited_in_posthog() -> TemplateRefusal: + return TemplateRefusal( + code="template_edited_in_posthog", + detail="This template is edited in PostHog, not managed by code, so the upload changed nothing.", + attr="if_code_managed", + why="An upload with `if_code_managed` never replaces a template that people edit in PostHog.", + fix="To manage the template in code again, upload the file without `if_code_managed`. That replaces the " + "content edited in PostHog and sets `managed_by` to `code`.", + ) diff --git a/products/messaging/backend/services/template_keys.py b/products/messaging/backend/services/template_keys.py new file mode 100644 index 000000000000..a0fd0d3264c4 --- /dev/null +++ b/products/messaging/backend/services/template_keys.py @@ -0,0 +1,87 @@ +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 +from products.messaging.backend.services.transactional_email import known_key + + +class KeyedTemplate(Protocol): + @property + def key(self) -> str | None: ... + + +def is_adoption(intent: TemplateWriteIntent) -> bool: + return patches_only(intent, "key") + + +def check_key_kept(template: KeyedTemplate, intent: TemplateWriteIntent) -> None: + if template.key and "key" in intent.payload and intent.payload["key"] != template.key: + raise _key_already_set(template.key) + + +def check_key_unchanged(template: KeyedTemplate, intent: TemplateWriteIntent) -> None: + check_key_kept(template, intent) + if "key" not in intent.payload or intent.payload["key"] == template.key: + return + 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) + _require_editor_of_the_key_workflow(team_id, key, require_workflow_editor) + 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 _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 _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/services/template_uploads.py b/products/messaging/backend/services/template_uploads.py index b2b0e72cef2f..6a1bbff58aac 100644 --- a/products/messaging/backend/services/template_uploads.py +++ b/products/messaging/backend/services/template_uploads.py @@ -8,6 +8,7 @@ from products.messaging.backend.facade.contracts import ( ContentHashState, SenderUnavailable, + TemplateEditedInPostHog, TemplateManagedBy, TemplateSource, TemplateUpload, @@ -80,6 +81,8 @@ def _upload( require_workflow_create: WorkflowCreateCheck, ) -> Uploaded: template = locked_template_with_key(team_id, upload.key) + if upload.if_code_managed and template is not None and template.managed_by != TemplateManagedBy.CODE: + raise TemplateEditedInPostHog() if template is None or template.managed_by is None: _require_the_key_workflow_can_be_taken_over(team_id, upload.key, require_workflow_editor) if template is None: 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..86ffcd6663a1 --- /dev/null +++ b/products/messaging/backend/tests/api/test_code_managed_template_lock.py @@ -0,0 +1,219 @@ +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-emails/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_a_new_key_for_a_code_managed_template_is_refused_as_a_key_change(self, _post, _flag, _capture): + uploaded = self._upload(WELCOME) + + response = self.client.patch(self._url(uploaded["id"]), {"key": "other"}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json() + assert (response.json()["code"], response.json()["attr"]) == ("key_already_set", "key") + assert self._template(uploaded["id"])["key"] == "welcome" + + 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.workflows.backend.models.hog_flow.hog_flow.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.read_object + + def read_then_upload_claims(viewset: MessageTemplatesViewSet) -> Any: + template = original(viewset) + assert template is not None + MessageTemplate.objects.filter(id=template.id).update(managed_by="code") + return template + + with patch.object(MessageTemplatesViewSet, "read_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..bc90eb5c2264 --- /dev/null +++ b/products/messaging/backend/tests/api/test_template_adoption.py @@ -0,0 +1,143 @@ +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.constants import AvailableFeature +from posthog.models import OrganizationMembership, 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.access_control.backend.models.access_control import AccessControl +from products.messaging.backend.models.message_template import MessageTemplate +from products.messaging.backend.models.transactional_email_key import TransactionalEmailKey +from products.workflows.backend.facade.models import HogFlow + +DOCS_URL = "https://posthog.com/docs/workflows/transactional-emails/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_refuses_a_key_whose_code_send_workflow_the_member_cannot_edit(self): + key_workflow = HogFlow.objects.create(team=self.team, name="welcome", status="active") + TransactionalEmailKey.objects.for_team(self.team.id).create( + team=self.team, key="welcome", hog_flow_id=key_workflow.id + ) + 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=str(key_workflow.id), access_level="viewer" + ) + + response = self._adopt("welcome") + + assert response.status_code == status.HTTP_403_FORBIDDEN, response.json() + assert self._stored_key() is None + + 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 4aa52ea4752a..b21302b1d94d 100644 --- a/products/messaging/backend/tests/api/test_template_upload.py +++ b/products/messaging/backend/tests/api/test_template_upload.py @@ -23,6 +23,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 from products.workflows.backend.facade.models import HogFlow @@ -212,6 +213,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.workflows.backend.models.hog_flow.hog_flow.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,10 +447,22 @@ def test_refuses_a_first_upload_under_a_key_whose_code_sends_are_paused( assert f"`{key_workflow_id}`" in response.json()["detail"] 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 ): key_workflow_id = self._code_send_under_the_key(post) + 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} ] @@ -381,7 +476,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): @@ -416,6 +514,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']}/", @@ -457,3 +559,61 @@ def test_an_upload_if_not_superseded_never_puts_replaced_content_back( 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 + + def _template_that_is(self, state: str) -> str | None: + if state == "new": + return None + if state == "made_in_posthog": + return str( + MessageTemplate.objects.create( + team=self.team, + name="Welcome", + key="welcome", + type="email", + content={"email": {"subject": "Hello", "html": "Hello
"}}, + ).id + ) + uploaded = self._upload(WELCOME) + if state == "edited_in_posthog": + for patch_body in ( + {"managed_by": "gui"}, + {"content": {"email": {"subject": "Edited", "html": "E
"}}}, + ): + edited = self.client.patch( + f"/api/projects/{self.team.id}/messaging_templates/{uploaded['id']}/", patch_body, format="json" + ) + assert edited.status_code == status.HTTP_200_OK, edited.json() + return uploaded["id"] + + @parameterized.expand( + [ + ("made_in_posthog", "made_in_posthog", WELCOME, status.HTTP_409_CONFLICT, None), + ("edited_in_posthog_over_superseded_content", "edited_in_posthog", WELCOME, status.HTTP_409_CONFLICT, None), + ("managed_by_code", "managed_by_code", GREETING, status.HTTP_200_OK, "updated"), + ("new_key", "new", WELCOME, status.HTTP_201_CREATED, "created"), + ] + ) + def test_an_upload_if_code_managed_refuses_a_template_edited_in_posthog( + self, _post, _flag, _capture, _name, state, body, expected_status, expected_result + ): + template_id = self._template_that_is(state) + templates_before = list(MessageTemplate.objects.values()) + workflows_before = list(HogFlow.objects.values("id", "version", "actions")) + + with patch("products.messaging.backend.presentation.views.message_templates.publish_resource_edited") as edited: + response = self.psak.put( + self.url, {**body, "if_code_managed": True, "if_not_superseded": True}, format="json" + ) + + assert response.status_code == expected_status, response.json() + if expected_result is not None: + assert response.json()["result"] == expected_result + assert response.json()["managed_by"] == "code" + assert template_id in (None, response.json()["id"]) + return + refusal = response.json() + assert (refusal["code"], refusal["attr"]) == ("template_edited_in_posthog", "if_code_managed") + assert "if_code_managed" in refusal["fix"] + assert list(MessageTemplate.objects.values()) == templates_before + assert list(HogFlow.objects.values("id", "version", "actions")) == workflows_before + edited.assert_not_called() diff --git a/products/messaging/frontend/generated/api.schemas.ts b/products/messaging/frontend/generated/api.schemas.ts index 91b37b8f4a4f..97ee8c28bb27 100644 --- a/products/messaging/frontend/generated/api.schemas.ts +++ b/products/messaging/frontend/generated/api.schemas.ts @@ -864,11 +864,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 */ @@ -904,7 +904,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. @@ -946,15 +975,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 @@ -1103,6 +1131,8 @@ export interface UploadTemplateApi { 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 + /** Set it so the upload only updates a template that code manages. When the key names a template edited in PostHog, the upload changes nothing and answers `409` with `template_edited_in_posthog`. A new key, or a template managed by code, uploads as usual. Leave it out to take over a template edited in PostHog, so code manages it from then on. */ + if_code_managed?: 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 } @@ -1150,26 +1180,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 interface TransactionalEmailLogEntryApi { /** The `message_id` that the send returned. */ message_id: string diff --git a/products/messaging/frontend/generated/api.ts b/products/messaging/frontend/generated/api.ts index 3ce09e0c3fed..3d14f0f843aa 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, @@ -919,14 +919,14 @@ export const getMessagingTemplatesPartialUpdateUrl = (projectId: string, id: str export const messagingTemplatesPartialUpdate = async ( projectId: string, id: string, - patchedMessageTemplateApi?: NonReadonlyNew 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: '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", @@ -17609,7 +17609,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 with your edited design, plus only the other writable fields you change. The tool refuses the read-only fields of a fetched template: created_at, updated_at, transactional_hog_flow_id and _posthogUrl. 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 with your edited design, plus only the other writable fields you change. The tool refuses the read-only fields of a fetched template: created_at, updated_at, transactional_hog_flow_id and _posthogUrl. 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 0f69681ca584..a1d336239744 100644 --- a/services/mcp/schema/tool-definitions-all.json +++ b/services/mcp/schema/tool-definitions-all.json @@ -18132,7 +18132,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: '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: '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", @@ -18454,7 +18454,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 with your edited design, plus only the other writable fields you change. The tool refuses the read-only fields of a fetched template: created_at, updated_at, transactional_hog_flow_id and _posthogUrl. 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 with your edited design, plus only the other writable fields you change. The tool refuses the read-only fields of a fetched template: created_at, updated_at, transactional_hog_flow_id and _posthogUrl. 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 6bed1bdf5065..d74303ac02ef 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -66814,11 +66814,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 */ @@ -84990,7 +84990,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. @@ -85032,15 +85042,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 @@ -115108,6 +115117,8 @@ export namespace Schemas { 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; + /** Set it so the upload only updates a template that code manages. When the key names a template edited in PostHog, the upload changes nothing and answers `409` with `template_edited_in_posthog`. A new key, or a template managed by code, uploads as usual. Leave it out to take over a template edited in PostHog, so code manages it from then on. */ + if_code_managed?: 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; } diff --git a/services/mcp/src/generated/email_templates/api.ts b/services/mcp/src/generated/email_templates/api.ts index 2d9d8de35598..f01cee566a3e 100644 --- a/services/mcp/src/generated/email_templates/api.ts +++ b/services/mcp/src/generated/email_templates/api.ts @@ -272,6 +272,8 @@ export const messagingTemplatesPartialUpdateBodyContentOneEmailOneFromOneEmailMa export const messagingTemplatesPartialUpdateBodyTypeMax = 24 +export const messagingTemplatesPartialUpdateBodyKeyMax = 400 + export const MessagingTemplatesPartialUpdateBody = () => zod.strictObject({ name: zod .string() @@ -399,6 +401,13 @@ export const MessagingTemplatesPartialUpdateBody = () => zod.strictObject({ .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({ @@ -495,7 +504,7 @@ export const MessagingTemplatesMakeTransactionalCreateBody = () => zod.strictObj }) /** - * 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. + * 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`. With `if_code_managed`, an upload to a template edited in PostHog changes nothing and answers `409`. 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({ @@ -514,6 +523,7 @@ export const MessagingTemplatesByKeyUpdateParams = () => zod.object({ export const messagingTemplatesByKeyUpdateBodyNameMax = 400 export const messagingTemplatesByKeyUpdateBodyIfNotSupersededDefault = false +export const messagingTemplatesByKeyUpdateBodyIfCodeManagedDefault = false export const messagingTemplatesByKeyUpdateBodySourceOneRepositoryMax = 400 export const messagingTemplatesByKeyUpdateBodySourceOnePathMax = 400 @@ -543,6 +553,12 @@ export const MessagingTemplatesByKeyUpdateBody = () => zod.strictObject({ .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.' ), + if_code_managed: zod + .boolean() + .default(messagingTemplatesByKeyUpdateBodyIfCodeManagedDefault) + .describe( + 'Set it so the upload only updates a template that code manages. When the key names a template edited in PostHog, the upload changes nothing and answers `409` with `template_edited_in_posthog`. A new key, or a template managed by code, uploads as usual. Leave it out to take over a template edited in PostHog, so code manages it from then on.' + ), source: zod .strictObject({ repository: zod diff --git a/services/mcp/src/tools/generated/email_templates.ts b/services/mcp/src/tools/generated/email_templates.ts index 271cdcfed77b..f0907d9f7084 100644 --- a/services/mcp/src/tools/generated/email_templates.ts +++ b/services/mcp/src/tools/generated/email_templates.ts @@ -403,6 +403,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