Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions products/messaging/backend/facade/transactional_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@

from products.messaging.backend.facade.contracts import DeliveryUnavailable, QueuedEmail, TransactionalEmailError
from products.messaging.backend.services.transactional_email import (
SENDER_ADDRESS_MAX_LENGTH,
check_transactional_update,
lock_transactional_workflow_id,
make_transactional,
send_transactional_email,
sender_address_error,
sync_transactional_workflow,
)

__all__ = [
"SENDER_ADDRESS_MAX_LENGTH",
"DeliveryUnavailable",
"QueuedEmail",
"TransactionalEmailError",
"check_transactional_update",
"lock_transactional_workflow_id",
"make_transactional",
"send_transactional_email",
"sender_address_error",
"sync_transactional_workflow",
]
72 changes: 72 additions & 0 deletions products/messaging/backend/presentation/views/message_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from posthog.auth import ProjectSecretAPIKeyAuthentication
from posthog.event_usage import groups, report_user_action
from posthog.models import User
from posthog.models.integration import Integration
from posthog.models.tag import tagify
from posthog.permissions import is_authenticated_via_project_secret_api_key
from posthog.rate_limit import PersonalOrProjectSecretApiKeyRateThrottle, ProjectSecretApiKeyTeamRateThrottle
Expand All @@ -54,12 +55,14 @@
update_template,
)
from products.messaging.backend.facade.transactional_email import (
SENDER_ADDRESS_MAX_LENGTH,
DeliveryUnavailable,
TransactionalEmailError,
check_transactional_update,
lock_transactional_workflow_id,
make_transactional,
send_transactional_email,
sender_address_error,
sync_transactional_workflow,
)
from products.messaging.backend.presentation.views.serializers import DesignOperationSerializer
Expand Down Expand Up @@ -108,6 +111,51 @@ class UnlayerDesignField(serializers.JSONField):
pass


class EmailTemplateSenderSerializer(serializers.Serializer):
email = serializers.CharField(
required=False,
allow_blank=True,
max_length=SENDER_ADDRESS_MAX_LENGTH,
help_text="Address to send from instead of the sender's own. Must be on the sender's verified domain. "
"Supports Liquid templating. Leave out or empty to use the sender's address.",
)
name = serializers.CharField(
required=False,
allow_blank=True,
help_text="Sender name shown in the inbox instead of the sender's own. Supports Liquid templating. "
"Leave out or empty to use the sender's name.",
)

def get_fields(self) -> dict[str, serializers.Field]:
fields = super().get_fields()
# Keys match the workflow email step, which a transactional template's sender is copied into.
fields["integrationId"] = serializers.IntegerField(
help_text="ID of the project's email sender integration that sends this email. Transactional sends "
"need a verified sender."
)
return fields

def validate(self, data: dict[str, Any]) -> dict[str, Any]:
# A partial update doesn't enforce required nested fields, so the sender is checked here.
integration_id = data.get("integrationId")
if integration_id is None:
raise serializers.ValidationError({"integrationId": "Choose an email sender."})
if not Integration.objects.filter(team_id=self.context["team_id"], kind="email", id=integration_id).exists():
raise serializers.ValidationError({"integrationId": "No email sender with this ID in this project."})
return data


def _without_legacy_sender(email: Any, *, reading: bool) -> Any:
# Only a transactional template stores a sender object. A blank template, a template saved before senders
# existed, and one saved from a destination's email editor hold a string or an empty object instead.
if not isinstance(email, dict) or "from" not in email:
return email
sender = email["from"]
# A write that names a sender but no integration is rejected later, so only reads drop it.
is_legacy = not isinstance(sender, dict) or not (sender.get("integrationId") if reading else any(sender.values()))
return {key: value for key, value in email.items() if key != "from"} if is_legacy else email


class EmailTemplateSerializer(serializers.Serializer):
subject = serializers.CharField(
required=False,
Expand Down Expand Up @@ -138,6 +186,27 @@ class EmailTemplateSerializer(serializers.Serializer):
"Full schema in the designing-email-templates skill.",
)

def to_internal_value(self, data: Any) -> Any:
return super().to_internal_value(_without_legacy_sender(data, reading=False))

def to_representation(self, instance: Any) -> Any:
return super().to_representation(_without_legacy_sender(instance, reading=True))

def get_fields(self) -> dict[str, serializers.Field]:
fields = super().get_fields()
fields["from"] = EmailTemplateSenderSerializer(
required=False,
help_text="The sender a transactional template sends from. A template made transactional without "
"one gets the sender it was made transactional with.",
)
fields["replyTo"] = serializers.CharField(
required=False,
allow_blank=True,
help_text="Reply-to address for a transactional template. Separate several addresses with commas. "
"Supports Liquid templating. Leave empty to use the sender's default reply-to.",
)
return fields


class MessageTemplateContentSerializer(serializers.Serializer):
templating = serializers.ChoiceField(
Expand Down Expand Up @@ -255,6 +324,9 @@ def validate(self, data: Any) -> Any:
raise serializers.ValidationError(
{"content": {"email": {"subject": "Subject is required for email templates."}}}
)
# Checked on publish only, so a draft still saves while someone types the address.
if email and email.get("from") and (error := sender_address_error(self.context["team_id"], email["from"])):
raise serializers.ValidationError({"content": {"email": {"from": {"email": error}}}})
# Programmatically authored templates often supply html without a design, which the
# visual editor can't open. Wrap the html in a single custom HTML block; the stored
# html stays untouched, so nothing about the sent email changes.
Expand Down
64 changes: 59 additions & 5 deletions products/messaging/backend/services/transactional_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import structlog

from posthog.models.integration import Integration
from posthog.models.team import Team
from posthog.plugins.plugin_server_api import (
reload_hog_flows_on_workers,
Expand Down Expand Up @@ -37,6 +38,7 @@
TransactionalEmailContentInvalid,
TransactionalEmailSenderUnavailable,
create_transactional_email_workflow,
resolve_transactional_email_sender,
update_transactional_email_workflow,
)

Expand All @@ -62,18 +64,60 @@ def check_sendable(team_id: int, *, template_type: str, content: dict, message_c
)


def _content(template: MessageTemplate, from_integration_id: int | None) -> TransactionalEmailContent:
# An address longer than this can't be delivered, and the bound keeps the pattern below fast.
SENDER_ADDRESS_MAX_LENGTH = 254
# Matches the send path, which ignores a custom address that fails these checks and sends from the sender's own.
SENDER_ADDRESS_PATTERN = re.compile(r'^[^\s@"<>,;]+@[^\s@"<>,;]+\.[^\s@"<>,;]+$')


def sender_address_error(team_id: int, sender: dict[str, Any]) -> str | None:
"""Why a custom sender address can't send, or None. A templated address is only known at send time."""
address = (sender.get("email") or "").strip()
if not address or "{{" in address or "{%" in address:
return None
if len(address) > SENDER_ADDRESS_MAX_LENGTH:
return f"Use an address of at most {SENDER_ADDRESS_MAX_LENGTH} characters."
if not SENDER_ADDRESS_PATTERN.match(address):
return "Enter one email address, like hello@example.com."
integration_id = sender.get("integrationId")
# Without a sender there is no domain to check against. The sender field rejects that case on its own.
if integration_id is None:
return None
config = (
Integration.objects.filter(team_id=team_id, kind="email", id=integration_id)
.values_list("config", flat=True)
.first()
or {}
)
domain = (config.get("domain") or (config.get("email") or "").partition("@")[2]).lower()
if domain and address.partition("@")[2].lower() != domain:
return f"Use an address on {domain}, the domain this sender is verified for."
return None


def _template_sender(email: dict[str, Any]) -> dict[str, Any]:
# Templates saved before senders existed hold an empty string here.
sender = email.get("from")
return sender if isinstance(sender, dict) else {}


def _content(template: MessageTemplate) -> TransactionalEmailContent:
email = template.content["email"]
sender = _template_sender(email)
return TransactionalEmailContent(
message_template_id=template.id,
name=template.name,
subject=email["subject"],
preheader=email.get("preheader") or "",
html=email.get("html") or "",
text=email.get("text") or "",
from_integration_id=from_integration_id,
# Templates made transactional before they stored a sender keep their workflow's sender.
from_integration_id=sender.get("integrationId"),
message_category_id=template.message_category_id,
sensitive=template.sensitive,
from_email=sender.get("email") or "",
from_name=sender.get("name") or "",
reply_to=email.get("replyTo") or "",
)


Expand All @@ -93,16 +137,26 @@ def make_transactional(
content=template.content,
message_category_id=template.message_category_id,
)
email = template.content["email"]
sender = _template_sender(email)
try:
# The template keeps the sender it is made transactional with, so the editor shows it and later
# saves send from it.
sender_id = resolve_transactional_email_sender(team_id, from_integration_id or sender.get("integrationId"))
sender = {**sender, "integrationId": sender_id}
# A custom address set for one sender can't send from a sender on another domain.
if address_error := sender_address_error(team_id, sender):
raise TemplateNotSendable(address_error)
template.content = {**template.content, "email": {**email, "from": sender}}
hog_flow_id = create_transactional_email_workflow(
team_id=team_id, user_id=user_id, content=_content(template, from_integration_id)
team_id=team_id, user_id=user_id, content=_content(template)
)
except TransactionalEmailSenderUnavailable as e:
raise SenderUnavailable(
"No verified email sender. Verify a sending domain or pass from_integration_id."
) from e
template.transactional_hog_flow_id = hog_flow_id
template.save(update_fields=["transactional_hog_flow_id", "updated_at"])
template.save(update_fields=["content", "transactional_hog_flow_id", "updated_at"])
return hog_flow_id


Expand Down Expand Up @@ -166,7 +220,7 @@ def sync_transactional_workflow(team_id: int, template_id: UUID, *, user_id: int
team_id=team_id,
user_id=user_id,
hog_flow_id=hog_flow_id,
content=_content(template, from_integration_id=None),
content=_content(template),
)
# The save already told the workers to reload, but before the caller's transaction committed, so a
# worker can cache the old version. This reload runs once the new version is visible.
Expand Down
29 changes: 26 additions & 3 deletions products/messaging/backend/tests/api/test_message_templates.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import UTC, datetime
from typing import Any
from uuid import UUID

from posthog.test.base import APIBaseTest
Expand Down Expand Up @@ -130,7 +131,20 @@ def test_tags_only_update_leaves_the_template_row_alone(self):
# A row save here would write back every column this request read, losing a concurrent content edit.
assert MessageTemplate.objects.get(pk=self.message_template.id).updated_at == before

def test_retrieve_message_template(self):
@parameterized.expand(
[
("no_sender", None),
("blank_sender", ""),
("destination_sender", "Hedgebox <hello@example.com>"),
("sender_without_an_id", {"name": "Hedgebox"}),
]
)
def test_retrieve_message_template(self, _name: str, stored_sender: Any) -> None:
if stored_sender is not None:
email = {**self.message_template.content["email"], "from": stored_sender}
MessageTemplate.objects.filter(id=self.message_template.id).update(content={"email": email})
listed = self.client.get(f"/api/environments/{self.team.id}/messaging_templates/")
assert listed.status_code == status.HTTP_200_OK
response = self.client.get(f"/api/environments/{self.team.id}/messaging_templates/{self.message_template.id}/")
assert response.status_code == status.HTTP_200_OK

Expand Down Expand Up @@ -184,13 +198,21 @@ def test_email_content_without_subject_fails(self, _name, method, detail, body):

assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json()

@parameterized.expand(
[
# The editor's blank template carries an empty sender.
("blank_template", ""),
# A destination's "Save as new template" carries its sender string.
("destination_sender", "Hedgebox <hello@example.com>"),
]
)
@patch("products.messaging.backend.presentation.views.message_templates.report_user_action")
def test_create_email_template_with_subject_succeeds(self, mock_report):
def test_create_email_template_with_subject_succeeds(self, _name: str, sender: str, mock_report: Any) -> None:
response = self.client.post(
f"/api/environments/{self.team.id}/messaging_templates/",
data={
"name": "Valid Template",
"content": {"email": {"subject": "Hello", "html": "<p>Hello</p>"}},
"content": {"email": {"subject": "Hello", "html": "<p>Hello</p>", "from": sender}},
"type": "email",
"sensitive": True,
},
Expand All @@ -201,6 +223,7 @@ def test_create_email_template_with_subject_succeeds(self, mock_report):
assert response.json()["sensitive"] is True
assert MessageTemplate.objects.get(id=response.json()["id"]).sensitive is True
assert response.json()["content"]["email"]["subject"] == "Hello"
assert "from" not in response.json()["content"]["email"]
# The AI-first experiment counts creations from the editor and the agent with this one event.
mock_report.assert_called_once()
assert mock_report.call_args.args[1] == "message_template_created"
Expand Down
Loading
Loading