diff --git a/frontend/public/services/mailgun.png b/frontend/public/services/mailgun.png
new file mode 100644
index 0000000000000..e255b15587508
Binary files /dev/null and b/frontend/public/services/mailgun.png differ
diff --git a/frontend/src/lib/lemon-ui/LemonField/LemonField.tsx b/frontend/src/lib/lemon-ui/LemonField/LemonField.tsx
index 77e3dce5c3132..5c08c30d2fb84 100644
--- a/frontend/src/lib/lemon-ui/LemonField/LemonField.tsx
+++ b/frontend/src/lib/lemon-ui/LemonField/LemonField.tsx
@@ -16,6 +16,7 @@ export type LemonPureFieldProps = {
help?: React.ReactNode
/** Error message to be displayed */
error?: React.ReactNode
+ renderError?: (error: string) => React.ReactNode
className?: string
children?: React.ReactNode
onClick?: () => void
@@ -37,6 +38,7 @@ const LemonPureField = ({
children,
inline,
onClick,
+ renderError,
}: LemonPureFieldProps): JSX.Element => {
return (
{help}
: null}
- {error ? (
-
- {error}
-
+ {typeof error === 'string' ? (
+ renderError ? (
+ renderError(error)
+ ) : (
+
+ {error}
+
+ )
) : null}
)
@@ -83,6 +89,7 @@ export const LemonField = ({
showOptional,
inline,
info,
+ renderError,
...keaFieldProps
}: LemonFieldProps): JSX.Element => {
const template: KeaFieldProps['template'] = ({ label, kids, error }) => {
@@ -95,6 +102,7 @@ export const LemonField = ({
showOptional={showOptional}
inline={inline}
info={info}
+ renderError={renderError}
>
{kids}
diff --git a/frontend/src/lib/monaco/CodeEditor.scss b/frontend/src/lib/monaco/CodeEditor.scss
index 2fdacb435eaa4..5c7ebadabfde0 100644
--- a/frontend/src/lib/monaco/CodeEditor.scss
+++ b/frontend/src/lib/monaco/CodeEditor.scss
@@ -15,6 +15,11 @@
.CodeEditorResizeable,
.CodeEditorInline {
.monaco-editor {
+ /* stylelint-disable custom-property-pattern */
+ --vscode-textLink-foreground: transparent;
+ --vscode-focusBorder: transparent;
+ /* stylelint-enable custom-property-pattern */
+
border-radius: var(--radius);
.overflow-guard {
diff --git a/frontend/src/lib/monaco/CodeEditorInline.tsx b/frontend/src/lib/monaco/CodeEditorInline.tsx
index 1bac239f7720a..57dfa1c15f53b 100644
--- a/frontend/src/lib/monaco/CodeEditorInline.tsx
+++ b/frontend/src/lib/monaco/CodeEditorInline.tsx
@@ -1,7 +1,7 @@
-import { CodeEditorProps } from 'lib/monaco/CodeEditor'
-import { CodeEditorResizeable } from 'lib/monaco/CodeEditorResizable'
+import clsx from 'clsx'
+import { CodeEditorResizableProps, CodeEditorResizeable } from 'lib/monaco/CodeEditorResizable'
-export interface CodeEditorInlineProps extends Omit {
+export interface CodeEditorInlineProps extends Omit {
minHeight?: string
}
export function CodeEditorInline(props: CodeEditorInlineProps): JSX.Element {
@@ -9,6 +9,7 @@ export function CodeEditorInline(props: CodeEditorInlineProps): JSX.Element {
lineNumbers: 'off',
diff --git a/frontend/src/lib/monaco/CodeEditorResizable.tsx b/frontend/src/lib/monaco/CodeEditorResizable.tsx
index cb7454a4ef60c..8d686b0f97a05 100644
--- a/frontend/src/lib/monaco/CodeEditorResizable.tsx
+++ b/frontend/src/lib/monaco/CodeEditorResizable.tsx
@@ -8,6 +8,7 @@ export interface CodeEditorResizableProps extends Omit void
@@ -67,6 +68,22 @@ function JsonConfigField(props: {
)
}
+function EmailTemplateField({ schema }: { schema: HogFunctionInputSchemaType }): JSX.Element {
+ const { exampleInvocationGlobalsWithInputs, logicProps } = useValues(hogFunctionConfigurationLogic)
+
+ return (
+ <>
+
+ >
+ )
+}
+
function HogFunctionTemplateInput(props: Omit): JSX.Element {
const { exampleInvocationGlobalsWithInputs } = useValues(hogFunctionConfigurationLogic)
return
@@ -165,6 +182,8 @@ export function HogFunctionInputRenderer({ value, onChange, schema, disabled }:
return
case 'integration_field':
return
+ case 'email':
+ return
default:
return (
diff --git a/frontend/src/scenes/pipeline/hogfunctions/email-templater/EmailTemplater.tsx b/frontend/src/scenes/pipeline/hogfunctions/email-templater/EmailTemplater.tsx
new file mode 100644
index 0000000000000..2211f94c55298
--- /dev/null
+++ b/frontend/src/scenes/pipeline/hogfunctions/email-templater/EmailTemplater.tsx
@@ -0,0 +1,108 @@
+import { LemonButton, LemonLabel, LemonModal } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { Form } from 'kea-forms'
+import { LemonField } from 'lib/lemon-ui/LemonField'
+import { CodeEditorInline } from 'lib/monaco/CodeEditorInline'
+import { capitalizeFirstLetter } from 'lib/utils'
+import EmailEditor from 'react-email-editor'
+
+import { emailTemplaterLogic, EmailTemplaterLogicProps } from './emailTemplaterLogic'
+
+function EmailTemplaterForm({
+ mode,
+ ...props
+}: EmailTemplaterLogicProps & {
+ mode: 'full' | 'preview'
+}): JSX.Element {
+ const { setEmailEditorRef, emailEditorReady, setIsModalOpen } = useActions(emailTemplaterLogic(props))
+
+ return (
+
+ )
+}
+
+export function EmailTemplaterModal({ ...props }: EmailTemplaterLogicProps): JSX.Element {
+ const { isModalOpen } = useValues(emailTemplaterLogic(props))
+ const { setIsModalOpen, onSave } = useActions(emailTemplaterLogic(props))
+
+ return (
+ setIsModalOpen(false)}>
+
+
+
+
Editing email template
+
+
+
+
+
setIsModalOpen(false)}>Cancel
+
onSave()}>
+ Save
+
+
+
+
+
+ )
+}
+
+export function EmailTemplater(props: EmailTemplaterLogicProps): JSX.Element {
+ return (
+
+
+
+
+ )
+}
diff --git a/frontend/src/scenes/pipeline/hogfunctions/email-templater/emailTemplaterLogic.tsx b/frontend/src/scenes/pipeline/hogfunctions/email-templater/emailTemplaterLogic.tsx
new file mode 100644
index 0000000000000..2c4db8f978ec4
--- /dev/null
+++ b/frontend/src/scenes/pipeline/hogfunctions/email-templater/emailTemplaterLogic.tsx
@@ -0,0 +1,120 @@
+import { actions, connect, kea, listeners, LogicWrapper, path, props, reducers } from 'kea'
+import { capitalizeFirstLetter } from 'lib/utils'
+import { EditorRef as _EditorRef } from 'react-email-editor'
+
+import type { emailTemplaterLogicType } from './emailTemplaterLogicType'
+
+// Helping kea-typegen navigate the exported type
+export interface EditorRef extends _EditorRef {}
+
+export type EmailTemplate = {
+ design: any
+ html: string
+ subject: string
+ text: string
+ from: string
+ to: string
+}
+
+export interface EmailTemplaterLogicProps {
+ formLogic: LogicWrapper
+ formLogicProps: any
+ formKey: string
+ formFieldsPrefix?: string
+ globals?: Record
+}
+
+export const emailTemplaterLogic = kea([
+ props({} as EmailTemplaterLogicProps),
+ connect({
+ // values: [teamLogic, ['currentTeam'], groupsModel, ['groupTypes'], userLogic, ['hasAvailableFeature']],
+ }),
+ path(() => ['scenes', 'pipeline', 'hogfunctions', 'emailTemplaterLogic']),
+ actions({
+ onSave: true,
+ setEmailEditorRef: (emailEditorRef: EditorRef | null) => ({ emailEditorRef }),
+ emailEditorReady: true,
+ setIsModalOpen: (isModalOpen: boolean) => ({ isModalOpen }),
+ }),
+ reducers({
+ emailEditorRef: [
+ null as EditorRef | null,
+ {
+ setEmailEditorRef: (_, { emailEditorRef }) => emailEditorRef,
+ },
+ ],
+ isModalOpen: [
+ false,
+ {
+ setIsModalOpen: (_, { isModalOpen }) => isModalOpen,
+ },
+ ],
+ }),
+
+ listeners(({ props, values, actions }) => ({
+ onSave: async () => {
+ const editor = values.emailEditorRef?.editor
+ if (!editor) {
+ return
+ }
+ const data = await new Promise((res) => editor.exportHtml(res))
+
+ // TRICKY: We have to build the action we need in order to nicely callback to the form field
+ const setFormValue = props.formLogic.findMounted(props.formLogicProps)?.actions?.[
+ `set${capitalizeFirstLetter(props.formKey)}Value`
+ ]
+
+ const pathParts = props.formFieldsPrefix ? props.formFieldsPrefix.split('.') : []
+
+ setFormValue(pathParts.concat('design'), data.design)
+ setFormValue(pathParts.concat('html'), escapeHTMLStringCurlies(data.html))
+
+ // Load the logic and set the property...
+ actions.setIsModalOpen(false)
+ },
+
+ emailEditorReady: () => {
+ const pathParts = (props.formFieldsPrefix ? props.formFieldsPrefix.split('.') : []).concat('design')
+
+ let value = props.formLogic.findMounted(props.formLogicProps)?.values?.[props.formKey]
+
+ // Get the value from the form and set it in the editor
+ while (pathParts.length && value) {
+ value = value[pathParts.shift()!]
+ }
+
+ if (value) {
+ values.emailEditorRef?.editor?.loadDesign(value)
+ }
+ },
+ })),
+])
+
+function escapeHTMLStringCurlies(htmlString: string): string {
+ const parser = new DOMParser()
+ const doc = parser.parseFromString(htmlString, 'text/html')
+
+ function escapeCurlyBraces(text: string): string {
+ return text.replace(/{/g, '\\{')
+ }
+
+ function processNode(node: Node): void {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ const element = node as HTMLElement
+ if (element.tagName === 'STYLE' || element.tagName === 'SCRIPT') {
+ element.textContent = escapeCurlyBraces(element.textContent || '')
+ } else {
+ Array.from(node.childNodes).forEach(processNode)
+ }
+ } else if (node.nodeType === Node.COMMENT_NODE) {
+ const commentContent = (node as Comment).nodeValue || ''
+ ;(node as Comment).nodeValue = escapeCurlyBraces(commentContent)
+ }
+ }
+
+ processNode(doc.head)
+ processNode(doc.body)
+
+ const serializer = new XMLSerializer()
+ return serializer.serializeToString(doc)
+}
diff --git a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx
index 741304a4bb2e9..22a194e952724 100644
--- a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx
+++ b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx
@@ -39,6 +39,7 @@ import {
PropertyGroupFilter,
} from '~/types'
+import { EmailTemplate } from './email-templater/emailTemplaterLogic'
import type { hogFunctionConfigurationLogicType } from './hogFunctionConfigurationLogicType'
export interface HogFunctionConfigurationLogicProps {
@@ -291,6 +292,7 @@ export const hogFunctionConfigurationLogic = kea ({
+ logicProps: [() => [(_, props) => props], (props): HogFunctionConfigurationLogicProps => props],
hasAddon: [
(s) => [s.hasAvailableFeature],
(hasAvailableFeature) => {
@@ -359,6 +361,20 @@ export const hogFunctionConfigurationLogic = kea = {
+ html: !value.html ? 'HTML is required' : undefined,
+ subject: !value.subject ? 'Subject is required' : undefined,
+ // text: !value.text ? 'Text is required' : undefined,
+ from: !value.from ? 'From is required' : undefined,
+ to: !value.to ? 'To is required' : undefined,
+ }
+
+ if (Object.values(emailTemplateErrors).some((v) => !!v)) {
+ inputErrors[key] = { value: emailTemplateErrors } as any
+ }
+ }
})
return Object.keys(inputErrors).length > 0
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 7758a6d138b17..10bbea2b7cb36 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -4275,7 +4275,7 @@ export type OnboardingProduct = {
}
export type HogFunctionInputSchemaType = {
- type: 'string' | 'boolean' | 'dictionary' | 'choice' | 'json' | 'integration' | 'integration_field'
+ type: 'string' | 'boolean' | 'dictionary' | 'choice' | 'json' | 'integration' | 'integration_field' | 'email'
key: string
label: string
choices?: { value: string; label: string }[]
diff --git a/package.json b/package.json
index f257d70a5da80..6540eed6d43e7 100644
--- a/package.json
+++ b/package.json
@@ -160,6 +160,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-draggable": "^4.2.0",
+ "react-email-editor": "^1.7.11",
"react-grid-layout": "^1.3.0",
"react-instantsearch": "^7.6.0",
"react-intersection-observer": "^9.5.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ea51d354ce1bb..13251a7dd4733 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -301,6 +301,9 @@ dependencies:
react-draggable:
specifier: ^4.2.0
version: 4.4.5(react-dom@18.2.0)(react@18.2.0)
+ react-email-editor:
+ specifier: ^1.7.11
+ version: 1.7.11(react@18.2.0)
react-grid-layout:
specifier: ^1.3.0
version: 1.3.4(react-dom@18.2.0)(react@18.2.0)
@@ -18795,6 +18798,16 @@ packages:
react-is: 18.1.0
dev: true
+ /react-email-editor@1.7.11(react@18.2.0):
+ resolution: {integrity: sha512-AyoKKtMEPhKdqUxFX1hyJMvEO+E5fzk88uNVGHhK1ymhLpcghYLkz1+pXKLcByjR6LHkfEbrCmoYFG8zyripug==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ react: '>=15'
+ dependencies:
+ react: 18.2.0
+ unlayer-types: 1.65.0
+ dev: false
+
/react-error-boundary@3.1.4(react@18.2.0):
resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==}
engines: {node: '>=10', npm: '>=6'}
@@ -21340,6 +21353,10 @@ packages:
resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==}
engines: {node: '>= 10.0.0'}
+ /unlayer-types@1.65.0:
+ resolution: {integrity: sha512-fIeh/TtUhQ16A0oW3mHkcDekvhIbZbN+h0qVgBuVxjGnYME/Ma3saFRO4eKJll0YNyalvb9MdmSz0nyTgr/1/w==}
+ dev: false
+
/unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
diff --git a/posthog/cdp/templates/__init__.py b/posthog/cdp/templates/__init__.py
index 8e556b89bbfd4..6ec6530bd7eaf 100644
--- a/posthog/cdp/templates/__init__.py
+++ b/posthog/cdp/templates/__init__.py
@@ -13,6 +13,8 @@
template_update_contact_list as mailjet_update_contact_list,
)
+from .mailgun.template_mailgun import template_mailgun_send_email as mailgun
+
HOG_FUNCTION_TEMPLATES = [
slack,
@@ -28,6 +30,7 @@
salesforce_update,
mailjet_create_contact,
mailjet_update_contact_list,
+ mailgun,
]
diff --git a/posthog/cdp/templates/helpers.py b/posthog/cdp/templates/helpers.py
index d2a062c837690..2dd77baaabb94 100644
--- a/posthog/cdp/templates/helpers.py
+++ b/posthog/cdp/templates/helpers.py
@@ -1,4 +1,4 @@
-from typing import Any, cast
+from typing import Any, Optional, cast
from unittest.mock import MagicMock
from posthog.cdp.templates.hog_function_template import HogFunctionTemplate
from posthog.cdp.validation import compile_hog
@@ -62,7 +62,7 @@ def createHogGlobals(self, globals=None) -> dict:
return data
- def run_function(self, inputs: dict, globals=None):
+ def run_function(self, inputs: dict, globals=None, functions: Optional[dict] = None):
self.mock_fetch.reset_mock()
self.mock_print.reset_mock()
# Create the globals object
@@ -71,12 +71,17 @@ def run_function(self, inputs: dict, globals=None):
# Run the function
+ final_functions: dict = {
+ "fetch": self.mock_fetch,
+ "print": self.mock_print,
+ "postHogCapture": self.mock_posthog_capture,
+ }
+
+ if functions:
+ final_functions.update(functions)
+
return execute_bytecode(
self.compiled_hog,
globals,
- functions={
- "fetch": self.mock_fetch,
- "print": self.mock_print,
- "postHogCapture": self.mock_posthog_capture,
- },
+ functions=final_functions,
)
diff --git a/posthog/cdp/templates/mailgun/template_mailgun.py b/posthog/cdp/templates/mailgun/template_mailgun.py
new file mode 100644
index 0000000000000..d918405fce79e
--- /dev/null
+++ b/posthog/cdp/templates/mailgun/template_mailgun.py
@@ -0,0 +1,106 @@
+from posthog.cdp.templates.hog_function_template import HogFunctionTemplate
+
+
+# See https://documentation.mailgun.com/docs/mailgun/api-reference/openapi-final/tag/Messages
+
+
+template_mailgun_send_email: HogFunctionTemplate = HogFunctionTemplate(
+ status="alpha",
+ id="template-mailgun-send-email",
+ name="Send an email via Mailgun",
+ description="Send emails using the Mailgun HTTP API",
+ icon_url="/static/services/mailgun.png",
+ hog="""
+if (empty(inputs.template.to)) {
+ return false
+}
+
+fn multiPartFormEncode(data) {
+ let boundary := f'---011000010111000001101001'
+ let bodyBoundary := f'--{boundary}\\r\\n'
+ let body := bodyBoundary
+
+ for (let key, value in data) {
+ if (not empty(value)) {
+ body := f'{body}Content-Disposition: form-data; name="{key}"\\r\\n\\r\\n{value}\\r\\n{bodyBoundary}'
+ }
+ }
+
+ return {
+ 'body': body,
+ 'contentType': f'multipart/form-data; boundary={boundary}'
+ }
+}
+
+let form := multiPartFormEncode({
+ 'from': inputs.template.from,
+ 'to': inputs.template.to,
+ 'subject': inputs.template.subject,
+ 'text': inputs.template.text,
+ 'html': inputs.template.html
+})
+
+let res := fetch(f'https://{inputs.host}/v3/{inputs.domain_name}/messages', {
+ 'method': 'POST',
+ 'headers': {
+ 'Authorization': f'Basic {base64Encode(f'api:{inputs.api_key}')}',
+ 'Content-Type': form.contentType
+ },
+ 'body': form.body
+})
+
+if (res.status >= 400) {
+ print('Error from Mailgun API:', res.status, res.body)
+}
+""".strip(),
+ inputs_schema=[
+ {
+ "key": "domain_name",
+ "type": "string",
+ "label": "Mailgun Domain Name",
+ "description": "The domain name of the Mailgun account",
+ "secret": False,
+ "required": True,
+ },
+ {
+ "key": "api_key",
+ "type": "string",
+ "label": "Mailgun API Key",
+ "secret": True,
+ "required": True,
+ },
+ {
+ "key": "host",
+ "type": "choice",
+ "choices": [
+ {
+ "label": "US (api.mailgun.net)",
+ "value": "api.mailgun.net",
+ },
+ {
+ "label": "EU (api.eu.mailgun.net)",
+ "value": "api.eu.mailgun.net",
+ },
+ ],
+ "label": "Region",
+ "default": "api.eu.mailgun.net",
+ "secret": False,
+ "required": True,
+ },
+ {
+ "key": "template",
+ "type": "email",
+ "label": "Email template",
+ "default": {
+ "to": "{person.properties.email}",
+ },
+ "secret": False,
+ "required": True,
+ },
+ ],
+ filters={
+ "events": [{"id": "", "name": "", "type": "events", "order": 0}],
+ "actions": [],
+ "filter_test_accounts": True,
+ },
+)
diff --git a/posthog/cdp/templates/mailgun/test_template_mailgun.py b/posthog/cdp/templates/mailgun/test_template_mailgun.py
new file mode 100644
index 0000000000000..df9d21f11bfea
--- /dev/null
+++ b/posthog/cdp/templates/mailgun/test_template_mailgun.py
@@ -0,0 +1,74 @@
+from typing import Optional
+from inline_snapshot import snapshot
+from posthog.cdp.templates.helpers import BaseHogFunctionTemplateTest
+from posthog.cdp.templates.mailgun.template_mailgun import template_mailgun_send_email
+
+
+def create_inputs(overrides: Optional[dict] = None):
+ inputs = {
+ "domain_name": "DOMAIN_NAME",
+ "api_key": "API_KEY",
+ "host": "api.mailgun.net",
+ "template": {
+ "to": "example@posthog.com",
+ "from": "noreply@posthog.com",
+ "subject": "TEST SUBJECT",
+ "html": "Test
",
+ "text": "Test",
+ },
+ }
+ if overrides:
+ inputs.update(overrides)
+ return inputs
+
+
+class TestTemplateMailgunSendEmail(BaseHogFunctionTemplateTest):
+ template = template_mailgun_send_email
+
+ def test_function_works(self):
+ self.run_function(
+ inputs=create_inputs(), functions={"generateUUIDv4": lambda: "bcf493bf-5640-4519-817e-610dc1ba48bd"}
+ )
+
+ assert self.get_mock_fetch_calls()[0] == snapshot(
+ (
+ "https://api.mailgun.net/v3/DOMAIN_NAME/messages",
+ {
+ "method": "POST",
+ "headers": {
+ "Authorization": "Basic YXBpOkFQSV9LRVk=",
+ "Content-Type": "multipart/form-data; boundary=---011000010111000001101001",
+ },
+ "body": """\
+-----011000010111000001101001\r
+Content-Disposition: form-data; name="from"\r
+\r
+noreply@posthog.com\r
+-----011000010111000001101001\r
+Content-Disposition: form-data; name="to"\r
+\r
+example@posthog.com\r
+-----011000010111000001101001\r
+Content-Disposition: form-data; name="subject"\r
+\r
+TEST SUBJECT\r
+-----011000010111000001101001\r
+Content-Disposition: form-data; name="text"\r
+\r
+Test\r
+-----011000010111000001101001\r
+Content-Disposition: form-data; name="html"\r
+\r
+Test
\r
+-----011000010111000001101001\r
+""",
+ },
+ )
+ )
+
+ assert self.get_mock_print_calls() == []
+
+ def test_function_ignores_no_email(self):
+ self.run_function(inputs=create_inputs({"template": {"to": ""}}))
+
+ assert self.get_mock_fetch_calls() == []
diff --git a/posthog/cdp/test/test_validation.py b/posthog/cdp/test/test_validation.py
new file mode 100644
index 0000000000000..90428c9dd574a
--- /dev/null
+++ b/posthog/cdp/test/test_validation.py
@@ -0,0 +1,163 @@
+import json
+
+from inline_snapshot import snapshot
+
+from posthog.cdp.validation import validate_inputs, validate_inputs_schema
+from posthog.test.base import APIBaseTest, ClickhouseTestMixin, QueryMatchingTest
+
+
+def create_example_inputs_schema():
+ return [
+ {"key": "url", "type": "string", "label": "Webhook URL", "required": True},
+ {"key": "payload", "type": "json", "label": "JSON Payload", "required": True},
+ {
+ "key": "method",
+ "type": "choice",
+ "label": "HTTP Method",
+ "choices": [
+ {"label": "POST", "value": "POST"},
+ {"label": "PUT", "value": "PUT"},
+ {"label": "PATCH", "value": "PATCH"},
+ {"label": "GET", "value": "GET"},
+ ],
+ "required": True,
+ },
+ {"key": "headers", "type": "dictionary", "label": "Headers", "required": False},
+ ]
+
+
+def create_example_inputs():
+ return {
+ "url": {
+ "value": "http://localhost:2080/0e02d917-563f-4050-9725-aad881b69937",
+ },
+ "method": {"value": "POST"},
+ "headers": {
+ "value": {"version": "v={event.properties.$lib_version}"},
+ },
+ "payload": {
+ "value": {
+ "event": "{event}",
+ "groups": "{groups}",
+ "nested": {"foo": "{event.url}"},
+ "person": "{person}",
+ "event_url": "{f'{event.url}-test'}",
+ },
+ },
+ }
+
+
+class TestHogFunctionValidation(ClickhouseTestMixin, APIBaseTest, QueryMatchingTest):
+ def test_validate_inputs_schema(self):
+ inputs_schema = create_example_inputs_schema()
+ assert validate_inputs_schema(inputs_schema) == snapshot(
+ [
+ {"type": "string", "key": "url", "label": "Webhook URL", "required": True, "secret": False},
+ {"type": "json", "key": "payload", "label": "JSON Payload", "required": True, "secret": False},
+ {
+ "type": "choice",
+ "key": "method",
+ "label": "HTTP Method",
+ "choices": [
+ {"label": "POST", "value": "POST"},
+ {"label": "PUT", "value": "PUT"},
+ {"label": "PATCH", "value": "PATCH"},
+ {"label": "GET", "value": "GET"},
+ ],
+ "required": True,
+ "secret": False,
+ },
+ {"type": "dictionary", "key": "headers", "label": "Headers", "required": False, "secret": False},
+ ]
+ )
+
+ def test_validate_inputs(self):
+ inputs_schema = create_example_inputs_schema()
+ inputs = create_example_inputs()
+ assert json.loads(json.dumps(validate_inputs(inputs_schema, inputs))) == snapshot(
+ {
+ "url": {
+ "value": "http://localhost:2080/0e02d917-563f-4050-9725-aad881b69937",
+ "bytecode": ["_h", 32, "http://localhost:2080/0e02d917-563f-4050-9725-aad881b69937"],
+ },
+ "payload": {
+ "value": {
+ "event": "{event}",
+ "groups": "{groups}",
+ "nested": {"foo": "{event.url}"},
+ "person": "{person}",
+ "event_url": "{f'{event.url}-test'}",
+ },
+ "bytecode": {
+ "event": ["_h", 32, "event", 1, 1],
+ "groups": ["_h", 32, "groups", 1, 1],
+ "nested": {"foo": ["_h", 32, "url", 32, "event", 1, 2]},
+ "person": ["_h", 32, "person", 1, 1],
+ "event_url": ["_h", 32, "-test", 32, "url", 32, "event", 1, 2, 2, "concat", 2],
+ },
+ },
+ "method": {"value": "POST"},
+ "headers": {
+ "value": {"version": "v={event.properties.$lib_version}"},
+ "bytecode": {
+ "version": [
+ "_h",
+ 32,
+ "$lib_version",
+ 32,
+ "properties",
+ 32,
+ "event",
+ 1,
+ 3,
+ 32,
+ "v=",
+ 2,
+ "concat",
+ 2,
+ ]
+ },
+ },
+ }
+ )
+
+ def test_validate_inputs_creates_bytecode_for_html(self):
+ # NOTE: CSS block curly brackets must be escaped beforehand
+ html_with_css = '\n\n\n\n\n\n Hi {person.properties.email}
\n\n'
+
+ assert json.loads(
+ json.dumps(
+ validate_inputs(
+ [
+ {"key": "html", "type": "string", "label": "HTML", "required": True},
+ ],
+ {
+ "html": {"value": html_with_css},
+ },
+ )
+ )
+ ) == snapshot(
+ {
+ "html": {
+ "bytecode": [
+ "_h",
+ 32,
+ "\n