From b2599e3ae83da9ef9e4df242407d103767badd70 Mon Sep 17 00:00:00 2001 From: courier-codegen Date: Thu, 3 Sep 2026 21:13:54 +0000 Subject: [PATCH] docs(api): document the channel-block requirement on template creation --- api.md | 1 + src/courier/resources/journeys/templates.py | 14 ++++ .../resources/notifications/notifications.py | 20 +++++ .../resources/tenants/templates/templates.py | 20 +++++ src/courier/types/__init__.py | 1 + src/courier/types/shared/__init__.py | 1 + .../types/shared/elemental_channel_node.py | 10 ++- .../shared/elemental_node_non_channel.py | 75 ++++++++++++++++++ src/courier/types/shared_params/__init__.py | 1 + .../shared_params/elemental_channel_node.py | 10 ++- .../elemental_node_non_channel.py | 77 +++++++++++++++++++ .../api_resources/journeys/test_templates.py | 4 +- tests/api_resources/tenants/test_templates.py | 4 +- 13 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 src/courier/types/shared/elemental_node_non_channel.py create mode 100644 src/courier/types/shared_params/elemental_node_non_channel.py diff --git a/api.md b/api.md index 0adc9fa..1ef0b48 100644 --- a/api.md +++ b/api.md @@ -32,6 +32,7 @@ from courier.types import ( ElementalMetaNode, ElementalMetaNodeWithType, ElementalNode, + ElementalNodeNonChannel, ElementalQuoteNode, ElementalQuoteNodeWithType, ElementalTextNode, diff --git a/src/courier/resources/journeys/templates.py b/src/courier/resources/journeys/templates.py index b030ff6..4b480a6 100644 --- a/src/courier/resources/journeys/templates.py +++ b/src/courier/resources/journeys/templates.py @@ -83,6 +83,13 @@ def create( Defaults to `DRAFT` state; pass `state: "PUBLISHED"` to publish on create. + The content tree must contain exactly one channel block whose `channel` matches + the `channel` on the request — a journey-scoped template carries a single + channel. Top-level elements, or a block for a different channel, return `400`. + The template designer renders only the channel block matching the tab it draws, + so content stored without one cannot be opened. An empty `elements` array is + accepted. + Args: extra_headers: Send extra headers @@ -614,6 +621,13 @@ async def create( Defaults to `DRAFT` state; pass `state: "PUBLISHED"` to publish on create. + The content tree must contain exactly one channel block whose `channel` matches + the `channel` on the request — a journey-scoped template carries a single + channel. Top-level elements, or a block for a different channel, return `400`. + The template designer renders only the channel block matching the tab it draws, + so content stored without one cannot be opened. An empty `elements` array is + accepted. + Args: extra_headers: Send extra headers diff --git a/src/courier/resources/notifications/notifications.py b/src/courier/resources/notifications/notifications.py index f178e82..9b2e710 100644 --- a/src/courier/resources/notifications/notifications.py +++ b/src/courier/resources/notifications/notifications.py @@ -103,6 +103,16 @@ def create( Requires all fields in the notification object. Templates are created in draft state by default. + Content must place its elements inside a channel block — + `{ "type": "channel", "channel": "email", "elements": [...] }` — or the request + returns `400`. The template designer renders only the channel block matching the + tab it draws, so content stored without one cannot be opened. An empty + `elements` array is accepted, and the requirement applies to creation only: + `PUT /notifications/{id}` still accepts unwrapped content. Note this endpoint + takes versioned content only — the `{ title, body }` shorthand accepted by + `/send` is rejected here with an `invalid_request_error` on + `notification.content.version`. + Args: notification: Template fields accepted in POST and PUT request bodies, nested under a `notification` key. @@ -769,6 +779,16 @@ async def create( Requires all fields in the notification object. Templates are created in draft state by default. + Content must place its elements inside a channel block — + `{ "type": "channel", "channel": "email", "elements": [...] }` — or the request + returns `400`. The template designer renders only the channel block matching the + tab it draws, so content stored without one cannot be opened. An empty + `elements` array is accepted, and the requirement applies to creation only: + `PUT /notifications/{id}` still accepts unwrapped content. Note this endpoint + takes versioned content only — the `{ title, body }` shorthand accepted by + `/send` is rejected here with an `invalid_request_error` on + `notification.content.version`. + Args: notification: Template fields accepted in POST and PUT request bodies, nested under a `notification` key. diff --git a/src/courier/resources/tenants/templates/templates.py b/src/courier/resources/tenants/templates/templates.py index 2001594..d39c243 100644 --- a/src/courier/resources/tenants/templates/templates.py +++ b/src/courier/resources/tenants/templates/templates.py @@ -254,6 +254,16 @@ def replace( Creates or updates a notification template scoped to one tenant, letting a tenant override the content the workspace template would send. + This is an upsert: it creates when the tenant has no template under + `template_id`, and updates when it does. On the create half, content must place + its elements inside a channel block — + `{ "type": "channel", "channel": "email", "elements": [...] }` — or the request + returns `400`. The template designer renders only the channel block matching the + tab it draws, so content stored without one cannot be opened. An empty + `elements` array is accepted, as is the `{ title, body }` shorthand, which has + no elements to wrap. Updates are not checked, so tenant templates already stored + without a wrapper stay editable. + Args: template: Template configuration for creating or updating a tenant notification template @@ -508,6 +518,16 @@ async def replace( Creates or updates a notification template scoped to one tenant, letting a tenant override the content the workspace template would send. + This is an upsert: it creates when the tenant has no template under + `template_id`, and updates when it does. On the create half, content must place + its elements inside a channel block — + `{ "type": "channel", "channel": "email", "elements": [...] }` — or the request + returns `400`. The template designer renders only the channel block matching the + tab it draws, so content stored without one cannot be opened. An empty + `elements` array is accepted, as is the `{ title, body }` shorthand, which has + no elements to wrap. Updates are not checked, so tenant templates already stored + without a wrapper stay editable. + Args: template: Template configuration for creating or updating a tenant notification template diff --git a/src/courier/types/__init__.py b/src/courier/types/__init__.py index 66c84a8..d67d9d6 100644 --- a/src/courier/types/__init__.py +++ b/src/courier/types/__init__.py @@ -95,6 +95,7 @@ WebhookAuthentication as WebhookAuthentication, AirshipProfileAudience as AirshipProfileAudience, SendToMsTeamsChannelID as SendToMsTeamsChannelID, + ElementalNodeNonChannel as ElementalNodeNonChannel, SendToMsTeamsChannelName as SendToMsTeamsChannelName, UserProfileFirebaseToken as UserProfileFirebaseToken, ElementalHTMLNodeWithType as ElementalHTMLNodeWithType, diff --git a/src/courier/types/shared/__init__.py b/src/courier/types/shared/__init__.py index 2fabc91..38a2095 100644 --- a/src/courier/types/shared/__init__.py +++ b/src/courier/types/shared/__init__.py @@ -74,6 +74,7 @@ from .airship_profile_audience import AirshipProfileAudience as AirshipProfileAudience from .ms_teams_base_properties import MsTeamsBaseProperties as MsTeamsBaseProperties from .send_to_ms_teams_user_id import SendToMsTeamsUserID as SendToMsTeamsUserID +from .elemental_node_non_channel import ElementalNodeNonChannel as ElementalNodeNonChannel from .send_to_ms_teams_channel_id import SendToMsTeamsChannelID as SendToMsTeamsChannelID from .user_profile_firebase_token import UserProfileFirebaseToken as UserProfileFirebaseToken from .elemental_html_node_with_type import ElementalHTMLNodeWithType as ElementalHTMLNodeWithType diff --git a/src/courier/types/shared/elemental_channel_node.py b/src/courier/types/shared/elemental_channel_node.py index 439a566..b5053e3 100644 --- a/src/courier/types/shared/elemental_channel_node.py +++ b/src/courier/types/shared/elemental_channel_node.py @@ -1,8 +1,9 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Dict, Optional +from typing import Dict, List, Optional from .elemental_base_node import ElementalBaseNode +from .elemental_node_non_channel import ElementalNodeNonChannel __all__ = ["ElementalChannelNode"] @@ -18,6 +19,13 @@ class ElementalChannelNode(ElementalBaseNode): Can be `email`, `push`, `direct_message`, `sms` or a provider such as slack """ + elements: Optional[List[ElementalNodeNonChannel]] = None + """An array of elements to apply to the channel. + + If `raw` has not been specified, `elements` is `required`. Channel elements + cannot nest, so these are any node except another channel block. + """ + font_size: Optional[str] = None """Email only. diff --git a/src/courier/types/shared/elemental_node_non_channel.py b/src/courier/types/shared/elemental_node_non_channel.py new file mode 100644 index 0000000..6b20de6 --- /dev/null +++ b/src/courier/types/shared/elemental_node_non_channel.py @@ -0,0 +1,75 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Union, Optional +from typing_extensions import Literal, TypeAlias + +from .elemental_html_node import ElementalHTMLNode +from .elemental_meta_node import ElementalMetaNode +from .elemental_text_node import ElementalTextNode +from .elemental_image_node import ElementalImageNode +from .elemental_quote_node import ElementalQuoteNode +from .elemental_action_node import ElementalActionNode +from .elemental_divider_node import ElementalDividerNode + +__all__ = [ + "ElementalNodeNonChannel", + "UnionMember0", + "UnionMember1", + "UnionMember2", + "UnionMember3", + "UnionMember4", + "UnionMember5", + "UnionMember6", +] + + +class UnionMember0(ElementalTextNode): + """Represents a body of text to be rendered inside of the notification.""" + + type: Optional[Literal["text"]] = None + + +class UnionMember1(ElementalMetaNode): + """ + The meta element contains information describing the notification that may be used by a particular channel or provider. One important field is the title field which will be used as the title for channels that support it. + """ + + type: Optional[Literal["meta"]] = None + + +class UnionMember2(ElementalImageNode): + """Used to embed an image into the notification.""" + + type: Optional[Literal["image"]] = None + + +class UnionMember3(ElementalActionNode): + """Allows the user to execute an action. Can be a button or a link.""" + + type: Optional[Literal["action"]] = None + + +class UnionMember4(ElementalDividerNode): + """Renders a dividing line between elements.""" + + type: Optional[Literal["divider"]] = None + + +class UnionMember5(ElementalQuoteNode): + """Renders a quote block.""" + + type: Optional[Literal["quote"]] = None + + +class UnionMember6(ElementalHTMLNode): + """Raw HTML string inside an Elemental document. + + When rendering a message, this node is turned into output only for the email channel; for other channels it produces no blocks. + """ + + type: Optional[Literal["html"]] = None + + +ElementalNodeNonChannel: TypeAlias = Union[ + UnionMember0, UnionMember1, UnionMember2, UnionMember3, UnionMember4, UnionMember5, UnionMember6 +] diff --git a/src/courier/types/shared_params/__init__.py b/src/courier/types/shared_params/__init__.py index 2464b94..8868280 100644 --- a/src/courier/types/shared_params/__init__.py +++ b/src/courier/types/shared_params/__init__.py @@ -57,6 +57,7 @@ from .elemental_content_sugar import ElementalContentSugar as ElementalContentSugar from .message_routing_channel import MessageRoutingChannel as MessageRoutingChannel from .send_to_ms_teams_user_id import SendToMsTeamsUserID as SendToMsTeamsUserID +from .elemental_node_non_channel import ElementalNodeNonChannel as ElementalNodeNonChannel from .send_to_ms_teams_channel_id import SendToMsTeamsChannelID as SendToMsTeamsChannelID from .elemental_html_node_with_type import ElementalHTMLNodeWithType as ElementalHTMLNodeWithType from .elemental_meta_node_with_type import ElementalMetaNodeWithType as ElementalMetaNodeWithType diff --git a/src/courier/types/shared_params/elemental_channel_node.py b/src/courier/types/shared_params/elemental_channel_node.py index ec63f40..156aa9b 100644 --- a/src/courier/types/shared_params/elemental_channel_node.py +++ b/src/courier/types/shared_params/elemental_channel_node.py @@ -2,9 +2,10 @@ from __future__ import annotations -from typing import Dict, Optional +from typing import Dict, Iterable, Optional from .elemental_base_node import ElementalBaseNode +from .elemental_node_non_channel import ElementalNodeNonChannel __all__ = ["ElementalChannelNode"] @@ -20,6 +21,13 @@ class ElementalChannelNode(ElementalBaseNode, total=False): Can be `email`, `push`, `direct_message`, `sms` or a provider such as slack """ + elements: Optional[Iterable[ElementalNodeNonChannel]] + """An array of elements to apply to the channel. + + If `raw` has not been specified, `elements` is `required`. Channel elements + cannot nest, so these are any node except another channel block. + """ + font_size: Optional[str] """Email only. diff --git a/src/courier/types/shared_params/elemental_node_non_channel.py b/src/courier/types/shared_params/elemental_node_non_channel.py new file mode 100644 index 0000000..231fd01 --- /dev/null +++ b/src/courier/types/shared_params/elemental_node_non_channel.py @@ -0,0 +1,77 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from typing_extensions import Literal, TypeAlias + +from .elemental_html_node import ElementalHTMLNode +from .elemental_meta_node import ElementalMetaNode +from .elemental_text_node import ElementalTextNode +from .elemental_image_node import ElementalImageNode +from .elemental_quote_node import ElementalQuoteNode +from .elemental_action_node import ElementalActionNode +from .elemental_divider_node import ElementalDividerNode + +__all__ = [ + "ElementalNodeNonChannel", + "UnionMember0", + "UnionMember1", + "UnionMember2", + "UnionMember3", + "UnionMember4", + "UnionMember5", + "UnionMember6", +] + + +class UnionMember0(ElementalTextNode, total=False): + """Represents a body of text to be rendered inside of the notification.""" + + type: Literal["text"] + + +class UnionMember1(ElementalMetaNode, total=False): + """ + The meta element contains information describing the notification that may be used by a particular channel or provider. One important field is the title field which will be used as the title for channels that support it. + """ + + type: Literal["meta"] + + +class UnionMember2(ElementalImageNode, total=False): + """Used to embed an image into the notification.""" + + type: Literal["image"] + + +class UnionMember3(ElementalActionNode, total=False): + """Allows the user to execute an action. Can be a button or a link.""" + + type: Literal["action"] + + +class UnionMember4(ElementalDividerNode, total=False): + """Renders a dividing line between elements.""" + + type: Literal["divider"] + + +class UnionMember5(ElementalQuoteNode, total=False): + """Renders a quote block.""" + + type: Literal["quote"] + + +class UnionMember6(ElementalHTMLNode, total=False): + """Raw HTML string inside an Elemental document. + + When rendering a message, this node is turned into output only for the email channel; for other channels it produces no blocks. + """ + + type: Literal["html"] + + +ElementalNodeNonChannel: TypeAlias = Union[ + UnionMember0, UnionMember1, UnionMember2, UnionMember3, UnionMember4, UnionMember5, UnionMember6 +] diff --git a/tests/api_resources/journeys/test_templates.py b/tests/api_resources/journeys/test_templates.py index dd103a1..8ef42ce 100644 --- a/tests/api_resources/journeys/test_templates.py +++ b/tests/api_resources/journeys/test_templates.py @@ -51,7 +51,7 @@ def test_method_create_with_all_params(self, client: Courier) -> None: notification={ "brand": {"id": "id"}, "content": { - "elements": [{"type": "text"}], + "elements": [{"type": "channel"}], "version": "2022-01-01", "scope": "default", }, @@ -777,7 +777,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncCourier) - notification={ "brand": {"id": "id"}, "content": { - "elements": [{"type": "text"}], + "elements": [{"type": "channel"}], "version": "2022-01-01", "scope": "default", }, diff --git a/tests/api_resources/tenants/test_templates.py b/tests/api_resources/tenants/test_templates.py index 876b3bc..712a4e4 100644 --- a/tests/api_resources/tenants/test_templates.py +++ b/tests/api_resources/tenants/test_templates.py @@ -265,7 +265,7 @@ def test_method_replace_with_all_params(self, client: Courier) -> None: tenant_id="tenant_id", template={ "content": { - "elements": [{"type": "text"}], + "elements": [{"type": "channel"}], "version": "2022-01-01", }, "channels": { @@ -629,7 +629,7 @@ async def test_method_replace_with_all_params(self, async_client: AsyncCourier) tenant_id="tenant_id", template={ "content": { - "elements": [{"type": "text"}], + "elements": [{"type": "channel"}], "version": "2022-01-01", }, "channels": {