diff --git a/apps/start/src/routeTree.gen.ts b/apps/start/src/routeTree.gen.ts index 5d514401..2cbd6bdd 100644 --- a/apps/start/src/routeTree.gen.ts +++ b/apps/start/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { createFileRoute } from '@tanstack/react-router' import { Route as rootRouteImport } from './routes/__root' +import { Route as UnsubscribeRouteImport } from './routes/unsubscribe' import { Route as StepsRouteImport } from './routes/_steps' import { Route as PublicRouteImport } from './routes/_public' import { Route as LoginRouteImport } from './routes/_login' @@ -102,6 +103,11 @@ const AppOrganizationIdProjectIdProfilesProfileIdRouteImport = createFileRoute( '/_app/$organizationId/$projectId/profiles/$profileId', )() +const UnsubscribeRoute = UnsubscribeRouteImport.update({ + id: '/unsubscribe', + path: '/unsubscribe', + getParentRoute: () => rootRouteImport, +} as any) const StepsRoute = StepsRouteImport.update({ id: '/_steps', getParentRoute: () => rootRouteImport, @@ -525,6 +531,7 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/unsubscribe': typeof UnsubscribeRoute '/$organizationId': typeof AppOrganizationIdRouteWithChildren '/login': typeof LoginLoginRoute '/reset-password': typeof LoginResetPasswordRoute @@ -591,6 +598,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/unsubscribe': typeof UnsubscribeRoute '/login': typeof LoginLoginRoute '/reset-password': typeof LoginResetPasswordRoute '/onboarding': typeof PublicOnboardingRoute @@ -653,6 +661,7 @@ export interface FileRoutesById { '/_login': typeof LoginRouteWithChildren '/_public': typeof PublicRouteWithChildren '/_steps': typeof StepsRouteWithChildren + '/unsubscribe': typeof UnsubscribeRoute '/_app/$organizationId': typeof AppOrganizationIdRouteWithChildren '/_login/login': typeof LoginLoginRoute '/_login/reset-password': typeof LoginResetPasswordRoute @@ -728,6 +737,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/unsubscribe' | '/$organizationId' | '/login' | '/reset-password' @@ -794,6 +804,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/unsubscribe' | '/login' | '/reset-password' | '/onboarding' @@ -855,6 +866,7 @@ export interface FileRouteTypes { | '/_login' | '/_public' | '/_steps' + | '/unsubscribe' | '/_app/$organizationId' | '/_login/login' | '/_login/reset-password' @@ -933,6 +945,7 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRouteWithChildren PublicRoute: typeof PublicRouteWithChildren StepsRoute: typeof StepsRouteWithChildren + UnsubscribeRoute: typeof UnsubscribeRoute ApiConfigRoute: typeof ApiConfigRoute ApiHealthcheckRoute: typeof ApiHealthcheckRoute WidgetCounterRoute: typeof WidgetCounterRoute @@ -945,6 +958,13 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/unsubscribe': { + id: '/unsubscribe' + path: '/unsubscribe' + fullPath: '/unsubscribe' + preLoaderRoute: typeof UnsubscribeRouteImport + parentRoute: typeof rootRouteImport + } '/_steps': { id: '/_steps' path: '' @@ -1872,6 +1892,7 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRouteWithChildren, PublicRoute: PublicRouteWithChildren, StepsRoute: StepsRouteWithChildren, + UnsubscribeRoute: UnsubscribeRoute, ApiConfigRoute: ApiConfigRoute, ApiHealthcheckRoute: ApiHealthcheckRoute, WidgetCounterRoute: WidgetCounterRoute, diff --git a/apps/start/src/routes/unsubscribe.tsx b/apps/start/src/routes/unsubscribe.tsx new file mode 100644 index 00000000..1fbcbdee --- /dev/null +++ b/apps/start/src/routes/unsubscribe.tsx @@ -0,0 +1,112 @@ +import { FullPageEmptyState } from '@/components/full-page-empty-state'; +import FullPageLoadingState from '@/components/full-page-loading-state'; +import { LoginNavbar } from '@/components/login-navbar'; +import { useTRPC } from '@/integrations/trpc/react'; +import { emailCategories } from '@openpanel/constants'; +import { createFileRoute, useSearch } from '@tanstack/react-router'; +import { useState } from 'react'; +import { z } from 'zod'; + +const unsubscribeSearchSchema = z.object({ + email: z.string().email(), + category: z.string(), + token: z.string(), +}); + +export const Route = createFileRoute('/unsubscribe')({ + component: RouteComponent, + validateSearch: unsubscribeSearchSchema, + pendingComponent: FullPageLoadingState, +}); + +function RouteComponent() { + const search = useSearch({ from: '/unsubscribe' }); + const { email, category, token } = search; + const trpc = useTRPC(); + const [isUnsubscribing, setIsUnsubscribing] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [error, setError] = useState(null); + + const unsubscribeMutation = trpc.email.unsubscribe.useMutation({ + onSuccess: () => { + setIsSuccess(true); + setIsUnsubscribing(false); + }, + onError: (err) => { + setError(err.message || 'Failed to unsubscribe'); + setIsUnsubscribing(false); + }, + }); + + const handleUnsubscribe = () => { + setIsUnsubscribing(true); + setError(null); + unsubscribeMutation.mutate({ email, category, token }); + }; + + const categoryName = + emailCategories[category as keyof typeof emailCategories] || category; + + if (isSuccess) { + return ( +
+ +
+
+
+

Unsubscribed

+

+ You've been unsubscribed from {categoryName} emails. +

+

+ You won't receive any more {categoryName.toLowerCase()} emails from + us. +

+
+
+
+ ); + } + + return ( +
+ +
+
+
+

Unsubscribe

+

+ Unsubscribe from {categoryName} emails? +

+

+ You'll stop receiving {categoryName.toLowerCase()} emails sent to{' '} + {email} +

+
+ + {error && ( +
+ {error} +
+ )} + +
+ + + Cancel + +
+
+
+
+ ); +} diff --git a/apps/worker/package.json b/apps/worker/package.json index ecd9bbc3..7c3c1572 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -20,6 +20,7 @@ "@openpanel/json": "workspace:*", "@openpanel/logger": "workspace:*", "@openpanel/importer": "workspace:*", + "@openpanel/payments": "workspace:*", "@openpanel/queue": "workspace:*", "@openpanel/redis": "workspace:*", "bullmq": "^5.63.0", diff --git a/apps/worker/src/jobs/cron.onboarding.ts b/apps/worker/src/jobs/cron.onboarding.ts index f13a0ce4..b21eb0ee 100644 --- a/apps/worker/src/jobs/cron.onboarding.ts +++ b/apps/worker/src/jobs/cron.onboarding.ts @@ -1,180 +1,265 @@ -import { differenceInDays } from 'date-fns'; import type { Job } from 'bullmq'; +import { differenceInDays } from 'date-fns'; import { db } from '@openpanel/db'; -import { sendEmail } from '@openpanel/email'; +import { + type EmailData, + type EmailTemplate, + sendEmail, +} from '@openpanel/email'; import type { CronQueuePayload } from '@openpanel/queue'; +import { getRecommendedPlan } from '@openpanel/payments'; import { logger } from '../utils/logger'; -const EMAIL_SCHEDULE = { - 1: 0, // Welcome email - Day 0 - 2: 2, // What to track - Day 2 - 3: 6, // Dashboards - Day 6 - 4: 14, // Replace stack - Day 14 - 5: 26, // Trial ending - Day 26 +// Types for the onboarding email system +const orgQuery = { + include: { + createdBy: { + select: { + id: true, + email: true, + firstName: true, + deletedAt: true, + }, + }, + }, } as const; +type OrgWithCreator = Awaited< + ReturnType> +>[number]; + +type OnboardingContext = { + org: OrgWithCreator; + user: NonNullable; +}; + +type OnboardingEmail = { + day: number; + template: T; + shouldSend?: (ctx: OnboardingContext) => Promise; + data: (ctx: OnboardingContext) => EmailData; +}; + +// Helper to create type-safe email entries with correlated template/data types +function email(config: OnboardingEmail) { + return config; +} + +const getters = { + firstName: (ctx: OnboardingContext) => ctx.user.firstName || undefined, + organizationName: (ctx: OnboardingContext) => ctx.org.name, + dashboardUrl: (ctx: OnboardingContext) => { + return `${process.env.DASHBOARD_URL}/${ctx.org.id}`; + }, + billingUrl: (ctx: OnboardingContext) => { + return `${process.env.DASHBOARD_URL}/${ctx.org.id}/billing`; + }, + recommendedPlan: (ctx: OnboardingContext) => { + return getRecommendedPlan( + ctx.org.subscriptionPeriodEventsCount, + (plan) => + `${plan.formattedEvents} events per month for ${plan.formattedPrice}`, + ); + }, +} as const; + +// Declarative email schedule - easy to add, remove, or reorder +const ONBOARDING_EMAILS = [ + email({ + day: 0, + template: 'onboarding-welcome', + data: (ctx) => ({ + firstName: getters.firstName(ctx), + dashboardUrl: getters.dashboardUrl(ctx), + }), + }), + email({ + day: 2, + template: 'onboarding-what-to-track', + data: (ctx) => ({ + firstName: getters.firstName(ctx), + }), + }), + email({ + day: 6, + template: 'onboarding-dashboards', + data: (ctx) => ({ + firstName: getters.firstName(ctx), + dashboardUrl: getters.dashboardUrl(ctx), + }), + }), + email({ + day: 14, + template: 'onboarding-featue-request', + data: (ctx) => ({ + firstName: getters.firstName(ctx), + }), + }), + email({ + day: 26, + template: 'onboarding-trial-ending', + shouldSend: async ({ org }) => { + if (org.subscriptionStatus === 'active') { + return 'complete'; + } + return true; + }, + data: (ctx) => { + return { + firstName: getters.firstName(ctx), + organizationName: getters.organizationName(ctx), + billingUrl: getters.billingUrl(ctx), + recommendedPlan: getters.recommendedPlan(ctx), + }; + }, + }), + email({ + day: 30, + template: 'onboarding-trial-ended', + shouldSend: async ({ org }) => { + if (org.subscriptionStatus === 'active') { + return 'complete'; + } + return true; + }, + data: (ctx) => { + return { + firstName: getters.firstName(ctx), + billingUrl: getters.billingUrl(ctx), + recommendedPlan: getters.recommendedPlan(ctx), + }; + }, + }), +]; + export async function onboardingJob(job: Job) { logger.info('Starting onboarding email job'); - // Fetch organizations with their creators who are in onboarding - const organizations = await db.organization.findMany({ + // Fetch organizations that are in onboarding (not completed) + const orgs = await db.organization.findMany({ where: { - createdByUserId: { - not: null, + onboarding: { + not: 'completed', }, + deleteAt: null, createdBy: { - onboarding: { - not: null, - gte: 1, - lte: 5, - }, deletedAt: null, }, }, - include: { - createdBy: { - select: { - id: true, - email: true, - firstName: true, - lastName: true, - onboarding: true, - }, - }, - }, + ...orgQuery, }); - logger.info(`Found ${organizations.length} organizations with creators in onboarding`); + logger.info(`Found ${orgs.length} organizations in onboarding`); let emailsSent = 0; - let usersCompleted = 0; - let usersSkipped = 0; + let orgsCompleted = 0; + let orgsSkipped = 0; - for (const org of organizations) { - if (!org.createdBy || !org.createdByUserId) { + for (const org of orgs) { + // Skip if no creator or creator is deleted + if (!org.createdBy || org.createdBy.deletedAt) { + orgsSkipped++; continue; } const user = org.createdBy; - - // Check if organization has active subscription - if (org.subscriptionStatus === 'active') { - // Stop onboarding for users with active subscriptions - await db.user.update({ - where: { id: user.id }, - data: { onboarding: null }, - }); - usersCompleted++; - logger.info(`Stopped onboarding for user ${user.id} (active subscription)`); - continue; - } - - if (!user.onboarding || user.onboarding < 1 || user.onboarding > 5) { - continue; - } - - // Use organization creation date instead of user registration date const daysSinceOrgCreation = differenceInDays(new Date(), org.createdAt); - const requiredDays = EMAIL_SCHEDULE[user.onboarding as keyof typeof EMAIL_SCHEDULE]; - if (daysSinceOrgCreation < requiredDays) { - usersSkipped++; + // Find the next email to send + // If org.onboarding is empty string, they haven't received any email yet + const lastSentIndex = org.onboarding + ? ONBOARDING_EMAILS.findIndex((e) => e.template === org.onboarding) + : -1; + const nextEmailIndex = lastSentIndex + 1; + + // No more emails to send + if (nextEmailIndex >= ONBOARDING_EMAILS.length) { + await db.organization.update({ + where: { id: org.id }, + data: { onboarding: 'completed' }, + }); + orgsCompleted++; + logger.info( + `Completed onboarding for organization ${org.id} (all emails sent)`, + ); continue; } - const dashboardUrl = `${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL || 'https://dashboard.openpanel.dev'}/${org.id}`; - const billingUrl = `${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL || 'https://dashboard.openpanel.dev'}/${org.id}/billing`; + const nextEmail = ONBOARDING_EMAILS[nextEmailIndex]; + if (!nextEmail) { + continue; + } - try { - // Send appropriate email based on onboarding step - switch (user.onboarding) { - case 1: { - // Welcome email - await sendEmail('onboarding-welcome', { - to: user.email, - data: { - firstName: user.firstName || undefined, - dashboardUrl, - }, - }); - break; - } - case 2: { - // What to track email - await sendEmail('onboarding-what-to-track', { - to: user.email, - data: { - firstName: user.firstName || undefined, - }, - }); - break; - } - case 3: { - // Dashboards email - await sendEmail('onboarding-dashboards', { - to: user.email, - data: { - firstName: user.firstName || undefined, - dashboardUrl, - }, - }); - break; - } - case 4: { - // Replace stack email - await sendEmail('onboarding-replace-stack', { - to: user.email, - data: { - firstName: user.firstName || undefined, - }, - }); - break; - } - case 5: { - // Trial ending email - await sendEmail('onboarding-trial-ending', { - to: user.email, - data: { - firstName: user.firstName || undefined, - organizationName: org.name, - billingUrl, - recommendedPlan: undefined, // TODO: Calculate based on usage - }, - }); - break; - } + // Check if enough days have passed + if (daysSinceOrgCreation < nextEmail.day) { + orgsSkipped++; + continue; + } + + // Check shouldSend callback if defined + if (nextEmail.shouldSend) { + const result = await nextEmail.shouldSend({ org, user }); + + if (result === 'complete') { + await db.organization.update({ + where: { id: org.id }, + data: { onboarding: 'completed' }, + }); + orgsCompleted++; + logger.info( + `Completed onboarding for organization ${org.id} (shouldSend returned complete)`, + ); + continue; } - // Increment onboarding state - const nextOnboardingState = user.onboarding + 1; - await db.user.update({ - where: { id: user.id }, - data: { - onboarding: nextOnboardingState > 5 ? null : nextOnboardingState, - }, + if (result === false) { + orgsSkipped++; + continue; + } + } + + try { + const emailData = nextEmail.data({ org, user }); + + await sendEmail(nextEmail.template, { + to: user.email, + data: emailData as never, + }); + + // Update onboarding to the template name we just sent + await db.organization.update({ + where: { id: org.id }, + data: { onboarding: nextEmail.template }, }); emailsSent++; - logger.info(`Sent onboarding email ${user.onboarding} to user ${user.id} for org ${org.id}`); - - if (nextOnboardingState > 5) { - usersCompleted++; - } + logger.info( + `Sent onboarding email "${nextEmail.template}" to organization ${org.id} (user ${user.id})`, + ); } catch (error) { - logger.error(`Failed to send onboarding email to user ${user.id}`, { - error, - onboardingStep: user.onboarding, - organizationId: org.id, - }); + logger.error( + `Failed to send onboarding email to organization ${org.id}`, + { + error, + template: nextEmail.template, + }, + ); } } logger.info('Completed onboarding email job', { - totalOrganizations: organizations.length, + totalOrgs: orgs.length, emailsSent, - usersCompleted, - usersSkipped, + orgsCompleted, + orgsSkipped, }); + + return { + totalOrgs: orgs.length, + emailsSent, + orgsCompleted, + orgsSkipped, + }; } diff --git a/packages/constants/index.ts b/packages/constants/index.ts index 27cf4119..077697a4 100644 --- a/packages/constants/index.ts +++ b/packages/constants/index.ts @@ -508,6 +508,13 @@ export function getCountry(code?: string) { return countries[code as keyof typeof countries]; } +export const emailCategories = { + onboarding: 'Onboarding', + billing: 'Billing', +} as const; + +export type EmailCategory = keyof typeof emailCategories; + export const chartColors = [ { main: '#2563EB', translucent: 'rgba(37, 99, 235, 0.1)' }, { main: '#ff7557', translucent: 'rgba(255, 117, 87, 0.1)' }, diff --git a/packages/db/prisma/migrations/20260120110632_/migration.sql b/packages/db/prisma/migrations/20260120110632_/migration.sql deleted file mode 100644 index 73984e66..00000000 --- a/packages/db/prisma/migrations/20260120110632_/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "public"."users" ADD COLUMN "onboarding" INTEGER; diff --git a/packages/db/prisma/migrations/20260120230539_onboarding_to_organization/migration.sql b/packages/db/prisma/migrations/20260120230539_onboarding_to_organization/migration.sql new file mode 100644 index 00000000..bcd49719 --- /dev/null +++ b/packages/db/prisma/migrations/20260120230539_onboarding_to_organization/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "public"."organizations" +ADD COLUMN "onboarding" TEXT NOT NULL DEFAULT 'completed'; \ No newline at end of file diff --git a/packages/db/prisma/migrations/20260121071611_add_unsubscribe_email/migration.sql b/packages/db/prisma/migrations/20260121071611_add_unsubscribe_email/migration.sql new file mode 100644 index 00000000..b2c9b584 --- /dev/null +++ b/packages/db/prisma/migrations/20260121071611_add_unsubscribe_email/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "public"."email_unsubscribes" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "email" TEXT NOT NULL, + "category" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "email_unsubscribes_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "email_unsubscribes_email_category_key" ON "public"."email_unsubscribes"("email", "category"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 0a41b7fd..a9457a09 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -62,6 +62,7 @@ model Organization { integrations Integration[] invites Invite[] timezone String? + onboarding String @default("completed") // 'completed' or template name for next email // Subscription subscriptionId String? @@ -94,7 +95,6 @@ model User { email String @unique firstName String? lastName String? - onboarding Int? // null = disabled/completed, 1-5 = next email step createdOrganizations Organization[] @relation("organizationCreatedBy") subscriptions Organization[] @relation("subscriptionCreatedBy") membership Member[] @@ -611,3 +611,13 @@ model InsightEvent { @@index([insightId, createdAt]) @@map("insight_events") } + +model EmailUnsubscribe { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + email String + category String + createdAt DateTime @default(now()) + + @@unique([email, category]) + @@map("email_unsubscribes") +} diff --git a/packages/email/package.json b/packages/email/package.json index a5664bcb..27ae2e2e 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -8,6 +8,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@openpanel/db": "workspace:*", "@react-email/components": "^0.5.6", "react": "catalog:", "react-dom": "catalog:", diff --git a/packages/email/src/components/button.tsx b/packages/email/src/components/button.tsx new file mode 100644 index 00000000..e376fb25 --- /dev/null +++ b/packages/email/src/components/button.tsx @@ -0,0 +1,23 @@ +import { Button as EmailButton } from '@react-email/components'; + +export function Button({ + href, + children, + style, +}: { href: string; children: React.ReactNode; style?: React.CSSProperties }) { + return ( + + {children} + + ); +} diff --git a/packages/email/src/components/list.tsx b/packages/email/src/components/list.tsx new file mode 100644 index 00000000..dbbd9afd --- /dev/null +++ b/packages/email/src/components/list.tsx @@ -0,0 +1,13 @@ +import { Text } from '@react-email/components'; + +export function List({ items }: { items: React.ReactNode[] }) { + return ( +
    + {items.map((node, index) => ( +
  • + {node} +
  • + ))} +
+ ); +} diff --git a/packages/email/src/emails/index.tsx b/packages/email/src/emails/index.tsx index 74006631..b6f7abbb 100644 --- a/packages/email/src/emails/index.tsx +++ b/packages/email/src/emails/index.tsx @@ -3,22 +3,23 @@ import { EmailInvite, zEmailInvite } from './email-invite'; import EmailResetPassword, { zEmailResetPassword, } from './email-reset-password'; -import TrailEndingSoon, { zTrailEndingSoon } from './trial-ending-soon'; -import OnboardingWelcome, { - zOnboardingWelcome, -} from './onboarding-welcome'; -import OnboardingWhatToTrack, { - zOnboardingWhatToTrack, -} from './onboarding-what-to-track'; import OnboardingDashboards, { zOnboardingDashboards, } from './onboarding-dashboards'; -import OnboardingReplaceStack, { - zOnboardingReplaceStack, -} from './onboarding-replace-stack'; +import OnboardingFeatureRequest, { + zOnboardingFeatureRequest, +} from './onboarding-feature-request'; +import OnboardingTrialEnded, { + zOnboardingTrialEnded, +} from './onboarding-trial-ended'; import OnboardingTrialEnding, { zOnboardingTrialEnding, } from './onboarding-trial-ending'; +import OnboardingWelcome, { zOnboardingWelcome } from './onboarding-welcome'; +import OnboardingWhatToTrack, { + zOnboardingWhatToTrack, +} from './onboarding-what-to-track'; +import TrailEndingSoon, { zTrailEndingSoon } from './trial-ending-soon'; export const templates = { invite: { @@ -38,31 +39,43 @@ export const templates = { 'Your trial is ending soon', Component: TrailEndingSoon, schema: zTrailEndingSoon, + category: 'billing' as const, }, 'onboarding-welcome': { subject: () => "You're in", Component: OnboardingWelcome, schema: zOnboardingWelcome, + category: 'onboarding' as const, }, 'onboarding-what-to-track': { subject: () => "What's actually worth tracking", Component: OnboardingWhatToTrack, schema: zOnboardingWhatToTrack, + category: 'onboarding' as const, }, 'onboarding-dashboards': { subject: () => 'The part most people skip', Component: OnboardingDashboards, schema: zOnboardingDashboards, + category: 'onboarding' as const, }, - 'onboarding-replace-stack': { + 'onboarding-featue-request': { subject: () => 'One provider to rule them all', - Component: OnboardingReplaceStack, - schema: zOnboardingReplaceStack, + Component: OnboardingFeatureRequest, + schema: zOnboardingFeatureRequest, + category: 'onboarding' as const, }, 'onboarding-trial-ending': { subject: () => 'Your trial ends in a few days', Component: OnboardingTrialEnding, schema: zOnboardingTrialEnding, + category: 'onboarding' as const, + }, + 'onboarding-trial-ended': { + subject: () => 'Your trial has ended', + Component: OnboardingTrialEnded, + schema: zOnboardingTrialEnded, + category: 'onboarding' as const, }, } as const; diff --git a/packages/email/src/emails/onboarding-dashboards.tsx b/packages/email/src/emails/onboarding-dashboards.tsx index 902c7f16..d49e23fd 100644 --- a/packages/email/src/emails/onboarding-dashboards.tsx +++ b/packages/email/src/emails/onboarding-dashboards.tsx @@ -2,6 +2,7 @@ import { Link, Text } from '@react-email/components'; import React from 'react'; import { z } from 'zod'; import { Layout } from '../components/layout'; +import { List } from '../components/list'; export const zOnboardingDashboards = z.object({ firstName: z.string().optional(), @@ -30,20 +31,34 @@ export function OnboardingDashboards({ If you haven't yet, try building a simple dashboard. Pick one thing you care about and visualize it. Could be: - - - How many people sign up and then actually do something - - - Where users drop off in a flow (funnel) - - Which pages lead to conversions (entry page → CTA) + This is usually when people go from "I have analytics" to "I understand what's happening." It's a different feeling. Takes maybe 10 minutes to set up. Worth it. - Create your first dashboard + Best regards, +
+ Carl
- Carl + + Dashboard + ); } diff --git a/packages/email/src/emails/onboarding-feature-request.tsx b/packages/email/src/emails/onboarding-feature-request.tsx new file mode 100644 index 00000000..a0a8b289 --- /dev/null +++ b/packages/email/src/emails/onboarding-feature-request.tsx @@ -0,0 +1,39 @@ +import { Link, Text } from '@react-email/components'; +import React from 'react'; +import { z } from 'zod'; +import { Layout } from '../components/layout'; + +export const zOnboardingFeatureRequest = z.object({ + firstName: z.string().optional(), +}); + +export type Props = z.infer; +export default OnboardingFeatureRequest; +export function OnboardingFeatureRequest({ firstName }: Props) { + return ( + + Hi{firstName ? ` ${firstName}` : ''}, + + OpenPanel aims to be the one stop shop for all your analytics needs. + + + We have already in a very short time become one of the most popular + open-source analytics platforms out there and we're working hard to add + more features to make it the best analytics platform. + + + Do you feel like you're missing a feature that's important to you? If + that's the case, please reply here or go to our feedback board and add + your request there. + + + Feedback board + + + Best regards, +
+ Carl +
+
+ ); +} diff --git a/packages/email/src/emails/onboarding-replace-stack.tsx b/packages/email/src/emails/onboarding-replace-stack.tsx deleted file mode 100644 index 1e248719..00000000 --- a/packages/email/src/emails/onboarding-replace-stack.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Text } from '@react-email/components'; -import React from 'react'; -import { z } from 'zod'; -import { Layout } from '../components/layout'; - -export const zOnboardingReplaceStack = z.object({ - firstName: z.string().optional(), -}); - -export type Props = z.infer; -export default OnboardingReplaceStack; -export function OnboardingReplaceStack({ - firstName, -}: Props) { - return ( - - Hi{firstName ? ` ${firstName}` : ''}, - - A lot of people who sign up are using multiple tools: something for - traffic, something for product analytics and something else for seeing - raw events. - - OpenPanel can replace that whole setup. - - If you're still thinking of web analytics and product analytics as - separate things, try combining them in a single dashboard. Traffic - sources on top, user behavior below. That view tends to be more useful - than either one alone. - - - OpenPanel should be able to replace all of them, you can just reach out - if you feel like something is missing. - - Carl - - ); -} diff --git a/packages/email/src/emails/onboarding-trial-ended.tsx b/packages/email/src/emails/onboarding-trial-ended.tsx new file mode 100644 index 00000000..8b61db3d --- /dev/null +++ b/packages/email/src/emails/onboarding-trial-ended.tsx @@ -0,0 +1,55 @@ +import { Text } from '@react-email/components'; +import React from 'react'; +import { z } from 'zod'; +import { Button } from '../components/button'; +import { Layout } from '../components/layout'; + +export const zOnboardingTrialEnded = z.object({ + firstName: z.string().optional(), + billingUrl: z.string(), + recommendedPlan: z.string().optional(), +}); + +export type Props = z.infer; +export default OnboardingTrialEnded; +export function OnboardingTrialEnded({ + firstName, + billingUrl = 'https://dashboard.openpanel.dev', + recommendedPlan, +}: Props) { + const newUrl = new URL(billingUrl); + newUrl.searchParams.set('utm_source', 'email'); + newUrl.searchParams.set('utm_medium', 'email'); + newUrl.searchParams.set('utm_campaign', 'onboarding-trial-ended'); + + return ( + + Hi{firstName ? ` ${firstName}` : ''}, + Your OpenPanel trial has ended. + + Your tracking is still running in the background, but you won't be able + to see any new data until you upgrade. All your dashboards, reports, and + event history are still there waiting for you. + + + Important: If you don't upgrade within 30 days, your workspace and + projects will be permanently deleted. + + + To keep your data and continue using OpenPanel, upgrade to a paid plan.{' '} + {recommendedPlan + ? `Based on your usage we recommend upgrading to the ${recommendedPlan}` + : 'Plans start at $2.50/month'} + . + + + If you have any questions or something's holding you back, just reply to + this email. + + + + + Carl + + ); +} diff --git a/packages/email/src/emails/onboarding-trial-ending.tsx b/packages/email/src/emails/onboarding-trial-ending.tsx index 3c8ef0be..aa7e4fef 100644 --- a/packages/email/src/emails/onboarding-trial-ending.tsx +++ b/packages/email/src/emails/onboarding-trial-ending.tsx @@ -1,6 +1,7 @@ -import { Button, Link, Text } from '@react-email/components'; +import { Text } from '@react-email/components'; import React from 'react'; import { z } from 'zod'; +import { Button } from '../components/button'; import { Layout } from '../components/layout'; export const zOnboardingTrialEnding = z.object({ @@ -33,32 +34,21 @@ export function OnboardingTrialEnding({ event history) stays intact. - If OpenPanel has been useful, upgrading just keeps it going. Plans - start at $2.50/month - {recommendedPlan ? ` and based on your usage we recommend ${recommendedPlan}` : ''} + To continue using OpenPanel, you'll need to upgrade to a paid plan.{' '} + {recommendedPlan + ? `Based on your usage we recommend upgrading to the ${recommendedPlan} plan` + : 'Plans start at $2.50/month'} . - If something's holding you back, I'd like to hear about it. Just - reply. + If something's holding you back, I'd like to hear about it. Just reply. Your project will recieve events for the next 30 days, if you haven't upgraded by then we'll remove your workspace and projects. - + Carl diff --git a/packages/email/src/emails/onboarding-welcome.tsx b/packages/email/src/emails/onboarding-welcome.tsx index 5929b6bc..727fee0f 100644 --- a/packages/email/src/emails/onboarding-welcome.tsx +++ b/packages/email/src/emails/onboarding-welcome.tsx @@ -1,7 +1,8 @@ -import { Link, Text } from '@react-email/components'; +import { Heading, Link, Text } from '@react-email/components'; import React from 'react'; import { z } from 'zod'; import { Layout } from '../components/layout'; +import { List } from '../components/list'; export const zOnboardingWelcome = z.object({ firstName: z.string().optional(), @@ -10,34 +11,42 @@ export const zOnboardingWelcome = z.object({ export type Props = z.infer; export default OnboardingWelcome; -export function OnboardingWelcome({ - firstName, - dashboardUrl = 'https://dashboard.openpanel.dev', -}: Props) { - const newUrl = new URL(dashboardUrl); - newUrl.searchParams.set('utm_source', 'email'); - newUrl.searchParams.set('utm_medium', 'email'); - newUrl.searchParams.set('utm_campaign', 'onboarding-welcome'); - +export function OnboardingWelcome({ firstName }: Props) { return ( Hi{firstName ? ` ${firstName}` : ''}, Thanks for trying OpenPanel. - We built OpenPanel because most analytics tools are either too expensive, - too complicated, or both. OpenPanel is different. + We built OpenPanel because most analytics tools are either too + expensive, too complicated, or both. OpenPanel is different. - If you already have setup your tracking you should see your dashboard - getting filled up. If you come from another provider and want to import - your old events you can do that in our{' '} - project settings. + We hope you find OpenPanel useful and if you have any questions, + regarding tracking or how to import your existing events, just reach + out. We're here to help. + To get started, you can: + + Install tracking script + , + + Start tracking your events + , + ]} + /> - If you can't find your provider just reach out and we'll help you out. + Best regards, +
+ Carl
- Reach out if you have any questions. I answer all emails. - Carl
); } diff --git a/packages/email/src/emails/onboarding-what-to-track.tsx b/packages/email/src/emails/onboarding-what-to-track.tsx index ac94a15a..556dde50 100644 --- a/packages/email/src/emails/onboarding-what-to-track.tsx +++ b/packages/email/src/emails/onboarding-what-to-track.tsx @@ -2,6 +2,7 @@ import { Text } from '@react-email/components'; import React from 'react'; import { z } from 'zod'; import { Layout } from '../components/layout'; +import { List } from '../components/list'; export const zOnboardingWhatToTrack = z.object({ firstName: z.string().optional(), @@ -9,33 +10,34 @@ export const zOnboardingWhatToTrack = z.object({ export type Props = z.infer; export default OnboardingWhatToTrack; -export function OnboardingWhatToTrack({ - firstName, -}: Props) { +export function OnboardingWhatToTrack({ firstName }: Props) { return ( Hi{firstName ? ` ${firstName}` : ''}, - Track the moments that tell you whether your product is working. Track - things that matters to your product the most and then you can easily - create funnels or conversions reports to understand what happening. + Tracking can be overwhelming at first, and that's why its important to + focus on what's matters. For most products, that's something like: - For most products, that's something like: - - Signups + - - The first meaningful action (create something, send something, buy - something) - - - Return visits - - You don't need 50 events. Five good ones will tell you more than fifty - random ones. + Start small and incrementally add more events as you go is usually the + best approach. - If you're not sure whether something's worth tracking, just ask. I'm - happy to look at your setup. + If you're not sure whether something's worth tracking, or have any + questions, just reply here. + + + Best regards, +
+ Carl
- Carl
); } diff --git a/packages/email/src/index.tsx b/packages/email/src/index.tsx index e61bcd58..f1bedd34 100644 --- a/packages/email/src/index.tsx +++ b/packages/email/src/index.tsx @@ -2,34 +2,72 @@ import React from 'react'; import { Resend } from 'resend'; import type { z } from 'zod'; +import { db } from '@openpanel/db'; import { type TemplateKey, type Templates, templates } from './emails'; +import { getUnsubscribeUrl } from './unsubscribe'; + +export * from './unsubscribe'; const FROM = process.env.EMAIL_SENDER ?? 'hello@openpanel.dev'; +export type EmailData = z.infer; +export type EmailTemplate = keyof Templates; + export async function sendEmail( - template: T, + templateKey: T, options: { - to: string | string[]; + to: string; data: z.infer; }, ) { const { to, data } = options; - const { subject, Component, schema } = templates[template]; - const props = schema.safeParse(data); + const template = templates[templateKey]; + const props = template.schema.safeParse(data); if (!props.success) { console.error('Failed to parse data', props.error); return null; } + // Check if user has unsubscribed from this category (only for non-transactional emails) + if ('category' in template && template.category) { + const unsubscribed = await db.emailUnsubscribe.findUnique({ + where: { + email_category: { + email: to, + category: template.category, + }, + }, + }); + + if (unsubscribed) { + console.log( + `Skipping email to ${to} - unsubscribed from ${template.category}`, + ); + return null; + } + } + if (!process.env.RESEND_API_KEY) { console.log('No RESEND_API_KEY found, here is the data'); - console.log(data); + console.log('Template:', template); + // @ts-expect-error - TODO: fix this + console.log('Subject: ', subject(props.data)); + console.log('To: ', to); + console.log('Data: ', JSON.stringify(data, null, 2)); return null; } const resend = new Resend(process.env.RESEND_API_KEY); + // Build headers for unsubscribe (only for non-transactional emails) + const headers: Record = {}; + if ('category' in template && template.category) { + const unsubscribeUrl = getUnsubscribeUrl(to, template.category); + headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`; + headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'; + } + try { const res = await resend.emails.send({ from: FROM, @@ -38,12 +76,11 @@ export async function sendEmail( subject: subject(props.data), // @ts-expect-error - TODO: fix this react: , + headers: Object.keys(headers).length > 0 ? headers : undefined, }); - if (res.error) { throw new Error(res.error.message); } - return res; } catch (error) { console.error('Failed to send email', error); diff --git a/packages/email/src/unsubscribe.ts b/packages/email/src/unsubscribe.ts new file mode 100644 index 00000000..baeef6fd --- /dev/null +++ b/packages/email/src/unsubscribe.ts @@ -0,0 +1,28 @@ +import { createHmac } from 'crypto'; + +const SECRET = + process.env.UNSUBSCRIBE_SECRET || + process.env.COOKIE_SECRET || + process.env.SECRET || + 'default-secret-change-in-production'; + +export function generateUnsubscribeToken(email: string, category: string): string { + const data = `${email}:${category}`; + return createHmac('sha256', SECRET).update(data).digest('hex'); +} + +export function verifyUnsubscribeToken( + email: string, + category: string, + token: string, +): boolean { + const expectedToken = generateUnsubscribeToken(email, category); + return token === expectedToken; +} + +export function getUnsubscribeUrl(email: string, category: string): string { + const token = generateUnsubscribeToken(email, category); + const params = new URLSearchParams({ email, category, token }); + const dashboardUrl = process.env.DASHBOARD_URL || 'http://localhost:3000'; + return `${dashboardUrl}/unsubscribe?${params.toString()}`; +} diff --git a/packages/payments/src/prices.ts b/packages/payments/src/prices.ts index dbe728b4..6e3fb719 100644 --- a/packages/payments/src/prices.ts +++ b/packages/payments/src/prices.ts @@ -1,5 +1,11 @@ export type { ProductPrice } from '@polar-sh/sdk/models/components/productprice.js'; +function formatEventsCount(events: number) { + return new Intl.NumberFormat('en-gb', { + notation: 'compact', + }).format(events); +} + export type IPrice = { price: number; events: number; @@ -39,3 +45,29 @@ export const FREE_PRODUCT_IDS = [ 'a18b4bee-d3db-4404-be6f-fba2f042d9ed', // Prod '036efa2a-b3b4-4c75-b24a-9cac6bb8893b', // Sandbox ]; + +export function getRecommendedPlan( + monthlyEvents: number | undefined | null, + cb: ( + options: { + formattedEvents: string; + formattedPrice: string; + } & IPrice, + ) => T, +): T | undefined { + if (!monthlyEvents) { + return undefined; + } + const price = PRICING.find((price) => price.events >= monthlyEvents); + if (!price) { + return undefined; + } + return cb({ + ...price, + formattedEvents: formatEventsCount(price.events), + formattedPrice: Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(price.price), + }); +} diff --git a/packages/queue/src/queues.ts b/packages/queue/src/queues.ts index 93df0925..32b21a4c 100644 --- a/packages/queue/src/queues.ts +++ b/packages/queue/src/queues.ts @@ -259,18 +259,3 @@ export const insightsQueue = new Queue( }, }, ); - -export function addTrialEndingSoonJob(organizationId: string, delay: number) { - return miscQueue.add( - 'misc', - { - type: 'trialEndingSoon', - payload: { - organizationId, - }, - }, - { - delay, - }, - ); -} diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts index 88c19045..068a321d 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -3,6 +3,7 @@ import { chartRouter } from './routers/chart'; import { chatRouter } from './routers/chat'; import { clientRouter } from './routers/client'; import { dashboardRouter } from './routers/dashboard'; +import { emailRouter } from './routers/email'; import { eventRouter } from './routers/event'; import { importRouter } from './routers/import'; import { insightRouter } from './routers/insight'; @@ -51,6 +52,7 @@ export const appRouter = createTRPCRouter({ chat: chatRouter, insight: insightRouter, widget: widgetRouter, + email: emailRouter, }); // export type definition of API diff --git a/packages/trpc/src/routers/auth.ts b/packages/trpc/src/routers/auth.ts index fa22019a..912b6a87 100644 --- a/packages/trpc/src/routers/auth.ts +++ b/packages/trpc/src/routers/auth.ts @@ -353,7 +353,6 @@ export const authRouter = createTRPCRouter({ .input(zSignInShare) .mutation(async ({ input, ctx }) => { const { password, shareId, shareType = 'overview' } = input; - let share: { password: string | null; public: boolean } | null = null; let cookieName = ''; diff --git a/packages/trpc/src/routers/email.ts b/packages/trpc/src/routers/email.ts new file mode 100644 index 00000000..5e149d5d --- /dev/null +++ b/packages/trpc/src/routers/email.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import { db } from '@openpanel/db'; +import { verifyUnsubscribeToken } from '@openpanel/email'; +import { createTRPCRouter, publicProcedure } from '../trpc'; + +export const emailRouter = createTRPCRouter({ + unsubscribe: publicProcedure + .input( + z.object({ + email: z.string().email(), + category: z.string(), + token: z.string(), + }), + ) + .mutation(async ({ input }) => { + const { email, category, token } = input; + + // Verify token + if (!verifyUnsubscribeToken(email, category, token)) { + throw new Error('Invalid unsubscribe link'); + } + + // Upsert the unsubscribe record + await db.emailUnsubscribe.upsert({ + where: { + email_category: { + email, + category, + }, + }, + create: { + email, + category, + }, + update: {}, + }); + + return { success: true }; + }), +}); diff --git a/packages/trpc/src/routers/onboarding.ts b/packages/trpc/src/routers/onboarding.ts index 616dce5b..a49b5435 100644 --- a/packages/trpc/src/routers/onboarding.ts +++ b/packages/trpc/src/routers/onboarding.ts @@ -8,7 +8,6 @@ import { zOnboardingProject } from '@openpanel/validation'; import { hashPassword } from '@openpanel/common/server'; import { addDays } from 'date-fns'; -import { addTrialEndingSoonJob, miscQueue } from '../../../queue'; import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; async function createOrGetOrganization( @@ -22,13 +21,6 @@ async function createOrGetOrganization( const TRIAL_DURATION_IN_DAYS = 30; if (input.organization) { - // Check if this is the user's first organization - const existingOrgCount = await db.organization.count({ - where: { - createdByUserId: user.id, - }, - }); - const organization = await db.organization.create({ data: { id: await getId('organization', input.organization), @@ -37,24 +29,10 @@ async function createOrGetOrganization( subscriptionEndsAt: addDays(new Date(), TRIAL_DURATION_IN_DAYS), subscriptionStatus: 'trialing', timezone: input.timezone, + onboarding: 'onboarding-welcome', }, }); - // Set onboarding = 1 for first organization creation - if (existingOrgCount === 0 && user.onboarding === null) { - await db.user.update({ - where: { id: user.id }, - data: { onboarding: 1 }, - }); - } - - if (!process.env.SELF_HOSTED) { - await addTrialEndingSoonJob( - organization.id, - 1000 * 60 * 60 * 24 * TRIAL_DURATION_IN_DAYS * 0.9, - ); - } - return organization; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66f793fe..6fb2d4d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ overrides: patchedDependencies: nuqs: - hash: w6thjv3pgywfrbh4sblczc6qpy + hash: 4f93812bf7a04685f9477b2935e3acbf2aa24dfbb1b00b9ae0ed8f7a2877a98e path: patches/nuqs.patch importers: @@ -656,7 +656,7 @@ importers: version: 3.0.1 nuqs: specifier: ^2.5.2 - version: 2.5.2(patch_hash=w6thjv3pgywfrbh4sblczc6qpy)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 2.5.2(patch_hash=4f93812bf7a04685f9477b2935e3acbf2aa24dfbb1b00b9ae0ed8f7a2877a98e)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) prisma-error-enum: specifier: ^0.1.3 version: 0.1.3 @@ -880,6 +880,9 @@ importers: '@openpanel/logger': specifier: workspace:* version: link:../../packages/logger + '@openpanel/payments': + specifier: workspace:* + version: link:../../packages/payments '@openpanel/queue': specifier: workspace:* version: link:../../packages/queue @@ -1136,6 +1139,9 @@ importers: packages/email: dependencies: + '@openpanel/db': + specifier: workspace:* + version: link:../db '@react-email/components': specifier: ^0.5.6 version: 0.5.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -33178,7 +33184,7 @@ snapshots: dependencies: esm-env: esm-env-runtime@0.1.1 - nuqs@2.5.2(patch_hash=w6thjv3pgywfrbh4sblczc6qpy)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): + nuqs@2.5.2(patch_hash=4f93812bf7a04685f9477b2935e3acbf2aa24dfbb1b00b9ae0ed8f7a2877a98e)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.3