feature: onboarding emails

* wip

* wip

* wip

* fix coderabbit comments

* remove template
This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-22 10:38:05 +01:00
committed by GitHub
parent 67301d928c
commit e645c094b2
43 changed files with 1604 additions and 114 deletions

View File

@@ -3,10 +3,7 @@ import { differenceInDays, isSameDay, isSameMonth } from 'date-fns';
export const DEFAULT_ASPECT_RATIO = 0.5625;
export const NOT_SET_VALUE = '(not set)';
export const RESERVED_EVENT_NAMES = [
'session_start',
'session_end',
] as const;
export const RESERVED_EVENT_NAMES = ['session_start', 'session_end'] as const;
export const timeWindows = {
'30min': {
@@ -508,6 +505,12 @@ export function getCountry(code?: string) {
return countries[code as keyof typeof countries];
}
export const emailCategories = {
onboarding: 'Onboarding',
} 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)' },

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "public"."organizations"
ADD COLUMN "onboarding" TEXT NOT NULL DEFAULT 'completed';

View File

@@ -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");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."organizations" ALTER COLUMN "onboarding" DROP NOT NULL;

View File

@@ -62,6 +62,7 @@ model Organization {
integrations Integration[]
invites Invite[]
timezone String?
onboarding String? @default("completed")
// Subscription
subscriptionId String?
@@ -610,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")
}

View File

@@ -8,6 +8,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@openpanel/db": "workspace:*",
"@react-email/components": "^0.5.6",
"react": "catalog:",
"react-dom": "catalog:",

View File

@@ -0,0 +1,24 @@
import { Button as EmailButton } from '@react-email/components';
import type * as React from 'react';
export function Button({
href,
children,
style,
}: { href: string; children: React.ReactNode; style?: React.CSSProperties }) {
return (
<EmailButton
href={href}
style={{
backgroundColor: '#000',
borderRadius: '6px',
color: '#fff',
padding: '12px 20px',
textDecoration: 'none',
...style,
}}
>
{children}
</EmailButton>
);
}

View File

@@ -11,7 +11,7 @@ import React from 'react';
const baseUrl = 'https://openpanel.dev';
export function Footer() {
export function Footer({ unsubscribeUrl }: { unsubscribeUrl?: string }) {
return (
<>
<Hr />
@@ -71,15 +71,17 @@ export function Footer() {
</Text>
</Row>
{/* <Row>
<Link
className="text-[#707070] text-[14px]"
href="https://dashboard.openpanel.dev/settings/notifications"
title="Unsubscribe"
>
Notification preferences
</Link>
</Row> */}
{unsubscribeUrl && (
<Row>
<Link
className="text-[#707070] text-[14px]"
href={unsubscribeUrl}
title="Unsubscribe"
>
Notification preferences
</Link>
</Row>
)}
</Section>
</>
);

View File

@@ -7,15 +7,15 @@ import {
Section,
Tailwind,
} from '@react-email/components';
// biome-ignore lint/style/useImportType: resend needs React
import React from 'react';
import type React from 'react';
import { Footer } from './footer';
type Props = {
children: React.ReactNode;
unsubscribeUrl?: string;
};
export function Layout({ children }: Props) {
export function Layout({ children, unsubscribeUrl }: Props) {
return (
<Html>
<Tailwind>
@@ -57,7 +57,7 @@ export function Layout({ children }: Props) {
/>
</Section>
<Section className="p-6">{children}</Section>
<Footer />
<Footer unsubscribeUrl={unsubscribeUrl} />
</Container>
</Body>
</Tailwind>

View File

@@ -0,0 +1,13 @@
import { Text } from '@react-email/components';
export function List({ items }: { items: React.ReactNode[] }) {
return (
<ul style={{ paddingLeft: 20 }}>
{items.map((node, index) => (
<li key={index.toString()}>
<Text style={{ marginBottom: 2, marginTop: 2 }}>{node}</Text>
</li>
))}
</ul>
);
}

View File

@@ -13,9 +13,10 @@ export default EmailInvite;
export function EmailInvite({
organizationName = 'Acme Co',
url = 'https://openpanel.dev',
}: Props) {
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout>
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>You've been invited to join {organizationName}!</Text>
<Text>
If you don't have an account yet, click the button below to create one

View File

@@ -9,9 +9,12 @@ export const zEmailResetPassword = z.object({
export type Props = z.infer<typeof zEmailResetPassword>;
export default EmailResetPassword;
export function EmailResetPassword({ url = 'https://openpanel.dev' }: Props) {
export function EmailResetPassword({
url = 'https://openpanel.dev',
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout>
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>
You have requested to reset your password. Follow the link below to
reset your password:

View File

@@ -3,6 +3,22 @@ import { EmailInvite, zEmailInvite } from './email-invite';
import EmailResetPassword, {
zEmailResetPassword,
} from './email-reset-password';
import OnboardingDashboards, {
zOnboardingDashboards,
} from './onboarding-dashboards';
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 = {
@@ -24,6 +40,40 @@ export const templates = {
Component: TrailEndingSoon,
schema: zTrailEndingSoon,
},
'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-feature-request': {
subject: () => 'One provider to rule them all',
Component: OnboardingFeatureRequest,
schema: zOnboardingFeatureRequest,
category: 'onboarding' as const,
},
'onboarding-trial-ending': {
subject: () => 'Your trial ends in a few days',
Component: OnboardingTrialEnding,
schema: zOnboardingTrialEnding,
},
'onboarding-trial-ended': {
subject: () => 'Your trial has ended',
Component: OnboardingTrialEnded,
schema: zOnboardingTrialEnded,
},
} as const;
export type Templates = typeof templates;

View File

@@ -0,0 +1,65 @@
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(),
dashboardUrl: z.string(),
});
export type Props = z.infer<typeof zOnboardingDashboards>;
export default OnboardingDashboards;
export function OnboardingDashboards({
firstName,
dashboardUrl = 'https://dashboard.openpanel.dev',
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
const newUrl = new URL(dashboardUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');
newUrl.searchParams.set('utm_campaign', 'onboarding-dashboards');
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
Tracking events is the easy part. The value comes from actually looking
at them.
</Text>
<Text>
If you haven't yet, try building a simple dashboard. Pick one thing you
care about and visualize it. Could be:
</Text>
<List
items={[
'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)',
]}
/>
<Text>
This is usually when people go from "I have analytics" to "I understand
what's happening." It's a different feeling.
</Text>
<Text>Takes maybe 10 minutes to set up. Worth it.</Text>
<Text>
Best regards,
<br />
Carl
</Text>
<span style={{ margin: '0 -20px', display: 'block' }}>
<img
src="https://openpanel.dev/_next/image?url=%2Fscreenshots%2Fdashboard-dark.webp&w=3840&q=75"
alt="Dashboard"
style={{
width: '100%',
height: 'auto',
borderRadius: '5px',
}}
/>
</span>
</Layout>
);
}

View File

@@ -0,0 +1,42 @@
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<typeof zOnboardingFeatureRequest>;
export default OnboardingFeatureRequest;
export function OnboardingFeatureRequest({
firstName,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
OpenPanel aims to be the one stop shop for all your analytics needs.
</Text>
<Text>
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.
</Text>
<Text>
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.
</Text>
<Text>
<Link href={'https://feedback.openpanel.dev'}>Feedback board</Link>
</Text>
<Text>
Best regards,
<br />
Carl
</Text>
</Layout>
);
}

View File

@@ -0,0 +1,56 @@
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<typeof zOnboardingTrialEnded>;
export default OnboardingTrialEnded;
export function OnboardingTrialEnded({
firstName,
billingUrl = 'https://dashboard.openpanel.dev',
recommendedPlan,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
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 (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>Your OpenPanel trial has ended.</Text>
<Text>
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.
</Text>
<Text>
Important: If you don't upgrade within 30 days, your workspace and
projects will be permanently deleted.
</Text>
<Text>
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'}
.
</Text>
<Text>
If you have any questions or something's holding you back, just reply to
this email.
</Text>
<Text>
<Button href={newUrl.toString()}>Upgrade Now</Button>
</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -0,0 +1,57 @@
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({
firstName: z.string().optional(),
organizationName: z.string(),
billingUrl: z.string(),
recommendedPlan: z.string().optional(),
});
export type Props = z.infer<typeof zOnboardingTrialEnding>;
export default OnboardingTrialEnding;
export function OnboardingTrialEnding({
firstName,
organizationName = 'your organization',
billingUrl = 'https://dashboard.openpanel.dev',
recommendedPlan,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
const newUrl = new URL(billingUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');
newUrl.searchParams.set('utm_campaign', 'onboarding-trial-ending');
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>Quick heads up: your OpenPanel trial ends soon.</Text>
<Text>
Your tracking will keep working, but you won't be able to see new data
until you upgrade. Everything you've built so far (dashboards, reports,
event history) stays intact.
</Text>
<Text>
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'}
.
</Text>
<Text>
If something's holding you back, I'd like to hear about it. Just reply.
</Text>
<Text>
Your project will receive events for the next 30 days, if you haven't
upgraded by then we'll remove your workspace and projects.
</Text>
<Text>
<Button href={newUrl.toString()}>Upgrade Now</Button>
</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -0,0 +1,55 @@
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(),
dashboardUrl: z.string(),
});
export type Props = z.infer<typeof zOnboardingWelcome>;
export default OnboardingWelcome;
export function OnboardingWelcome({
firstName,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>Thanks for trying OpenPanel.</Text>
<Text>
We built OpenPanel because most analytics tools are either too
expensive, too complicated, or both. OpenPanel is different.
</Text>
<Text>
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.
</Text>
<Text>To get started, you can:</Text>
<List
items={[
<Link
key=""
href={'https://openpanel.dev/docs/get-started/install-openpanel'}
>
Install tracking script
</Link>,
<Link
key=""
href={'https://openpanel.dev/docs/get-started/track-events'}
>
Start tracking your events
</Link>,
]}
/>
<Text>
Best regards,
<br />
Carl
</Text>
</Layout>
);
}

View File

@@ -0,0 +1,46 @@
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(),
});
export type Props = z.infer<typeof zOnboardingWhatToTrack>;
export default OnboardingWhatToTrack;
export function OnboardingWhatToTrack({
firstName,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
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:
</Text>
<List
items={[
'Find good funnels to track (onboarding or checkout)',
'Conversions (how many clicks your hero CTA)',
'What did the user do after clicking the CTA',
]}
/>
<Text>
Start small and incrementally add more events as you go is usually the
best approach.
</Text>
<Text>
If you're not sure whether something's worth tracking, or have any
questions, just reply here.
</Text>
<Text>
Best regards,
<br />
Carl
</Text>
</Layout>
);
}

View File

@@ -13,7 +13,8 @@ export default TrailEndingSoon;
export function TrailEndingSoon({
organizationName = 'Acme Co',
url = 'https://openpanel.dev',
}: Props) {
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
const newUrl = new URL(url);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');

View File

@@ -2,48 +2,81 @@ 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<T extends TemplateKey> = z.infer<Templates[T]['schema']>;
export type EmailTemplate = keyof Templates;
export async function sendEmail<T extends TemplateKey>(
template: T,
templateKey: T,
options: {
to: string | string[];
to: string;
data: z.infer<Templates[T]['schema']>;
},
) {
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;
}
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);
console.log('Subject: ', template.subject(props.data as any));
console.log('To: ', to);
console.log('Data: ', JSON.stringify(data, null, 2));
return null;
}
const resend = new Resend(process.env.RESEND_API_KEY);
const headers: Record<string, string> = {};
if ('category' in template && template.category) {
const unsubscribeUrl = getUnsubscribeUrl(to, template.category);
(props.data as any).unsubscribeUrl = unsubscribeUrl;
headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`;
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
}
try {
const res = await resend.emails.send({
from: FROM,
to,
// @ts-expect-error - TODO: fix this
subject: subject(props.data),
// @ts-expect-error - TODO: fix this
react: <Component {...props.data} />,
subject: template.subject(props.data as any),
react: <template.Component {...(props.data as any)} />,
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);

View File

@@ -0,0 +1,39 @@
import { createHmac, timingSafeEqual } 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);
const tokenBuffer = Buffer.from(token, 'hex');
const expectedBuffer = Buffer.from(expectedToken, 'hex');
// Handle length mismatch safely to avoid timing leaks
if (tokenBuffer.length !== expectedBuffer.length) {
// Compare against zero-filled buffer of same length as token to maintain constant time
const zeroBuffer = Buffer.alloc(tokenBuffer.length);
timingSafeEqual(tokenBuffer, zeroBuffer);
return false;
}
return timingSafeEqual(tokenBuffer, expectedBuffer);
}
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()}`;
}

View File

@@ -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<T>(
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),
});
}

View File

@@ -115,6 +115,10 @@ export type CronQueuePayloadInsightsDaily = {
type: 'insightsDaily';
payload: undefined;
};
export type CronQueuePayloadOnboarding = {
type: 'onboarding';
payload: undefined;
};
export type CronQueuePayload =
| CronQueuePayloadSalt
| CronQueuePayloadFlushEvents
@@ -122,7 +126,8 @@ export type CronQueuePayload =
| CronQueuePayloadFlushProfiles
| CronQueuePayloadPing
| CronQueuePayloadProject
| CronQueuePayloadInsightsDaily;
| CronQueuePayloadInsightsDaily
| CronQueuePayloadOnboarding;
export type MiscQueuePayloadTrialEndingSoon = {
type: 'trialEndingSoon';
@@ -254,18 +259,3 @@ export const insightsQueue = new Queue<InsightsQueuePayloadProject>(
},
},
);
export function addTrialEndingSoonJob(organizationId: string, delay: number) {
return miscQueue.add(
'misc',
{
type: 'trialEndingSoon',
payload: {
organizationId,
},
},
{
delay,
},
);
}

View File

@@ -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

View File

@@ -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 = '';

View File

@@ -0,0 +1,114 @@
import { emailCategories } from '@openpanel/constants';
import { db } from '@openpanel/db';
import { verifyUnsubscribeToken } from '@openpanel/email';
import { z } from 'zod';
import { TRPCBadRequestError } from '../errors';
import { createTRPCRouter, protectedProcedure, 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 TRPCBadRequestError('Invalid unsubscribe link');
}
// Upsert the unsubscribe record
await db.emailUnsubscribe.upsert({
where: {
email_category: {
email,
category,
},
},
create: {
email,
category,
},
update: {},
});
return { success: true };
}),
getPreferences: protectedProcedure.query(async ({ ctx }) => {
if (!ctx.session.userId || !ctx.session.user?.email) {
throw new Error('User not authenticated');
}
const email = ctx.session.user.email;
// Get all unsubscribe records for this user
const unsubscribes = await db.emailUnsubscribe.findMany({
where: {
email,
},
select: {
category: true,
},
});
const unsubscribedCategories = new Set(unsubscribes.map((u) => u.category));
// Return object with all categories, true = subscribed (not unsubscribed)
const preferences: Record<string, boolean> = {};
for (const [category] of Object.entries(emailCategories)) {
preferences[category] = !unsubscribedCategories.has(category);
}
return preferences;
}),
updatePreferences: protectedProcedure
.input(
z.object({
categories: z.record(z.string(), z.boolean()),
}),
)
.mutation(async ({ input, ctx }) => {
if (!ctx.session.userId || !ctx.session.user?.email) {
throw new Error('User not authenticated');
}
const email = ctx.session.user.email;
// Process each category
for (const [category, subscribed] of Object.entries(input.categories)) {
if (subscribed) {
// User wants to subscribe - delete unsubscribe record if exists
await db.emailUnsubscribe.deleteMany({
where: {
email,
category,
},
});
} else {
// User wants to unsubscribe - upsert unsubscribe record
await db.emailUnsubscribe.upsert({
where: {
email_category: {
email,
category,
},
},
create: {
email,
category,
},
update: {},
});
}
}
return { success: true };
}),
});

View File

@@ -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(
@@ -30,16 +29,10 @@ async function createOrGetOrganization(
subscriptionEndsAt: addDays(new Date(), TRIAL_DURATION_IN_DAYS),
subscriptionStatus: 'trialing',
timezone: input.timezone,
onboarding: '',
},
});
if (!process.env.SELF_HOSTED) {
await addTrialEndingSoonJob(
organization.id,
1000 * 60 * 60 * 24 * TRIAL_DURATION_IN_DAYS * 0.9,
);
}
return organization;
}