-
-
-
Unsubscribe
-
- Unsubscribe from {categoryName} emails?
-
-
- You'll stop receiving {categoryName.toLowerCase()} emails sent to{' '}
- {email}
-
+
+ Unsubscribe from {categoryName} emails? You'll stop receiving{' '}
+ {categoryName.toLowerCase()} emails sent to
+ {email}
+ >
+ }
+ >
+
+ {error && (
+
+ {error}
-
- {error && (
-
- {error}
-
- )}
-
-
-
-
- Cancel
-
-
-
+ )}
+
+
+ Cancel
+
-
+
);
}
diff --git a/apps/worker/src/boot-cron.ts b/apps/worker/src/boot-cron.ts
index 4c0342ec..9eb558c6 100644
--- a/apps/worker/src/boot-cron.ts
+++ b/apps/worker/src/boot-cron.ts
@@ -42,7 +42,7 @@ export async function bootCron() {
{
name: 'onboarding',
type: 'onboarding',
- pattern: '0 10 * * *',
+ pattern: '0 * * * *',
},
];
diff --git a/apps/worker/src/boot-workers.ts b/apps/worker/src/boot-workers.ts
index 4b739afc..6d96dd61 100644
--- a/apps/worker/src/boot-workers.ts
+++ b/apps/worker/src/boot-workers.ts
@@ -281,10 +281,20 @@ export async function bootWorkers() {
eventName: string,
evtOrExitCodeOrError: number | string | Error,
) {
- logger.info('Starting graceful shutdown', {
- code: evtOrExitCodeOrError,
- eventName,
- });
+ // Log the actual error details for unhandled rejections/exceptions
+ if (evtOrExitCodeOrError instanceof Error) {
+ logger.error('Unhandled error triggered shutdown', {
+ eventName,
+ message: evtOrExitCodeOrError.message,
+ stack: evtOrExitCodeOrError.stack,
+ name: evtOrExitCodeOrError.name,
+ });
+ } else {
+ logger.info('Starting graceful shutdown', {
+ code: evtOrExitCodeOrError,
+ eventName,
+ });
+ }
try {
const time = performance.now();
diff --git a/apps/worker/src/jobs/cron.onboarding.ts b/apps/worker/src/jobs/cron.onboarding.ts
index b21eb0ee..fe195c77 100644
--- a/apps/worker/src/jobs/cron.onboarding.ts
+++ b/apps/worker/src/jobs/cron.onboarding.ts
@@ -135,14 +135,16 @@ const ONBOARDING_EMAILS = [
];
export async function onboardingJob(job: Job
) {
+ if (process.env.SELF_HOSTED === 'true') {
+ return null;
+ }
+
logger.info('Starting onboarding email job');
// Fetch organizations that are in onboarding (not completed)
const orgs = await db.organization.findMany({
where: {
- onboarding: {
- not: 'completed',
- },
+ OR: [{ onboarding: null }, { onboarding: { notIn: ['completed'] } }],
deleteAt: null,
createdBy: {
deletedAt: null,
@@ -168,7 +170,7 @@ export async function onboardingJob(job: Job) {
const daysSinceOrgCreation = differenceInDays(new Date(), org.createdAt);
// Find the next email to send
- // If org.onboarding is empty string, they haven't received any email yet
+ // If org.onboarding is null or empty string, they haven't received any email yet
const lastSentIndex = org.onboarding
? ONBOARDING_EMAILS.findIndex((e) => e.template === org.onboarding)
: -1;
@@ -192,6 +194,15 @@ export async function onboardingJob(job: Job) {
continue;
}
+ logger.info(
+ `Checking if enough days have passed for organization ${org.id}`,
+ {
+ daysSinceOrgCreation,
+ nextEmailDay: nextEmail.day,
+ orgCreatedAt: org.createdAt,
+ today: new Date(),
+ },
+ );
// Check if enough days have passed
if (daysSinceOrgCreation < nextEmail.day) {
orgsSkipped++;
diff --git a/packages/constants/index.ts b/packages/constants/index.ts
index 077697a4..8169f0bd 100644
--- a/packages/constants/index.ts
+++ b/packages/constants/index.ts
@@ -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;
diff --git a/packages/db/prisma/migrations/20260121093831_nullable_onboaridng/migration.sql b/packages/db/prisma/migrations/20260121093831_nullable_onboaridng/migration.sql
new file mode 100644
index 00000000..cb115cd4
--- /dev/null
+++ b/packages/db/prisma/migrations/20260121093831_nullable_onboaridng/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "public"."organizations" ALTER COLUMN "onboarding" DROP NOT NULL;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index a9457a09..784f94fb 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -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?
diff --git a/packages/email/src/components/footer.tsx b/packages/email/src/components/footer.tsx
index ad4dd213..3beead85 100644
--- a/packages/email/src/components/footer.tsx
+++ b/packages/email/src/components/footer.tsx
@@ -11,7 +11,7 @@ import React from 'react';
const baseUrl = 'https://openpanel.dev';
-export function Footer() {
+export function Footer({ unsubscribeUrl }: { unsubscribeUrl?: string }) {
return (
<>
@@ -71,15 +71,17 @@ export function Footer() {
- {/*
-
- Notification preferences
-
-
*/}
+ {unsubscribeUrl && (
+
+
+ Notification preferences
+
+
+ )}
>
);
diff --git a/packages/email/src/components/layout.tsx b/packages/email/src/components/layout.tsx
index dbf6879c..6900b31c 100644
--- a/packages/email/src/components/layout.tsx
+++ b/packages/email/src/components/layout.tsx
@@ -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 (
@@ -57,7 +57,7 @@ export function Layout({ children }: Props) {
/>
-
+
diff --git a/packages/email/src/emails/email-invite.tsx b/packages/email/src/emails/email-invite.tsx
index f3e66262..472de870 100644
--- a/packages/email/src/emails/email-invite.tsx
+++ b/packages/email/src/emails/email-invite.tsx
@@ -13,9 +13,10 @@ export default EmailInvite;
export function EmailInvite({
organizationName = 'Acme Co',
url = 'https://openpanel.dev',
-}: Props) {
+ unsubscribeUrl,
+}: Props & { unsubscribeUrl?: string }) {
return (
-
+
You've been invited to join {organizationName}!
If you don't have an account yet, click the button below to create one
diff --git a/packages/email/src/emails/email-reset-password.tsx b/packages/email/src/emails/email-reset-password.tsx
index 56a6fe76..cf6136f1 100644
--- a/packages/email/src/emails/email-reset-password.tsx
+++ b/packages/email/src/emails/email-reset-password.tsx
@@ -9,9 +9,12 @@ export const zEmailResetPassword = z.object({
export type Props = z.infer;
export default EmailResetPassword;
-export function EmailResetPassword({ url = 'https://openpanel.dev' }: Props) {
+export function EmailResetPassword({
+ url = 'https://openpanel.dev',
+ unsubscribeUrl,
+}: Props & { unsubscribeUrl?: string }) {
return (
-
+
You have requested to reset your password. Follow the link below to
reset your password:
diff --git a/packages/email/src/emails/index.tsx b/packages/email/src/emails/index.tsx
index b6f7abbb..c2c21b21 100644
--- a/packages/email/src/emails/index.tsx
+++ b/packages/email/src/emails/index.tsx
@@ -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;
diff --git a/packages/email/src/emails/onboarding-dashboards.tsx b/packages/email/src/emails/onboarding-dashboards.tsx
index d49e23fd..02ae98a4 100644
--- a/packages/email/src/emails/onboarding-dashboards.tsx
+++ b/packages/email/src/emails/onboarding-dashboards.tsx
@@ -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 (
-
+
Hi{firstName ? ` ${firstName}` : ''},
Tracking events is the easy part. The value comes from actually looking
diff --git a/packages/email/src/emails/onboarding-feature-request.tsx b/packages/email/src/emails/onboarding-feature-request.tsx
index a0a8b289..6cc10c20 100644
--- a/packages/email/src/emails/onboarding-feature-request.tsx
+++ b/packages/email/src/emails/onboarding-feature-request.tsx
@@ -9,9 +9,12 @@ export const zOnboardingFeatureRequest = z.object({
export type Props = z.infer;
export default OnboardingFeatureRequest;
-export function OnboardingFeatureRequest({ firstName }: Props) {
+export function OnboardingFeatureRequest({
+ firstName,
+ unsubscribeUrl,
+}: Props & { unsubscribeUrl?: string }) {
return (
-
+
Hi{firstName ? ` ${firstName}` : ''},
OpenPanel aims to be the one stop shop for all your analytics needs.
diff --git a/packages/email/src/emails/onboarding-trial-ended.tsx b/packages/email/src/emails/onboarding-trial-ended.tsx
index 8b61db3d..3a5e3187 100644
--- a/packages/email/src/emails/onboarding-trial-ended.tsx
+++ b/packages/email/src/emails/onboarding-trial-ended.tsx
@@ -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');
diff --git a/packages/email/src/emails/onboarding-trial-ending.tsx b/packages/email/src/emails/onboarding-trial-ending.tsx
index aa7e4fef..9a91d809 100644
--- a/packages/email/src/emails/onboarding-trial-ending.tsx
+++ b/packages/email/src/emails/onboarding-trial-ending.tsx
@@ -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');
diff --git a/packages/email/src/emails/onboarding-welcome.tsx b/packages/email/src/emails/onboarding-welcome.tsx
index 727fee0f..b2faf278 100644
--- a/packages/email/src/emails/onboarding-welcome.tsx
+++ b/packages/email/src/emails/onboarding-welcome.tsx
@@ -11,9 +11,12 @@ export const zOnboardingWelcome = z.object({
export type Props = z.infer;
export default OnboardingWelcome;
-export function OnboardingWelcome({ firstName }: Props) {
+export function OnboardingWelcome({
+ firstName,
+ unsubscribeUrl,
+}: Props & { unsubscribeUrl?: string }) {
return (
-
+
Hi{firstName ? ` ${firstName}` : ''},
Thanks for trying OpenPanel.
diff --git a/packages/email/src/emails/onboarding-what-to-track.tsx b/packages/email/src/emails/onboarding-what-to-track.tsx
index 556dde50..ce3f8ecb 100644
--- a/packages/email/src/emails/onboarding-what-to-track.tsx
+++ b/packages/email/src/emails/onboarding-what-to-track.tsx
@@ -10,9 +10,12 @@ export const zOnboardingWhatToTrack = z.object({
export type Props = z.infer;
export default OnboardingWhatToTrack;
-export function OnboardingWhatToTrack({ firstName }: Props) {
+export function OnboardingWhatToTrack({
+ firstName,
+ unsubscribeUrl,
+}: Props & { unsubscribeUrl?: string }) {
return (
-
+
Hi{firstName ? ` ${firstName}` : ''},
Tracking can be overwhelming at first, and that's why its important to
diff --git a/packages/email/src/emails/trial-ending-soon.tsx b/packages/email/src/emails/trial-ending-soon.tsx
index 77e7e3ae..bf6e7034 100644
--- a/packages/email/src/emails/trial-ending-soon.tsx
+++ b/packages/email/src/emails/trial-ending-soon.tsx
@@ -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');
diff --git a/packages/email/src/index.tsx b/packages/email/src/index.tsx
index f1bedd34..bba633af 100644
--- a/packages/email/src/index.tsx
+++ b/packages/email/src/index.tsx
@@ -29,7 +29,6 @@ export async function sendEmail(
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(
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(
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);
+ (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(
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: ,
+ subject: template.subject(props.data as any),
+ react: ,
headers: Object.keys(headers).length > 0 ? headers : undefined,
});
if (res.error) {
diff --git a/packages/trpc/src/routers/email.ts b/packages/trpc/src/routers/email.ts
index 5e149d5d..8bb95f66 100644
--- a/packages/trpc/src/routers/email.ts
+++ b/packages/trpc/src/routers/email.ts
@@ -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 = {};
+ 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 };
}),
});
diff --git a/packages/trpc/src/routers/onboarding.ts b/packages/trpc/src/routers/onboarding.ts
index a49b5435..12494bc5 100644
--- a/packages/trpc/src/routers/onboarding.ts
+++ b/packages/trpc/src/routers/onboarding.ts
@@ -29,7 +29,7 @@ async function createOrGetOrganization(
subscriptionEndsAt: addDays(new Date(), TRIAL_DURATION_IN_DAYS),
subscriptionStatus: 'trialing',
timezone: input.timezone,
- onboarding: 'onboarding-welcome',
+ onboarding: '',
},
});