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 e2e-harness/__tests__/keyboard-equivalence.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ vi.mock('@utils/analytics', () => ({
setTag: vi.fn(),
captureException: vi.fn(),
shutdown: vi.fn().mockResolvedValue(undefined),
getCachedWizardFlags: vi.fn(() => null),
},
sessionProperties: vi.fn(() => ({})),
}));
Expand Down
8 changes: 8 additions & 0 deletions e2e-harness/action-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,14 @@ export const ACTION_REGISTRY: Partial<Record<string, DriverAction[]>> = {
},
],

[PostHogIntegrationScreenId.EmailProviderOffer]: [
{
id: 'dismiss_email_provider_offer',
description: 'Leave the email provider question.',
apply: (store) => store.setEmailProviderOfferDone(),
},
],

// ── Slack ─────────────────────────────────────────────────────────────
[SelfDrivingScreenId.Github]: [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { describe, expect, it } from 'vitest';
import { buildSession } from '@services/session';
import { CODEBASE_SCAN_KEY, type CodebaseScan } from '@services/codebase-scan';
import { HostResolution } from '@shared/host-resolution';
import { OutroKind } from '@shared/outro';
import { ScanConsent } from '@shared/run-state';
import type { ProgramSession } from '@programs/program-contract';
import {
emailProviderOffer,
emailProviderOfferChoices,
emailProviderOfferQuestion,
} from '@programs/posthog-integration';

function detected(kind: string, label: string) {
return {
kind,
label,
warehouseSourceType: null,
mode: null,
matchedSignal: `package ${kind.toLowerCase()}`,
signals: [],
};
}

function scanFinding(...tools: ReturnType<typeof detected>[]): CodebaseScan {
return {
program: 'posthog-integration',
scannedAt: '2026-10-26T09:30:00.000Z',
scannedDir: '/tmp/app',
tools,
complete: true,
};
}

function finishedRun(
scan: CodebaseScan,
overrides: Partial<ProgramSession> = {},
): ProgramSession {
const session = buildSession({ installDir: '/tmp/app' });
session.scanConsent = ScanConsent.Granted;
session.frameworkContext[CODEBASE_SCAN_KEY] = scan;
session.outroData = { kind: OutroKind.Success, message: 'Done' };
session.credentials = {
accessToken: 'token',
projectApiKey: 'phc_example',
host: HostResolution.fromApiHost('https://us.posthog.com'),
projectId: 42,
};
return Object.assign(session, overrides);
}

const RESEND = detected('Resend', 'Resend');
const OFFER_ON = { 'wizard-email-provider-offer': 'true' };

describe('emailProviderOffer', () => {
it('offers Workflows in the project to a run that found a provider', () => {
const offer = emailProviderOffer(
finishedRun(
scanFinding(
detected('Stripe', 'Stripe'),
RESEND,
detected('AwsSes', 'Amazon SES'),
),
),
OFFER_ON,
);

expect(offer).toEqual({
providers: ['Resend', 'AwsSes'],
providerLabels: ['Resend', 'Amazon SES'],
workflowsUrl:
'https://us.posthog.com/project/42/workflows?source=wizard_cli',
});
});

it.each<[string, (scan: CodebaseScan) => ProgramSession]>([
['a CI run', (scan) => finishedRun(scan, { ci: true })],
['telemetry is off', (scan) => finishedRun(scan, { noTelemetry: true })],
[
'sharing was declined',
(scan) => finishedRun(scan, { scanConsent: ScanConsent.Declined }),
],
[
'the install failed',
(scan) =>
finishedRun(scan, {
outroData: { kind: OutroKind.Error, message: 'failed' },
}),
],
[
'the user is not logged in',
(scan) => finishedRun(scan, { credentials: null }),
],
])('stays silent when %s', (_case, sessionFor) => {
expect(
emailProviderOffer(sessionFor(scanFinding(RESEND)), OFFER_ON),
).toBeUndefined();
});

it('stays silent when the scan found no email provider', () => {
expect(
emailProviderOffer(
finishedRun(scanFinding(detected('Stripe', 'Stripe'))),
OFFER_ON,
),
).toBeUndefined();
});

it.each<[string, Record<string, string> | null]>([
['the flag is off', { 'wizard-email-provider-offer': 'false' }],
['the flag evaluation left the flag out', {}],
['no flag evaluation finished', null],
])('stays silent when %s', (_case, flags) => {
expect(
emailProviderOffer(finishedRun(scanFinding(RESEND)), flags),
).toBeUndefined();
});

it.each<[string, ReturnType<typeof detected>[], string, string]>([
[
'one provider',
[RESEND],
'Your app sends email with Resend.',
"No, I'm happy with Resend",
],
[
'two providers',
[RESEND, detected('SendGrid', 'SendGrid')],
'Your app sends email with Resend and SendGrid.',
"No, I'm happy with my current setup",
],
[
'three providers',
[
RESEND,
detected('SendGrid', 'SendGrid'),
detected('Postmark', 'Postmark'),
],
'Your app sends email with Resend, SendGrid and others.',
"No, I'm happy with my current setup",
],
])(
'names the providers for %s and keeps all of them in the offer',
(_case, tools, opening, decline) => {
const offer = emailProviderOffer(
finishedRun(scanFinding(...tools)),
OFFER_ON,
);
if (!offer) throw new Error('expected an offer');

expect(offer.providers).toEqual(tools.map((tool) => tool.kind));
expect(emailProviderOfferQuestion(offer)).toBe(
`${opening} PostHog can send it too, triggered by the events you already track. Want to try it?`,
);
expect(emailProviderOfferChoices(offer).map((c) => c.label)).toEqual([
'Yes, show me how',
'Not now',
decline,
]);
},
);
});
118 changes: 118 additions & 0 deletions src/programs/posthog-integration/email-provider-offer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
detectedEmailProviders,
getCodebaseScan,
maySendScanResults,
type EmailProviderKind,
} from '@services/codebase-scan';
import { WIZARD_EMAIL_PROVIDER_OFFER_FLAG_KEY } from '@shared/constants';
import { OutroKind } from '@shared/outro';
import { analytics } from '@utils/analytics';
import type { ProgramSession } from '@programs/program-contract';

export enum EmailProviderOfferAnswer {
Yes = 'yes',
NotNow = 'not_now',
No = 'no',
Skipped = 'skipped',
}

export type EmailProviderOffer = {
providers: EmailProviderKind[];
providerLabels: [string, ...string[]];
workflowsUrl: string;
migrateCommand?: string;
};

type WizardFlags = Readonly<Record<string, string>> | null;

type OfferSession = Pick<
ProgramSession,
| 'ci'
| 'noTelemetry'
| 'scanConsent'
| 'outroData'
| 'credentials'
| 'frameworkContext'
>;

const MIGRATE_COMMANDS: Readonly<Partial<Record<EmailProviderKind, string>>> =
{};

function mayAsk(session: OfferSession, flags: WizardFlags): boolean {
return (
flags?.[WIZARD_EMAIL_PROVIDER_OFFER_FLAG_KEY] === 'true' &&
!session.ci &&
maySendScanResults(session) &&
session.outroData?.kind === OutroKind.Success
);
}

export function emailProviderOffer(
session: OfferSession,
flags: WizardFlags = analytics.getCachedWizardFlags(),
): EmailProviderOffer | undefined {
const scan = getCodebaseScan(session);
const credentials = session.credentials;
if (!scan || !credentials || !mayAsk(session, flags)) return undefined;
const [first, ...others] = detectedEmailProviders(scan);
if (!first) return undefined;
return {
providers: [first, ...others].map((tool) => tool.kind),
providerLabels: [first.label, ...others.map((tool) => tool.label)],
workflowsUrl: `${credentials.host.appHost}/project/${credentials.projectId}/workflows?source=wizard_cli`,
migrateCommand: MIGRATE_COMMANDS[first.kind],
};
}

function providerList([
first,
second,
...rest
]: EmailProviderOffer['providerLabels']): string {
if (second === undefined) return first;
if (rest.length === 0) return `${first} and ${second}`;
return `${first}, ${second} and others`;
}

function declineLabel(offer: EmailProviderOffer): string {
const [only, ...others] = offer.providerLabels;
return others.length === 0
? `No, I'm happy with ${only}`
: "No, I'm happy with my current setup";
}

export function emailProviderOfferQuestion(offer: EmailProviderOffer): string {
return `Your app sends email with ${providerList(
offer.providerLabels,
)}. PostHog can send it too, triggered by the events you already track. Want to try it?`;
}

export function emailProviderOfferChoices(
offer: EmailProviderOffer,
): { label: string; value: EmailProviderOfferAnswer }[] {
return [
{ label: 'Yes, show me how', value: EmailProviderOfferAnswer.Yes },
{ label: 'Not now', value: EmailProviderOfferAnswer.NotNow },
{ label: declineLabel(offer), value: EmailProviderOfferAnswer.No },
];
}

export function reportEmailProviderOfferShown(offer: EmailProviderOffer): void {
analytics.wizardCapture('email provider offer shown', {
providers: offer.providers,
});
}

export function reportEmailProviderOfferAnswered(
offer: EmailProviderOffer,
answer: EmailProviderOfferAnswer,
): void {
analytics.wizardCapture('email provider offer answered', {
providers: offer.providers,
answer,
$set: {
wizard_email_offer_answer: answer,
wizard_email_offer_answered_at: new Date().toISOString(),
},
});
}
9 changes: 9 additions & 0 deletions src/programs/posthog-integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,15 @@ const warehouseSeedTasks: NonNullable<ProgramConfig['seedTasks']> = (sess) => {
};

export { EVENT_PLAN_FILE } from './constants.js';
export {
EmailProviderOfferAnswer,
emailProviderOffer,
emailProviderOfferChoices,
emailProviderOfferQuestion,
reportEmailProviderOfferAnswered,
reportEmailProviderOfferShown,
type EmailProviderOffer,
} from './email-provider-offer.js';

export const config: ProgramConfig = {
description: 'Set up PostHog SDK integration',
Expand Down
25 changes: 20 additions & 5 deletions src/services/codebase-scan/email-providers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { DetectedTool } from '../warehouse-sources/types';
import type { CodebaseScan } from './scan';

const EMAIL_PROVIDER_KINDS: ReadonlySet<string> = new Set([
const EMAIL_PROVIDER_KINDS = [
'AwsSes',
'BigMailer',
'Brevo',
Expand All @@ -21,19 +22,33 @@ const EMAIL_PROVIDER_KINDS: ReadonlySet<string> = new Set([
'Smaily',
'SparkPost',
'Tinyemail',
]);
] as const;

export type EmailProviderKind = (typeof EMAIL_PROVIDER_KINDS)[number];

export type EmailProvider = DetectedTool & { kind: EmailProviderKind };

const EMAIL_PROVIDER_KIND_SET: ReadonlySet<string> = new Set(
EMAIL_PROVIDER_KINDS,
);

function isEmailProvider(tool: DetectedTool): tool is EmailProvider {
return EMAIL_PROVIDER_KIND_SET.has(tool.kind);
}

export type EmailProviderPersonProperties = {
wizard_detected_email_providers: string[];
wizard_detected_email_provider_at: string;
};

export function detectedEmailProviders(scan: CodebaseScan): EmailProvider[] {
return scan.tools.filter(isEmailProvider);
}

export function emailProviderPersonProperties(
scan: CodebaseScan,
): EmailProviderPersonProperties | undefined {
const providers = scan.tools
.map((tool) => tool.kind)
.filter((kind) => EMAIL_PROVIDER_KINDS.has(kind));
const providers = detectedEmailProviders(scan).map((tool) => tool.kind);
if (providers.length === 0) return undefined;
return {
wizard_detected_email_providers: providers,
Expand Down
6 changes: 5 additions & 1 deletion src/services/codebase-scan/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,8 @@ export {
type CodebaseScan,
} from './scan';
export { reportCodebaseScan, type ScanReportStore } from './report';
export { tagScanResults } from './tags';
export { maySendScanResults, tagScanResults } from './tags';
export {
detectedEmailProviders,
type EmailProviderKind,
} from './email-providers';
2 changes: 1 addition & 1 deletion src/services/codebase-scan/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export async function scannedWarehouseSources(
}

export function getCodebaseScan(
session: ProgramSession,
session: Pick<ProgramSession, 'frameworkContext'>,
): CodebaseScan | undefined {
return session.frameworkContext[CODEBASE_SCAN_KEY] as
| CodebaseScan
Expand Down
Loading