Skip to content
Draft
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
1 change: 1 addition & 0 deletions frontend/src/lib/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,7 @@ export const FEATURE_FLAGS = {
WORKFLOWS_INTERNAL_EVENT_FILTERS: 'workflows-internal-event-filters', // owner: @haven #team-workflows
WORKFLOWS_LINEAR_VIEW: 'workflows-linear-view', // owner: #team-workflows
WORKFLOWS_PUSH_NOTIFICATIONS: 'workflows-push-notifications', // owner: #team-workflows
WORKFLOWS_SANDBOX_SENDER: 'workflows-sandbox-sender', // owner: #team-workflows
WORKFLOWS_SENDING_TIER_REQUESTS: 'workflows-sending-tier-requests', // owner: @luke-belton #team-workflows, lets customers request a higher email sending tier
WORKFLOWS_SUPABASE_AUTH_EMAILS: 'workflows-supabase-auth-emails', // owner: @luke-belton #team-workflows, send Supabase auth emails through transactional templates
WORKFLOWS_TILE_CANVAS: 'workflows-tile-canvas', // owner: #team-workflows
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/lib/integrations/IntegrationsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ export function IntegrationsList({
/** Rendered once the list has loaded and nothing matches. */
emptyState?: JSX.Element
}): JSX.Element {
const { integrations, integrationsLoading } = useValues(integrationsLogic)
const { userManagedIntegrations, integrationsLoading } = useValues(integrationsLogic)
const { startPolling, stopPolling } = useActions(integrationsLogic)

useOnMountEffect(() => {
startPolling()
return () => stopPolling()
})
const filteredIntegrations = integrations?.filter((integration) => {
const filteredIntegrations = userManagedIntegrations?.filter((integration) => {
if (onlyKinds && !onlyKinds.includes(integration.kind)) {
return false
}
Expand All @@ -46,7 +46,7 @@ export function IntegrationsList({
filteredIntegrations.map((integration) => (
<IntegrationView key={integration.id} integration={integration} />
))
) : integrationsLoading || integrations === null ? (
) : integrationsLoading || userManagedIntegrations === null ? (
<LemonSkeleton className="h-10" />
) : (
emptyState
Expand Down
96 changes: 96 additions & 0 deletions frontend/src/lib/integrations/integrationsLogic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { expectLogic } from 'kea-test-utils'
import { LemonDialog, lemonToast } from '@posthog/lemon-ui'

import apiReal, { ApiError } from 'lib/api'
import { FEATURE_FLAGS } from 'lib/constants'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { teamLogic } from 'scenes/teamLogic'

import { useMocks } from '~/mocks/jest'
Expand Down Expand Up @@ -78,6 +80,100 @@ describe('integrationsLogic', () => {
jest.restoreAllMocks()
})

describe('sandbox email sender', () => {
const sandboxSender: IntegrationType = {
id: 9,
kind: 'email',
display_name: 'Acme via PostHog <sandbox@example.com>',
icon_url: '',
config: {
provider: 'sandbox',
email: 'sandbox@example.com',
domain: 'example.com',
name: 'Acme via PostHog',
verified: true,
},
created_at: '2026-08-18T00:00:00Z',
}
const ownSender: IntegrationType = {
id: 10,
kind: 'email',
display_name: 'Acme <hello@acme.example.com>',
icon_url: '',
config: { provider: 'ses', email: 'hello@acme.example.com', domain: 'acme.example.com', verified: true },
created_at: '2026-08-18T00:00:00Z',
}
afterEach(() => {
featureFlagLogic.actions.setFeatureFlags([], {})
})

const enableFlag = (): void => {
featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], {
[FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true,
})
}

it.each([
[
'with an API detail',
{ detail: 'Could not load senders. Try again.' },
'Could not load senders. Try again.',
],
['without an API detail', '<html>Bad gateway</html>', null],
])(
'retains a failed integrations load %s without treating the unresolved list as empty',
async (_name, body, expectedError) => {
useMocks({ get: { '/api/projects/:team_id/integrations/': () => [502, body] } })
logic.actions.clearIntegrations()
await expectLogic(logic, () => logic.actions.loadIntegrations()).toDispatchActions([
'loadIntegrationsFailure',
])
expect(logic.values.integrations).toBeNull()
expect(logic.values.integrationsLoadFailed).toBe(true)
expect(logic.values.integrationsLoadError).toBe(expectedError)

useMocks({ get: { '/api/projects/:team_id/integrations/': { results: [] } } })
await expectLogic(logic, () => logic.actions.loadIntegrations()).toDispatchActions([
'loadIntegrationsSuccess',
])
expect(logic.values.integrationsLoadFailed).toBe(false)
expect(logic.values.integrationsLoadError).toBeNull()
}
)

it.each([
{ flagState: 'on', flag: true, expectedSandbox: expect.objectContaining({ id: sandboxSender.id }) },
{ flagState: 'off', flag: false, expectedSandbox: null },
])(
'keeps the sandbox row out of the own-sender lists and exposes it only with the flag $flagState',
async ({ flag, expectedSandbox }) => {
if (flag) {
enableFlag()
}
integrationsPayload = [sandboxSender, ownSender]

await expectLogic(logic, () => {
logic.actions.loadIntegrations()
})
.toDispatchActions(['loadIntegrationsSuccess'])
.toMatchValues({
sandboxEmailSender: expectedSandbox,
ownEmailIntegrations: [expect.objectContaining({ id: ownSender.id })],
userManagedIntegrations: [expect.objectContaining({ id: ownSender.id })],
domainGroupedEmailIntegrations: [
{
domain: 'acme.example.com',
integrations: [expect.objectContaining({ id: ownSender.id })],
},
],
})
expect(logic.values.getIntegrationsByKind(['email'])).toEqual([
expect.objectContaining({ id: ownSender.id }),
])
}
)
})

describe('GitHub discovery freshness', () => {
const discovery = (id: string): GitHubAvailableInstallationsResponseApi => ({
discovery_id: id,
Expand Down
95 changes: 77 additions & 18 deletions frontend/src/lib/integrations/integrationsLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import api, { ApiError, getCookie } from 'lib/api'
import { globalSetupLogic } from 'lib/components/ProductSetup'
import { FEATURE_FLAGS } from 'lib/constants'
import { buildGithubDisconnectDescription } from 'lib/integrations/githubDisconnectCopy'
import { describeGithubSetupError, GITHUB_INSTALL_PENDING_MESSAGE } from 'lib/integrations/githubSetupErrors'
import { describeScopeShortfall, getMissingScopes, requestedScopesForKind } from 'lib/integrations/integrationScopes'
Expand All @@ -14,6 +15,7 @@
INTEGRATION_ERROR_PARAM,
OAUTH_STATE_EXPIRED,
} from 'lib/integrations/oauthCallbackErrors'
import { FeatureFlagsSet, featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { preflightLogic } from 'lib/logic/preflightLogic'
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { isKeyOf } from 'lib/utils/guards'
Expand Down Expand Up @@ -44,7 +46,7 @@

import type { AvailableSetupTaskIdsEnumApi } from '../../generated/core/api.schemas'
import type { PreflightStatus, UserBasicType } from '../../types'
import { ICONS, getIntegrationNameFromKind } from './utils'
import { ICONS, getIntegrationNameFromKind, isSandboxEmailSender } from './utils'

const INTEGRATIONS_POLL_INTERVAL_MS = 30_000

Expand Down Expand Up @@ -105,6 +107,7 @@

// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface integrationsLogicValues {
featureFlags: FeatureFlagsSet // featureFlagLogic
preflight: PreflightStatus | null // preflightLogic
siteUrlMisconfigured: boolean // preflightLogic
currentProjectId: number | string // teamLogic
Expand Down Expand Up @@ -179,19 +182,25 @@
githubRepositoriesTotal: Record<number, number>
githubSuggestionSubscribers: number
integrations: IntegrationType[] | null
integrationsLoadError: string | null
integrationsLoadFailed: boolean
integrationsLoading: boolean
linearIntegrations: IntegrationType[]
linkedGithubInstallation: IntegrationConfigApi | null
linkedGithubInstallationLoading: boolean
newIntegrationModalId: string | null
newIntegrationModalKind: IntegrationKind | null
ownEmailIntegrations: IntegrationType[]
pollingSubscribers: number
requestedAccessKinds: IntegrationKind[]
sandboxEmailSender: IntegrationType | null
sandboxEmailSenderEnabled: boolean
selectedIntegration: IntegrationType | null
setupModalOpen: boolean
setupModalType: ChannelType | null
slackAvailable: boolean | undefined
slackIntegrations: IntegrationType[] | undefined
userManagedIntegrations: IntegrationType[] | null
}

// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
Expand Down Expand Up @@ -709,7 +718,7 @@
githubAvailableInstallationsResponse: GitHubAvailableInstallationsResponseApi | null
) => boolean | null
getIntegrationsByKind: (
integrations: IntegrationType[] | null
userManagedIntegrations: IntegrationType[] | null
) => (
kinds: (
| 'apns'
Expand Down Expand Up @@ -770,7 +779,14 @@
getGitHubRepositoriesTotal: (
githubRepositoriesTotal: Record<number, number>
) => (integrationId: number) => number | null
domainGroupedEmailIntegrations: (integrations: IntegrationType[] | null) => EmailIntegrationDomainGroupedType[]
sandboxEmailSenderEnabled: (featureFlags: FeatureFlagsSet) => boolean
sandboxEmailSender: (
integrations: IntegrationType[] | null,
sandboxEmailSenderEnabled: boolean
) => IntegrationType | null
userManagedIntegrations: (integrations: IntegrationType[] | null) => IntegrationType[] | null
ownEmailIntegrations: (userManagedIntegrations: IntegrationType[] | null) => IntegrationType[]
domainGroupedEmailIntegrations: (ownEmailIntegrations: IntegrationType[]) => EmailIntegrationDomainGroupedType[]
}
}

Expand All @@ -784,7 +800,14 @@
export const integrationsLogic = kea<integrationsLogicType>([
path(['lib', 'integrations', 'integrationsLogic']),
connect(() => ({
values: [preflightLogic, ['siteUrlMisconfigured', 'preflight'], teamLogic, ['currentProjectId']],
values: [
preflightLogic,
['siteUrlMisconfigured', 'preflight'],
teamLogic,
['currentProjectId'],
featureFlagLogic,
['featureFlags'],
],
actions: [
globalSetupLogic,
['markTaskAsCompleted'],
Expand Down Expand Up @@ -838,6 +861,25 @@
}),
reducers({
integrations: [null as IntegrationType[] | null, { clearIntegrations: () => null }],
integrationsLoadError: [
null as string | null,
{
loadIntegrations: () => null,
loadIntegrationsSuccess: () => null,
clearIntegrations: () => null,
loadIntegrationsFailure: (_, { errorObject }) =>
errorObject instanceof ApiError && errorObject.detail ? errorObject.detail : null,
},
],
integrationsLoadFailed: [
false,
{
loadIntegrations: () => false,
loadIntegrationsSuccess: () => false,
clearIntegrations: () => false,
loadIntegrationsFailure: () => true,
},
],
githubDisconnecting: [false, { setGithubDisconnecting: (_, { disconnecting }) => disconnecting }],
githubDiscoveryGeneration: [
0,
Expand Down Expand Up @@ -1232,7 +1274,7 @@
cache.disposables.dispose('poll')
cache.disposables.dispose('focusRefetch')
},
handleOauthCallback: async ({ kind, searchParams }) => {

Check warning on line 1277 in frontend/src/lib/integrations/integrationsLogic.ts

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`handleOauthCallback` has cyclomatic complexity 27 (warn >10)
if (kind === 'twitter-ads') {
// The server binds the temporary token to the initiating user and project instead of a state cookie.
let replaceUrl = urls.settings('project-integrations')
Expand Down Expand Up @@ -1479,7 +1521,7 @@
},
],
getIntegrationsByKind: [
(s) => [s.integrations],
(s) => [s.userManagedIntegrations],
(integrations: IntegrationType[] | null) => {
return (kinds: IntegrationKind[]) => integrations?.filter((i) => kinds.includes(i.kind)) || []
},
Expand Down Expand Up @@ -1511,23 +1553,40 @@
},
],

domainGroupedEmailIntegrations: [
sandboxEmailSenderEnabled: [
(s) => [s.featureFlags],
(featureFlags: FeatureFlagsSet): boolean => !!featureFlags[FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER],
],
sandboxEmailSender: [
(s) => [s.integrations, s.sandboxEmailSenderEnabled],
(integrations: IntegrationType[] | null, sandboxEmailSenderEnabled: boolean): IntegrationType | null =>
sandboxEmailSenderEnabled ? (integrations?.find(isSandboxEmailSender) ?? null) : null,
],
userManagedIntegrations: [
(s) => [s.integrations],
(integrations: IntegrationType[] | null): EmailIntegrationDomainGroupedType[] => {
(integrations: IntegrationType[] | null): IntegrationType[] | null =>
integrations?.filter((x) => !isSandboxEmailSender(x)) ?? null,
],
ownEmailIntegrations: [
(s) => [s.userManagedIntegrations],
(userManagedIntegrations: IntegrationType[] | null): IntegrationType[] =>
userManagedIntegrations?.filter((x) => x.kind === 'email') ?? [],
],
domainGroupedEmailIntegrations: [
(s) => [s.ownEmailIntegrations],
(ownEmailIntegrations: IntegrationType[]): EmailIntegrationDomainGroupedType[] => {
const domainGroupedIntegrations: Record<string, EmailIntegrationDomainGroupedType> = {}

integrations
?.filter((x) => x.kind === 'email')
.forEach((integration) => {
const domain = integration.config.domain
if (!domainGroupedIntegrations[domain]) {
domainGroupedIntegrations[domain] = {
domain,
integrations: [],
}
ownEmailIntegrations.forEach((integration) => {
const domain = integration.config.domain
if (!domainGroupedIntegrations[domain]) {
domainGroupedIntegrations[domain] = {
domain,
integrations: [],
}
domainGroupedIntegrations[domain].integrations.push(integration)
})
}
domainGroupedIntegrations[domain].integrations.push(integration)
})

return Object.values(domainGroupedIntegrations)
},
Expand Down
44 changes: 35 additions & 9 deletions products/workflows/frontend/Channels/ChannelCatalog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useValues } from 'kea'
import { useActions, useValues } from 'kea'

import { LemonBanner, LemonSkeleton } from '@posthog/lemon-ui'
import { LemonBanner, LemonButton, LemonSkeleton } from '@posthog/lemon-ui'

import { RestrictionScope, useRestrictedArea } from 'lib/components/RestrictedArea'
import { TeamMembershipLevel } from 'lib/constants'
Expand All @@ -11,7 +11,9 @@ import { CATALOG_CHANNELS, CATALOG_GRID_CLASS } from './channelSummary'

/** Every channel a workflow can send through, with whether this project has it set up. */
export function ChannelCatalog(): JSX.Element {
const { channels, activeCount, integrationsLoadFailed } = useValues(channelCatalogLogic)
const { channels, activeCount, integrationsLoadFailed, integrationsLoading, integrationsLoadError } =
useValues(channelCatalogLogic)
const { loadIntegrations } = useActions(channelCatalogLogic)
const restrictedReason = useRestrictedArea({
scope: RestrictionScope.Project,
minimumAccessLevel: TeamMembershipLevel.Admin,
Expand All @@ -26,13 +28,37 @@ export function ChannelCatalog(): JSX.Element {
)}
</div>
{channels ? (
<div className={CATALOG_GRID_CLASS}>
{channels.map((channel) => (
<ChannelCatalogCard key={channel.kind} channel={channel} disabledReason={restrictedReason} />
))}
</div>
<>
{integrationsLoadFailed && (
<div className="flex flex-wrap items-center gap-2 text-xs text-secondary">
<span>Couldn't refresh your channels. Showing the last loaded connections.</span>
<LemonButton
size="xsmall"
type="tertiary"
onClick={loadIntegrations}
loading={integrationsLoading}
>
Try again
</LemonButton>
</div>
)}
<div className={CATALOG_GRID_CLASS}>
{channels.map((channel) => (
<ChannelCatalogCard
key={channel.kind}
channel={channel}
disabledReason={restrictedReason}
/>
))}
</div>
</>
) : integrationsLoadFailed ? (
<LemonBanner type="error">Couldn't load your channels. Refresh the page to try again.</LemonBanner>
<LemonBanner
type="error"
action={{ children: 'Try again', onClick: loadIntegrations, loading: integrationsLoading }}
>
{integrationsLoadError || "Couldn't load your channels."}
</LemonBanner>
) : (
<div className={CATALOG_GRID_CLASS}>
{CATALOG_CHANNELS.map((kind) => (
Expand Down
Loading
Loading