diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailSenderForm.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailSenderForm.tsx
new file mode 100644
index 000000000000..0cd075d75986
--- /dev/null
+++ b/frontend/src/scenes/hog-functions/email-templater/EmailSenderForm.tsx
@@ -0,0 +1,116 @@
+import { IconExternal } from '@posthog/icons'
+import { LemonButton, LemonSelect } from '@posthog/lemon-ui'
+
+import { LemonField } from 'lib/lemon-ui/LemonField'
+import { LemonInput } from 'lib/lemon-ui/LemonInput/LemonInput'
+import { urls } from 'scenes/urls'
+
+import {
+ EmailSender,
+ addressFromLocalPart,
+ addressLocalPart,
+ senderAddressError,
+ senderDomain,
+} from './emailSenderFields'
+import { EmailTemplateFrom } from './types'
+
+const ADD_SENDER = -1
+
+function senderLabel(sender: EmailSender): string {
+ const { name, email } = sender.config ?? {}
+ return name && email ? `${name} <${email}>` : (email ?? sender.display_name ?? `Sender ${sender.id}`)
+}
+
+export function EmailSenderForm({
+ from,
+ replyTo,
+ senders,
+ onChangeFrom,
+ onChangeReplyTo,
+ onDone,
+}: {
+ from: EmailTemplateFrom
+ replyTo: string | undefined
+ senders: EmailSender[]
+ onChangeFrom: (from: EmailTemplateFrom) => void
+ onChangeReplyTo: (replyTo: string) => void
+ onDone: () => void
+}): JSX.Element {
+ const sender = senders.find((candidate) => candidate.id === from.integrationId)
+ const domain = senderDomain(sender)
+ const senderEmail = sender?.config?.email
+ const localPart = addressLocalPart(from.email, domain)
+
+ const onChangeSender = (integrationId: number): void => {
+ if (integrationId === ADD_SENDER) {
+ window.open(urls.workflows('channels'), '_blank')
+ return
+ }
+ // A custom address keeps its part before the @ and moves to the new sender's domain.
+ const nextDomain = senderDomain(senders.find((candidate) => candidate.id === integrationId))
+ const email = from.email ? addressFromLocalPart(addressLocalPart(from.email, domain), nextDomain) : undefined
+ onChangeFrom({ ...from, integrationId, email })
+ }
+
+ return (
+
{compactHeader ? (
-
-
null}>
- {({ value, onChange, error }: ChildFunctionProps) => (
-
- Subject
-
-
- )}
-
- {preheaderVisible ? (
-
null}>
- {({ value, onChange }: ChildFunctionProps) => (
+ <>
+ {logicProps.type === 'native_email_transactional_template' && }
+
+
null}>
+ {({ value, onChange, error }: ChildFunctionProps) => (
-
- Preheader
-
+ Subject
- }
- onClick={() => {
- onChange('')
- hideAdvancedField('preheader')
- }}
- tooltip="Remove field"
- />
)}
- ) : offerPreheader ? (
-
}
- onClick={() => revealAdvancedField('preheader')}
- >
- Preheader
-
- ) : null}
-
setActiveContentTab(tab as 'visual' | 'plaintext')}
- options={[
- { value: 'visual', label: 'Visual' },
- { value: 'plaintext', label: 'Plain text' },
- ]}
- />
-
+ {preheaderVisible ? (
+ null}>
+ {({ value, onChange }: ChildFunctionProps) => (
+
+
+ Preheader
+
+
+ }
+ onClick={() => {
+ onChange('')
+ hideAdvancedField('preheader')
+ }}
+ tooltip="Remove field"
+ />
+
+ )}
+
+ ) : offerPreheader ? (
+ }
+ onClick={() => revealAdvancedField('preheader')}
+ >
+ Preheader
+
+ ) : null}
+ setActiveContentTab(tab as 'visual' | 'plaintext')}
+ options={[
+ { value: 'visual', label: 'Visual' },
+ { value: 'plaintext', label: 'Plain text' },
+ ]}
+ />
+
+ >
) : (
visibleFields.map((field) => {
const fieldError = fieldErrorFor(field.key, logicProps.fieldErrors)
@@ -857,6 +864,7 @@ function EmailTemplaterForm({ mode, fieldsHidden }: { mode: EmailEditorMode; fie
case 'email':
return
case 'native_email_template':
+ case 'native_email_transactional_template':
case 'native_email':
return
}
diff --git a/frontend/src/scenes/hog-functions/email-templater/emailSenderFields.test.ts b/frontend/src/scenes/hog-functions/email-templater/emailSenderFields.test.ts
new file mode 100644
index 000000000000..60319f446bf6
--- /dev/null
+++ b/frontend/src/scenes/hog-functions/email-templater/emailSenderFields.test.ts
@@ -0,0 +1,42 @@
+import { addressFromLocalPart, addressLocalPart, senderAddressError } from './emailSenderFields'
+
+describe('emailSenderFields', () => {
+ const withDomain = { id: 1, config: { email: 'hello@example.com', domain: 'example.com' } }
+ // Senders without a stored domain are checked against their own address's domain.
+ const withoutDomain = { id: 2, config: { email: 'hello@example.com' } }
+
+ test.each([
+ ['an empty address', '', withDomain],
+ ['an address on the verified domain', 'security@example.com', withDomain],
+ ['an address whose domain differs only in case', 'security@Example.COM', withDomain],
+ ['a templated address', '{{ variables.alias }}@example.org', withDomain],
+ ])('accepts %s', (_name, address, sender) => {
+ expect(senderAddressError(address, sender)).toBeNull()
+ })
+
+ test.each([
+ ['an address on another domain', 'security@example.org', withDomain, 'Use an address on example.com'],
+ ['an address off the domain of the sender address', 'security@example.org', withoutDomain, 'example.com'],
+ ['a name instead of an address', 'Security team', withDomain, 'Enter one email address'],
+ ['two addresses', 'a@example.com, b@example.com', withDomain, 'Enter one email address'],
+ ['an address too long to deliver', `${'a'.repeat(250)}@example.com`, withDomain, 'at most 254 characters'],
+ ])('rejects %s', (_name, address, sender, expected) => {
+ expect(senderAddressError(address, sender)).toContain(expected)
+ })
+
+ test.each([
+ ['adds the domain to a local part', 'security', 'security@example.com'],
+ ['keeps a templated local part on the domain', '{{ variables.team }}', '{{ variables.team }}@example.com'],
+ ['keeps an address that already has a domain', 'security@example.org', 'security@example.org'],
+ ['clears the address when the input is empty', ' ', undefined],
+ ])('%s', (_name, localPart, expected) => {
+ expect(addressFromLocalPart(localPart, 'example.com')).toEqual(expected)
+ })
+
+ test.each([
+ ['strips the verified domain', 'security@Example.com', 'security'],
+ ['shows an off-domain address whole so its error is visible', 'security@example.org', 'security@example.org'],
+ ])('%s', (_name, address, expected) => {
+ expect(addressLocalPart(address, 'example.com')).toEqual(expected)
+ })
+})
diff --git a/frontend/src/scenes/hog-functions/email-templater/emailSenderFields.ts b/frontend/src/scenes/hog-functions/email-templater/emailSenderFields.ts
new file mode 100644
index 000000000000..76bda58f77db
--- /dev/null
+++ b/frontend/src/scenes/hog-functions/email-templater/emailSenderFields.ts
@@ -0,0 +1,65 @@
+import { EmailTemplate, EmailTemplateFrom } from './types'
+
+export type EmailSender = {
+ id: number
+ display_name?: string
+ config?: { domain?: string; email?: string; name?: string; reply_to?: string }
+}
+
+export function senderFrom(email: Partial
): EmailTemplateFrom {
+ return typeof email.from === 'object' && email.from ? email.from : {}
+}
+
+export function senderIntegrationFor(
+ email: Partial,
+ integrations: T[] | null | undefined
+): T | undefined {
+ const integrationId = senderFrom(email).integrationId
+ return integrationId ? integrations?.find((integration) => integration.id === integrationId) : undefined
+}
+
+export function senderDomain(sender: EmailSender | undefined): string {
+ return (sender?.config?.domain ?? sender?.config?.email?.split('@')[1] ?? '').toLowerCase()
+}
+
+// The address input holds only the part before the @, so the verified domain can't be mistyped.
+export function addressLocalPart(address: string | undefined, domain: string): string {
+ const suffix = `@${domain}`
+ if (address && domain && address.toLowerCase().endsWith(suffix)) {
+ return address.slice(0, -suffix.length)
+ }
+ return address ?? ''
+}
+
+// A value that already has an @ was set outside this input, so it is kept whole and checked as is.
+export function addressFromLocalPart(localPart: string, domain: string): string | undefined {
+ const trimmed = localPart.trim()
+ if (!trimmed) {
+ return undefined
+ }
+ return trimmed.includes('@') || !domain ? trimmed : `${trimmed}@${domain}`
+}
+
+// Mirrors the send path, which ignores a custom address that fails these checks and sends from the
+// sender's own address instead. A templated address is only known at send time, so it isn't checked here.
+// The length bound matches the API and keeps the pattern fast.
+const SENDER_ADDRESS_MAX_LENGTH = 254
+const SENDER_ADDRESS_REGEX = /^[^\s@"<>,;]+@[^\s@"<>,;]+\.[^\s@"<>,;]+$/
+
+export function senderAddressError(address: string | undefined, sender: EmailSender | undefined): string | null {
+ const trimmed = address?.trim()
+ if (!trimmed || trimmed.includes('{{') || trimmed.includes('{%')) {
+ return null
+ }
+ if (trimmed.length > SENDER_ADDRESS_MAX_LENGTH) {
+ return `Use an address of at most ${SENDER_ADDRESS_MAX_LENGTH} characters.`
+ }
+ if (!SENDER_ADDRESS_REGEX.test(trimmed)) {
+ return 'Enter one email address, like hello@example.com.'
+ }
+ const domain = senderDomain(sender)
+ if (domain && trimmed.split('@')[1].toLowerCase() !== domain) {
+ return `Use an address on ${domain}, the domain this sender is verified for.`
+ }
+ return null
+}
diff --git a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts
index 66af8c846bc8..2dc5302a5b56 100644
--- a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts
+++ b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts
@@ -9,6 +9,8 @@ import { useMocks } from '~/mocks/jest'
import { initKeaTests } from '~/test/init'
import { Realm } from '~/types'
+import { MessageTemplate } from 'products/workflows/frontend/TemplateLibrary/types'
+
import {
EMAIL_TYPE_SUPPORTED_FIELDS,
EditorRef,
@@ -203,6 +205,37 @@ describe('emailTemplaterLogic', () => {
logic.actions.setIsModalOpen(false)
await expectLogic(logic).toMatchValues({ isTemplatePickerOpen: false })
})
+
+ it("applying a transactional template keeps the email's own sender and reply-to", async () => {
+ logic = emailTemplaterLogic(
+ makeProps({
+ value: { ...DEFAULT_EMAIL_TEMPLATE, from: { integrationId: 1 }, replyTo: 'team@example.com' },
+ })
+ )
+ logic.mount()
+
+ logic.actions.applyTemplate({
+ id: 'template-1',
+ name: 'Password reset',
+ content: {
+ templating: 'liquid',
+ email: {
+ ...DEFAULT_EMAIL_TEMPLATE,
+ subject: 'Reset your password',
+ from: { integrationId: 2, name: 'PostHog Security' },
+ replyTo: 'security@example.com',
+ },
+ },
+ } as MessageTemplate)
+
+ await expectLogic(logic).toMatchValues({
+ emailTemplate: expect.objectContaining({
+ subject: 'Reset your password',
+ from: { integrationId: 1 },
+ replyTo: 'team@example.com',
+ }),
+ })
+ })
})
describe('html-only emails', () => {
diff --git a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx
index baea7cea41b5..761790c09b16 100644
--- a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx
+++ b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx
@@ -43,8 +43,13 @@ export type UnlayerMergeTags = NonNullable['mergeTa
* email: basic email editor with free-text fields, used for configuring email platform realtime destinations
* native_email: advanced editor with email integration dropdown, and additional email metafields
* native_email_template: editor for creating reusable templates, with only subject and preheader, and email content fields
+ * native_email_transactional_template: a template sent over the API, which also sets its own sender
*/
-export type EmailTemplaterType = 'email' | 'native_email' | 'native_email_template'
+export type EmailTemplaterType =
+ | 'email'
+ | 'native_email'
+ | 'native_email_template'
+ | 'native_email_transactional_template'
export type EmailMetaFieldKey = 'from' | 'to' | 'replyTo' | 'cc' | 'bcc' | 'subject' | 'preheader'
export type EmailMetaField = {
key: EmailMetaFieldKey
@@ -100,6 +105,8 @@ export const EMAIL_TYPE_SUPPORTED_FIELDS: Record([
const finalValues: EmailTemplate = {
...formValues,
- html: ['native_email', 'native_email_template'].includes(props.type)
- ? htmlData.html
- : escapeHTMLStringCurlies(htmlData.html),
+ html: props.type !== 'email' ? htmlData.html : escapeHTMLStringCurlies(htmlData.html),
text: textData.text,
design: htmlData.design,
}
@@ -722,9 +727,7 @@ export const emailTemplaterLogic = kea([
cache.lastLoadedExternalDesign = null
props.onChange({
...values.emailTemplate,
- html: ['native_email', 'native_email_template'].includes(props.type)
- ? htmlData.html
- : escapeHTMLStringCurlies(htmlData.html),
+ html: props.type !== 'email' ? htmlData.html : escapeHTMLStringCurlies(htmlData.html),
text: textData.text,
design: htmlData.design,
})
@@ -767,7 +770,9 @@ export const emailTemplaterLogic = kea([
},
applyTemplate: ({ template }) => {
- const emailTemplateContent = template.content.email
+ // A transactional template's sender and reply-to belong to its own sends, so applying it keeps the
+ // email's current ones.
+ const { from: _from, replyTo: _replyTo, ...emailTemplateContent } = template.content.email
actions.setEmailTemplateValues(emailTemplateContent)
// Load the design into the editor if it's ready and has a design
@@ -814,9 +819,7 @@ export const emailTemplaterLogic = kea([
cache.lastLoadedExternalDesign = null
props.onChange({
...values.emailTemplate,
- html: ['native_email', 'native_email_template'].includes(props.type)
- ? htmlData.html
- : escapeHTMLStringCurlies(htmlData.html),
+ html: props.type !== 'email' ? htmlData.html : escapeHTMLStringCurlies(htmlData.html),
text: textData.text,
design: htmlData.design,
})
@@ -852,9 +855,7 @@ export const emailTemplaterLogic = kea([
emailContent = {
...currentValues,
- html: ['native_email', 'native_email_template'].includes(props.type)
- ? htmlData.html
- : escapeHTMLStringCurlies(htmlData.html),
+ html: props.type !== 'email' ? htmlData.html : escapeHTMLStringCurlies(htmlData.html),
text: textData.text,
design: htmlData.design,
}
diff --git a/products/workflows/frontend/TemplateLibrary/MessageTemplate.tsx b/products/workflows/frontend/TemplateLibrary/MessageTemplate.tsx
index 335473d96d4e..eb782423f30a 100644
--- a/products/workflows/frontend/TemplateLibrary/MessageTemplate.tsx
+++ b/products/workflows/frontend/TemplateLibrary/MessageTemplate.tsx
@@ -136,7 +136,11 @@ export function MessageTemplate(props: MessageTemplateSceneLogicProps): JSX.Elem
value={template?.content.email}
onChange={(value) => setTemplateValue('content.email', value)}
onChangeTemplating={(templating) => setTemplateValue('content.email.templating', templating)}
- type="native_email_template"
+ type={
+ template?.transactional_hog_flow_id
+ ? 'native_email_transactional_template'
+ : 'native_email_template'
+ }
layout="inline"
/>
)}
diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplateLogic.test.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateLogic.test.ts
index 82ec3c92b9cb..979b3d0caab5 100644
--- a/products/workflows/frontend/TemplateLibrary/messageTemplateLogic.test.ts
+++ b/products/workflows/frontend/TemplateLibrary/messageTemplateLogic.test.ts
@@ -430,12 +430,16 @@ describe('messageTemplateLogic', () => {
})
describe('make transactional', () => {
- it('keeps edits made while the conversion runs', async () => {
+ test.each([
+ ['an edit made while it runs', true],
+ ['no edits', false],
+ ])('shows the stored sender and keeps the form, with %s', async (_name, editWhileConverting) => {
useMocks({
post: {
'/api/projects/:team_id/messaging_templates/:id/make_transactional/': {
id: 'existing-id',
name: 'Existing',
+ content: { templating: 'liquid', email: { subject: 'Hi', from: { integrationId: 9 } } },
transactional_hog_flow_id: 'flow-id',
updated_at: '2026-01-02T00:00:00Z',
},
@@ -447,12 +451,17 @@ describe('messageTemplateLogic', () => {
await expectLogic(logic, () => {
logic.actions.makeTransactional()
- logic.actions.setTemplateValue('name', 'Renamed while converting')
+ if (editWhileConverting) {
+ logic.actions.setTemplateValue('name', 'Renamed while converting')
+ }
})
.toDispatchActions(['makeTransactionalDone'])
- .toMatchValues({ transactionalHogFlowId: 'flow-id', templateChanged: true })
+ .toMatchValues({ transactionalHogFlowId: 'flow-id', templateChanged: editWhileConverting })
- expect(logic.values.template.name).toEqual('Renamed while converting')
+ expect(logic.values.template.content.email.from).toEqual({ integrationId: 9 })
+ if (editWhileConverting) {
+ expect(logic.values.template.name).toEqual('Renamed while converting')
+ }
})
})
})
diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplateLogic.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateLogic.ts
index 3dc1a4cd444a..1814ae4caa05 100644
--- a/products/workflows/frontend/TemplateLibrary/messageTemplateLogic.ts
+++ b/products/workflows/frontend/TemplateLibrary/messageTemplateLogic.ts
@@ -9,6 +9,7 @@ import { LemonDialog } from '@posthog/lemon-ui'
import api from 'lib/api'
import { dayjs } from 'lib/dayjs'
+import { integrationsLogic } from 'lib/integrations/integrationsLogic'
import { lemonToast } from 'lib/lemon-ui/LemonToast'
import { deleteWithUndo } from 'lib/utils/deleteWithUndo'
import { objectsEqual } from 'lib/utils/objects'
@@ -23,8 +24,14 @@ import {
import type { MessageTemplateContentApi } from 'products/messaging/frontend/generated/api.schemas'
import { resourceEditedLogic } from 'products/notifications/frontend/resourceEditedLogic'
+import {
+ senderAddressError,
+ senderFrom,
+ senderIntegrationFor,
+} from '../../../../frontend/src/scenes/hog-functions/email-templater/emailSenderFields'
import type { EmailTemplate } from '../../../../frontend/src/scenes/hog-functions/email-templater/types'
import type { HogFunctionType, ResourceEditedEvent, UserBasicType } from '../../../../frontend/src/types'
+import type { IntegrationType } from '../../../../frontend/src/types'
import { workflowLogic } from '../Workflows/workflowLogic'
import { NEW_TEMPLATE } from './constants'
import { newTemplateAgentLogic } from './newTemplateAgentLogic'
@@ -52,6 +59,7 @@ export interface MessageTemplateLogicProps {
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface messageTemplateLogicValues {
+ integrations: IntegrationType[] | null // integrationsLogic
aiComposerAvailable: boolean // newTemplateAgentLogic
currentTeamIdStrict: number | string // teamLogic
deferredExternalEdit: ResourceEditedEvent | null
@@ -475,7 +483,14 @@ export const messageTemplateLogic = kea([
props({} as MessageTemplateLogicProps),
key(({ id }) => id ?? 'new'),
connect(() => ({
- values: [teamLogic, ['currentTeamIdStrict'], newTemplateAgentLogic, ['aiComposerAvailable']],
+ values: [
+ teamLogic,
+ ['currentTeamIdStrict'],
+ newTemplateAgentLogic,
+ ['aiComposerAvailable'],
+ integrationsLogic,
+ ['integrations'],
+ ],
actions: [resourceEditedLogic, ['resourceEdited']],
})),
actions({
@@ -512,7 +527,7 @@ export const messageTemplateLogic = kea([
(props: MessageTemplateLogicProps): MessageTemplateLogicProps => props,
],
}),
- forms(({ actions }) => ({
+ forms(({ actions, values }) => ({
template: {
defaults: {
...NEW_TEMPLATE,
@@ -522,6 +537,13 @@ export const messageTemplateLogic = kea([
content: {
email: {
subject: !template.content.email.subject ? 'Subject is required' : undefined,
+ from: {
+ email:
+ senderAddressError(
+ senderFrom(template.content.email).email,
+ senderIntegrationFor(template.content.email, values.integrations)
+ ) ?? undefined,
+ },
},
},
}),
@@ -864,17 +886,30 @@ export const messageTemplateLogic = kea([
props.id,
{}
)
- // Only these two fields changed. Merging them in keeps edits made while this ran.
+ // Only these fields changed. Merging them in keeps edits made while this ran.
const changes = {
transactional_hog_flow_id: updated.transactional_hog_flow_id ?? null,
updated_at: updated.updated_at,
}
- actions.setOriginalTemplate({ ...values.originalTemplate, ...changes })
+ // Conversion stores the sender it sends from on the template, so the editor shows it.
+ const from = updated.content?.email?.from as EmailTemplate['from'] | undefined
+ const withSender = (template: MessageTemplate): MessageTemplate =>
+ from
+ ? {
+ ...template,
+ ...changes,
+ content: { ...template?.content, email: { ...template?.content?.email, from } },
+ }
+ : { ...template, ...changes }
+ actions.setOriginalTemplate(withSender(values.originalTemplate))
if (values.templateChanged) {
actions.setTemplateValues(changes)
+ if (from) {
+ actions.setTemplateValue('content.email.from', from)
+ }
} else {
// Resetting moves the form's baseline too, so the form doesn't read as edited.
- actions.resetTemplate({ ...values.template, ...changes })
+ actions.resetTemplate(withSender(values.template))
}
actions.makeTransactionalDone(updated.transactional_hog_flow_id ?? null)
actions.replayDeferredExternalEdit()
@@ -927,6 +962,8 @@ export const messageTemplateLogic = kea([
lemonToast.error('Name is required')
} else if (errors?.content?.email?.subject) {
lemonToast.error('Subject is required')
+ } else if (typeof errors?.content?.email?.from === 'object' && errors.content.email.from.email) {
+ lemonToast.error(errors.content.email.from.email)
}
},
saveTemplateFailure: ({ errorObject }) => {
diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts
index 1ca725067d5a..81a4245849aa 100644
--- a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts
+++ b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts
@@ -44,6 +44,12 @@ describe('messageTemplateTestSendLogic', () => {
config: { verified: true },
},
{ id: 6, kind: 'slack', display_name: 'Slack' },
+ {
+ id: 7,
+ kind: 'email',
+ display_name: 'Security ',
+ config: { verified: true },
+ },
],
},
},
@@ -82,11 +88,16 @@ describe('messageTemplateTestSendLogic', () => {
it('defaults the sender to the first verified email integration, excluding unverified and non-email kinds', async () => {
await expectLogic(logic).toMatchValues({
senderIntegrationId: 5,
- emailIntegrations: [expect.objectContaining({ id: 5 })],
+ emailIntegrations: [expect.objectContaining({ id: 5 }), expect.objectContaining({ id: 7 })],
})
})
it('sends a one-step synthetic workflow carrying the typed recipient and template content, stripping any cc/bcc', async () => {
+ templateLogic.actions.setTemplateValue('content.email.from', {
+ integrationId: 7,
+ name: 'Security team',
+ email: 'alerts@example.com',
+ })
templateLogic.actions.setTemplateValue('content.email.cc', 'observer@example.com')
templateLogic.actions.setTemplateValue('content.email.bcc', 'archive@example.com')
logic.actions.setRecipientEmail('recipient@example.com')
@@ -103,7 +114,8 @@ describe('messageTemplateTestSendLogic', () => {
expect(emailAction.config.inputs.email.value).toMatchObject({
subject: 'Welcome',
to: { email: 'recipient@example.com', name: '' },
- from: { integrationId: 5 },
+ // The test defaults to the template's own sender and keeps its custom name and address.
+ from: { integrationId: 7, name: 'Security team', email: 'alerts@example.com' },
cc: '',
bcc: '',
})
diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts
index a7efe158ed80..dc1d9315665d 100644
--- a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts
+++ b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts
@@ -10,6 +10,10 @@ import { userLogic } from 'scenes/userLogic'
import { IntegrationType, UserType } from '~/types'
+import {
+ senderFrom,
+ senderIntegrationFor,
+} from '../../../../frontend/src/scenes/hog-functions/email-templater/emailSenderFields'
import { HogflowTestResult } from '../Workflows/hogflows/steps/types'
import { createExampleEvent } from '../Workflows/hogflows/testEventFactory'
import type { HogFlow, HogFlowAction, HogFlowEdge } from '../Workflows/hogflows/types'
@@ -94,7 +98,8 @@ export interface messageTemplateTestSendLogicMeta {
emailIntegrations: (integrations: IntegrationType[] | null) => IntegrationType[]
senderIntegrationId: (
senderIntegrationIdOverride: number | null,
- emailIntegrations: IntegrationType[]
+ emailIntegrations: IntegrationType[],
+ template: MessageTemplate
) => number | null
sendDisabledReason: (
template: MessageTemplate,
@@ -163,7 +168,12 @@ export const messageTemplateTestSendLogic = kea [s.senderIntegrationIdOverride, s.emailIntegrations],
- (override: number | null, emailIntegrations: IntegrationType[]): number | null =>
- override ?? emailIntegrations[0]?.id ?? null,
+ (s) => [s.senderIntegrationIdOverride, s.emailIntegrations, s.template],
+ (override: number | null, emailIntegrations: IntegrationType[], template: MessageTemplate): number | null =>
+ override ??
+ senderIntegrationFor(template.content.email ?? {}, emailIntegrations)?.id ??
+ emailIntegrations[0]?.id ??
+ null,
],
sendDisabledReason: [
(s) => [s.template, s.recipientEmail, s.senderIntegrationId],