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

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