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 ( +
+ + ({ + value: candidate.id, + label: senderLabel(candidate), + })), + }, + { options: [{ value: ADD_SENDER, label: 'Add new email sender', icon: }] }, + ]} + data-attr="transactional-email-sender-select" + /> + + + onChangeFrom({ ...from, name: name || undefined })} + data-attr="transactional-email-sender-name" + /> + + + {`@${domain}`} + ) : undefined + } + onChange={(localPart) => onChangeFrom({ ...from, email: addressFromLocalPart(localPart, domain) })} + data-attr="transactional-email-sender-address" + /> + + + + +
+ + Done + +
+
+ ) +} diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx new file mode 100644 index 000000000000..a59fe39c9031 --- /dev/null +++ b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx @@ -0,0 +1,70 @@ +import { useActions, useValues } from 'kea' +import { useState } from 'react' + +import { IconPencil } from '@posthog/icons' +import { LemonButton, LemonDropdown, LemonLabel } from '@posthog/lemon-ui' + +import { integrationsLogic } from 'lib/integrations/integrationsLogic' + +import { senderAddressError, senderFrom, senderIntegrationFor } from './emailSenderFields' +import { EmailSenderForm } from './EmailSenderForm' +import { emailTemplaterLogic } from './emailTemplaterLogic' + +export function EmailSenderSummary(): JSX.Element { + const { emailTemplate } = useValues(emailTemplaterLogic) + const { setEmailTemplateValue } = useActions(emailTemplaterLogic) + const { integrations } = useValues(integrationsLogic) + const [isEditing, setIsEditing] = useState(false) + + const senders = (integrations ?? []).filter((integration) => integration.kind === 'email') + const from = senderFrom(emailTemplate) + const sender = senderIntegrationFor(emailTemplate, senders) + const name = from.name || sender?.config?.name + const address = from.email || sender?.config?.email + const replyTo = emailTemplate.replyTo || sender?.config?.reply_to + const addressError = senderAddressError(from.email, sender) + + return ( +
+ From +
+ {sender ? ( +
+ {name ? {name} : null} + {name ? ` <${address}>` : address} +
+ ) : ( +
Choose who this email comes from
+ )} + {replyTo ?
{`Replies go to ${replyTo}`}
: null} + {addressError ?
{addressError}
: null} +
+ setEmailTemplateValue('from', next)} + onChangeReplyTo={(next) => setEmailTemplateValue('replyTo', next)} + onDone={() => setIsEditing(false)} + /> + } + > + } + status={addressError ? 'danger' : 'default'} + data-attr="transactional-email-sender-edit" + > + {sender ? 'Edit sender' : 'Choose sender'} + + +
+ ) +} diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx index 513398abe605..fa7f4bc22b1a 100644 --- a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx @@ -42,6 +42,7 @@ import { MessageTemplateCard } from 'products/workflows/frontend/TemplateLibrary import { collapseToolsPanelCustomJs } from './custom-tools/collapseToolsPanel' import { previewLinkTargetCustomJs } from './custom-tools/previewLinkTarget' import { unsubscribeLinkToolCustomJs } from './custom-tools/unsubscribeLinkTool' +import { EmailSenderSummary } from './EmailSenderSummary' import { EMAIL_TYPE_SUPPORTED_FIELDS, EmailTemplaterLogicProps, emailTemplaterLogic } from './emailTemplaterLogic' import { EmailFieldErrors, EmailTemplateFrom, MAX_WORKFLOW_EMAIL_SENDERS } from './types' @@ -553,8 +554,11 @@ function NativeEmailTemplaterForm({ const { sceneIntegrationEnabled } = useValues(sceneAgentPanelLogic) // The template editor has only subject + preheader, so they share one row with the - // visual/plain-text switch to keep vertical space for the canvas. - const compactHeader = logicProps.type === 'native_email_template' && mode === 'full' + // visual/plain-text switch to keep vertical space for the canvas. A transactional template adds its + // sender as one summary line above. + const compactHeader = + (logicProps.type === 'native_email_template' || logicProps.type === 'native_email_transactional_template') && + mode === 'full' const preheaderVisible = visibleFields.some((field) => field.key === 'preheader') // Preheaders see almost no use, so don't advertise the field unless this team already uses it. const offerPreheader = templates.some((template) => !!template.content?.email?.preheader) @@ -569,65 +573,68 @@ function NativeEmailTemplaterForm({ >
{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],