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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions frontend/src/scenes/hog-functions/email-templater/EmailSenderForm.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-3 p-2 w-100 max-w-full">
<LemonField.Pure label="Sender">
<LemonSelect
fullWidth
placeholder="Choose a sender"
value={from.integrationId}
onChange={onChangeSender}
options={[
{
options: senders.map((candidate) => ({
value: candidate.id,
label: senderLabel(candidate),
})),
},
{ options: [{ value: ADD_SENDER, label: 'Add new email sender', icon: <IconExternal /> }] },
]}
data-attr="transactional-email-sender-select"
/>
</LemonField.Pure>
<LemonField.Pure label="Display name" help="Shown in the inbox. Supports Liquid templating.">
<LemonInput
value={from.name ?? ''}
placeholder={sender?.config?.name || "The sender's name"}
onChange={(name) => onChangeFrom({ ...from, name: name || undefined })}
data-attr="transactional-email-sender-name"
/>
</LemonField.Pure>
<LemonField.Pure
label="Address"
help={senderEmail ? `Leave empty to send from ${senderEmail}.` : undefined}
error={senderAddressError(from.email, sender)}
>
<LemonInput
value={localPart}
placeholder={senderEmail?.split('@')[0]}
// A pasted full address already has its own domain, so the suffix would only confuse it.
suffix={
domain && !localPart.includes('@') ? (
<span className="text-secondary">{`@${domain}`}</span>
) : undefined
}
onChange={(localPart) => onChangeFrom({ ...from, email: addressFromLocalPart(localPart, domain) })}
data-attr="transactional-email-sender-address"
/>
</LemonField.Pure>
<LemonField.Pure label="Reply-to" help="Separate several addresses with commas.">
<LemonInput
value={replyTo ?? ''}
placeholder={sender?.config?.reply_to || 'Replies go to the sender address'}
onChange={onChangeReplyTo}
data-attr="transactional-email-reply-to"
/>
</LemonField.Pure>
<div className="flex justify-end">
<LemonButton type="primary" size="small" onClick={onDone}>
Done
</LemonButton>
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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 {

Check warning on line 13 in frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`EmailSenderSummary` has cyclomatic complexity 12 (warn >10)

Check warning on line 13 in frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`EmailSenderSummary` has cyclomatic complexity 12 (warn >10)
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 (
<div className="flex gap-2 items-center pl-2 pr-1 py-1 border-b shrink-0 min-w-0">
<LemonLabel className="shrink-0">From</LemonLabel>
<div className="flex-1 min-w-0 px-2">
{sender ? (
<div className="truncate">
{name ? <span className="font-semibold">{name}</span> : null}
<span className="text-secondary">{name ? ` <${address}>` : address}</span>
</div>
) : (
<div className="text-secondary">Choose who this email comes from</div>
)}
{replyTo ? <div className="text-xs text-secondary truncate">{`Replies go to ${replyTo}`}</div> : null}
{addressError ? <div className="text-xs text-danger">{addressError}</div> : null}
</div>
<LemonDropdown
visible={isEditing}
onVisibilityChange={setIsEditing}
closeOnClickInside={false}
placement="bottom-end"
overlay={
<EmailSenderForm
from={from}
replyTo={emailTemplate.replyTo}
senders={senders}
onChangeFrom={(next) => setEmailTemplateValue('from', next)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Carry custom sender overrides into test emails

After setting a custom name or address here, clicking “Send test” sends neither override. messageTemplateTestSendLogic.sendTestEmail spreads the template content, then replaces from with only the test integration ID, so the received message uses the integration's default identity. Preserve the template's name and address when constructing the test sender.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dde6371. A test send defaults to the template's own sender and keeps its custom name and address. The send test now sets a non-default sender with overrides and asserts all three reach the email step.

onChangeReplyTo={(next) => setEmailTemplateValue('replyTo', next)}
onDone={() => setIsEditing(false)}
/>
}
>
<LemonButton
size="small"
type="secondary"
icon={<IconPencil />}
status={addressError ? 'danger' : 'default'}
data-attr="transactional-email-sender-edit"
>
{sender ? 'Edit sender' : 'Choose sender'}
</LemonButton>
</LemonDropdown>
</div>
)
}
112 changes: 60 additions & 52 deletions frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
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'

Expand Down Expand Up @@ -265,7 +266,7 @@
)
}

export function NativeEmailIntegrationChoice({

Check warning on line 269 in frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`NativeEmailIntegrationChoice` has cyclomatic complexity 13 (warn >10)

Check warning on line 269 in frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`NativeEmailIntegrationChoice` has cyclomatic complexity 13 (warn >10)
label,
onChange,
value,
Expand Down Expand Up @@ -528,7 +529,7 @@
)
}

function NativeEmailTemplaterForm({

Check warning on line 532 in frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`NativeEmailTemplaterForm` has cyclomatic complexity 15 (warn >10)

Check warning on line 532 in frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`NativeEmailTemplaterForm` has cyclomatic complexity 15 (warn >10)
mode,
fieldsHidden,
}: {
Expand All @@ -553,8 +554,11 @@
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)
Expand All @@ -569,65 +573,68 @@
>
<div className={fieldsHidden ? 'h-0 overflow-hidden' : ''}>
{compactHeader ? (
<div className="flex gap-2 items-center pl-2 pr-1 py-0.5 border-b shrink-0">
<LemonField name="subject" className="flex-2 min-w-40" renderError={() => null}>
{({ value, onChange, error }: ChildFunctionProps) => (
<div className="flex gap-2 items-center flex-1">
<LemonLabel className={error ? 'text-danger' : ''}>Subject</LemonLabel>
<LiquidSupportedText
value={value}
onChange={onChange}
globals={logicProps.variables}
/>
</div>
)}
</LemonField>
{preheaderVisible ? (
<LemonField name="preheader" className="flex-1 min-w-40" renderError={() => null}>
{({ value, onChange }: ChildFunctionProps) => (
<>
{logicProps.type === 'native_email_transactional_template' && <EmailSenderSummary />}
<div className="flex gap-2 items-center pl-2 pr-1 py-0.5 border-b shrink-0">
<LemonField name="subject" className="flex-2 min-w-40" renderError={() => null}>
{({ value, onChange, error }: ChildFunctionProps) => (
<div className="flex gap-2 items-center flex-1">
<LemonLabel info="This is the preview text that appears below the subject line in an inbox.">
Preheader
</LemonLabel>
<LemonLabel className={error ? 'text-danger' : ''}>Subject</LemonLabel>
<LiquidSupportedText
value={value}
onChange={onChange}
globals={logicProps.variables}
/>
<LemonButton
size="xsmall"
type="tertiary"
icon={<IconX />}
onClick={() => {
onChange('')
hideAdvancedField('preheader')
}}
tooltip="Remove field"
/>
</div>
)}
</LemonField>
) : offerPreheader ? (
<LemonButton
size="xsmall"
type="secondary"
icon={<IconPlus />}
onClick={() => revealAdvancedField('preheader')}
>
Preheader
</LemonButton>
) : null}
<LemonSegmentedButton
size="small"
className="ml-2 shrink-0"
value={activeContentTab}
onChange={(tab) => setActiveContentTab(tab as 'visual' | 'plaintext')}
options={[
{ value: 'visual', label: 'Visual' },
{ value: 'plaintext', label: 'Plain text' },
]}
/>
</div>
{preheaderVisible ? (
<LemonField name="preheader" className="flex-1 min-w-40" renderError={() => null}>
{({ value, onChange }: ChildFunctionProps) => (
<div className="flex gap-2 items-center flex-1">
<LemonLabel info="This is the preview text that appears below the subject line in an inbox.">
Preheader
</LemonLabel>
<LiquidSupportedText
value={value}
onChange={onChange}
globals={logicProps.variables}
/>
<LemonButton
size="xsmall"
type="tertiary"
icon={<IconX />}
onClick={() => {
onChange('')
hideAdvancedField('preheader')
}}
tooltip="Remove field"
/>
</div>
)}
</LemonField>
) : offerPreheader ? (
<LemonButton
size="xsmall"
type="secondary"
icon={<IconPlus />}
onClick={() => revealAdvancedField('preheader')}
>
Preheader
</LemonButton>
) : null}
<LemonSegmentedButton
size="small"
className="ml-2 shrink-0"
value={activeContentTab}
onChange={(tab) => setActiveContentTab(tab as 'visual' | 'plaintext')}
options={[
{ value: 'visual', label: 'Visual' },
{ value: 'plaintext', label: 'Plain text' },
]}
/>
</div>
</>
) : (
visibleFields.map((field) => {
const fieldError = fieldErrorFor(field.key, logicProps.fieldErrors)
Expand Down Expand Up @@ -857,6 +864,7 @@
case 'email':
return <DestinationEmailTemplaterForm mode={mode} fieldsHidden={fieldsHidden} />
case 'native_email_template':
case 'native_email_transactional_template':
case 'native_email':
return <NativeEmailTemplaterForm mode={mode} fieldsHidden={fieldsHidden} />
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading