This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-21 11:21:40 +01:00
parent a58761e8d7
commit 3fa1a5429e
28 changed files with 661 additions and 172 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': {
@@ -510,7 +507,6 @@ export function getCountry(code?: string) {
export const emailCategories = {
onboarding: 'Onboarding',
billing: 'Billing',
} as const;
export type EmailCategory = keyof typeof emailCategories;

View File

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

View File

@@ -62,7 +62,7 @@ model Organization {
integrations Integration[]
invites Invite[]
timezone String?
onboarding String @default("completed") // 'completed' or template name for next email
onboarding String? @default("completed")
// Subscription
subscriptionId String?

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

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

@@ -39,7 +39,6 @@ export const templates = {
'Your trial is ending soon',
Component: TrailEndingSoon,
schema: zTrailEndingSoon,
category: 'billing' as const,
},
'onboarding-welcome': {
subject: () => "You're in",
@@ -69,13 +68,11 @@ export const templates = {
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;

View File

@@ -14,14 +14,15 @@ export default OnboardingDashboards;
export function OnboardingDashboards({
firstName,
dashboardUrl = 'https://dashboard.openpanel.dev',
}: Props) {
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>
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
Tracking events is the easy part. The value comes from actually looking

View File

@@ -9,9 +9,12 @@ export const zOnboardingFeatureRequest = z.object({
export type Props = z.infer<typeof zOnboardingFeatureRequest>;
export default OnboardingFeatureRequest;
export function OnboardingFeatureRequest({ firstName }: Props) {
export function OnboardingFeatureRequest({
firstName,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout>
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
OpenPanel aims to be the one stop shop for all your analytics needs.

View File

@@ -16,7 +16,8 @@ export function OnboardingTrialEnded({
firstName,
billingUrl = 'https://dashboard.openpanel.dev',
recommendedPlan,
}: Props) {
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
const newUrl = new URL(billingUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');

View File

@@ -18,7 +18,8 @@ export function OnboardingTrialEnding({
organizationName = 'your organization',
billingUrl = 'https://dashboard.openpanel.dev',
recommendedPlan,
}: Props) {
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
const newUrl = new URL(billingUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');

View File

@@ -11,9 +11,12 @@ export const zOnboardingWelcome = z.object({
export type Props = z.infer<typeof zOnboardingWelcome>;
export default OnboardingWelcome;
export function OnboardingWelcome({ firstName }: Props) {
export function OnboardingWelcome({
firstName,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout>
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>Thanks for trying OpenPanel.</Text>
<Text>

View File

@@ -10,9 +10,12 @@ export const zOnboardingWhatToTrack = z.object({
export type Props = z.infer<typeof zOnboardingWhatToTrack>;
export default OnboardingWhatToTrack;
export function OnboardingWhatToTrack({ firstName }: Props) {
export function OnboardingWhatToTrack({
firstName,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout>
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
Tracking can be overwhelming at first, and that's why its important to

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

@@ -29,7 +29,6 @@ export async function sendEmail<T extends TemplateKey>(
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: {
@@ -51,8 +50,7 @@ export async function sendEmail<T extends TemplateKey>(
if (!process.env.RESEND_API_KEY) {
console.log('No RESEND_API_KEY found, here is the data');
console.log('Template:', template);
// @ts-expect-error - TODO: fix this
console.log('Subject: ', subject(props.data));
console.log('Subject: ', template.subject(props.data as any));
console.log('To: ', to);
console.log('Data: ', JSON.stringify(data, null, 2));
return null;
@@ -60,10 +58,10 @@ export async function sendEmail<T extends TemplateKey>(
const resend = new Resend(process.env.RESEND_API_KEY);
// Build headers for unsubscribe (only for non-transactional emails)
const headers: Record<string, string> = {};
if ('category' in template && template.category) {
const unsubscribeUrl = getUnsubscribeUrl(to, template.category);
(data as any).unsubscribeUrl = unsubscribeUrl;
headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`;
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
}
@@ -72,10 +70,8 @@ export async function sendEmail<T extends TemplateKey>(
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) {

View File

@@ -1,7 +1,8 @@
import { z } from 'zod';
import { emailCategories } from '@openpanel/constants';
import { db } from '@openpanel/db';
import { verifyUnsubscribeToken } from '@openpanel/email';
import { createTRPCRouter, publicProcedure } from '../trpc';
import { z } from 'zod';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
export const emailRouter = createTRPCRouter({
unsubscribe: publicProcedure
@@ -35,6 +36,78 @@ export const emailRouter = createTRPCRouter({
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

@@ -29,7 +29,7 @@ async function createOrGetOrganization(
subscriptionEndsAt: addDays(new Date(), TRIAL_DURATION_IN_DAYS),
subscriptionStatus: 'trialing',
timezone: input.timezone,
onboarding: 'onboarding-welcome',
onboarding: '',
},
});