diff --git a/apps/start/src/hooks/use-cookie-store.tsx b/apps/start/src/hooks/use-cookie-store.tsx
index 43f8b6ea..366bba15 100644
--- a/apps/start/src/hooks/use-cookie-store.tsx
+++ b/apps/start/src/hooks/use-cookie-store.tsx
@@ -35,7 +35,7 @@ const setCookieFn = createServerFn({ method: 'POST' })
});
// Called in __root.tsx beforeLoad hook to get cookies from the server
-// And recieved with useRouteContext in the client
+// And received with useRouteContext in the client
export const getCookiesFn = createServerFn({ method: 'GET' }).handler(() =>
pick(VALID_COOKIES, getCookies()),
);
diff --git a/apps/start/src/modals/create-invite.tsx b/apps/start/src/modals/create-invite.tsx
index 83572f5b..e05e228a 100644
--- a/apps/start/src/modals/create-invite.tsx
+++ b/apps/start/src/modals/create-invite.tsx
@@ -102,7 +102,7 @@ export default function CreateInvite() {
Invite a user
- Invite users to your organization. They will recieve an email
+ Invite users to your organization. They will receive an email
will instructions.
diff --git a/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx b/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx
index d9b9430d..f4fbb29e 100644
--- a/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx
+++ b/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx
@@ -20,6 +20,22 @@ const validator = z.object({
type IForm = z.infer;
+/**
+ * Build explicit boolean values for every key in emailCategories.
+ * Uses saved preferences when available, falling back to true (opted-in).
+ */
+function buildCategoryDefaults(
+ savedPreferences?: Record,
+): Record {
+ return Object.keys(emailCategories).reduce(
+ (acc, category) => {
+ acc[category] = savedPreferences?.[category] ?? true;
+ return acc;
+ },
+ {} as Record,
+ );
+}
+
export const Route = createFileRoute(
'/_app/$organizationId/profile/_tabs/email-preferences',
)({
@@ -37,7 +53,7 @@ function Component() {
const { control, handleSubmit, formState, reset } = useForm({
defaultValues: {
- categories: preferencesQuery.data,
+ categories: buildCategoryDefaults(preferencesQuery.data),
},
});
@@ -55,7 +71,7 @@ function Component() {
trpc.email.getPreferences.queryOptions(),
);
reset({
- categories: freshData,
+ categories: buildCategoryDefaults(freshData),
});
},
onError: handleError,
@@ -96,7 +112,7 @@ function Component() {
diff --git a/apps/worker/src/jobs/cron.onboarding.ts b/apps/worker/src/jobs/cron.onboarding.ts
index fe195c77..d2e61aaa 100644
--- a/apps/worker/src/jobs/cron.onboarding.ts
+++ b/apps/worker/src/jobs/cron.onboarding.ts
@@ -92,7 +92,7 @@ const ONBOARDING_EMAILS = [
}),
email({
day: 14,
- template: 'onboarding-featue-request',
+ template: 'onboarding-feature-request',
data: (ctx) => ({
firstName: getters.firstName(ctx),
}),
diff --git a/packages/email/onboarding-emails.md b/packages/email/onboarding-emails.md
index 56a4b523..dcac4810 100644
--- a/packages/email/onboarding-emails.md
+++ b/packages/email/onboarding-emails.md
@@ -99,6 +99,6 @@ If OpenPanel has been useful, upgrading just keeps it going. Plans start at $2.5
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.
+Your project will receive events for the next 30 days, if you haven't upgraded by then we'll remove your workspace and projects.
Carl
\ No newline at end of file
diff --git a/packages/email/src/components/button.tsx b/packages/email/src/components/button.tsx
index e376fb25..563844d6 100644
--- a/packages/email/src/components/button.tsx
+++ b/packages/email/src/components/button.tsx
@@ -1,4 +1,5 @@
import { Button as EmailButton } from '@react-email/components';
+import type * as React from 'react';
export function Button({
href,
diff --git a/packages/email/src/emails/index.tsx b/packages/email/src/emails/index.tsx
index c2c21b21..a5d43559 100644
--- a/packages/email/src/emails/index.tsx
+++ b/packages/email/src/emails/index.tsx
@@ -58,7 +58,7 @@ export const templates = {
schema: zOnboardingDashboards,
category: 'onboarding' as const,
},
- 'onboarding-featue-request': {
+ 'onboarding-feature-request': {
subject: () => 'One provider to rule them all',
Component: OnboardingFeatureRequest,
schema: zOnboardingFeatureRequest,
diff --git a/packages/email/src/emails/onboarding-trial-ended.tsx b/packages/email/src/emails/onboarding-trial-ended.tsx
index 3a5e3187..14be5b80 100644
--- a/packages/email/src/emails/onboarding-trial-ended.tsx
+++ b/packages/email/src/emails/onboarding-trial-ended.tsx
@@ -24,7 +24,7 @@ export function OnboardingTrialEnded({
newUrl.searchParams.set('utm_campaign', 'onboarding-trial-ended');
return (
-
+
Hi{firstName ? ` ${firstName}` : ''},
Your OpenPanel trial has ended.
diff --git a/packages/email/src/emails/onboarding-trial-ending.tsx b/packages/email/src/emails/onboarding-trial-ending.tsx
index 9a91d809..236d3c88 100644
--- a/packages/email/src/emails/onboarding-trial-ending.tsx
+++ b/packages/email/src/emails/onboarding-trial-ending.tsx
@@ -26,7 +26,7 @@ export function OnboardingTrialEnding({
newUrl.searchParams.set('utm_campaign', 'onboarding-trial-ending');
return (
-
+
Hi{firstName ? ` ${firstName}` : ''},
Quick heads up: your OpenPanel trial ends soon.
@@ -45,7 +45,7 @@ export function OnboardingTrialEnding({
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
+ Your project will receive events for the next 30 days, if you haven't
upgraded by then we'll remove your workspace and projects.
diff --git a/packages/email/src/index.tsx b/packages/email/src/index.tsx
index bba633af..4367408a 100644
--- a/packages/email/src/index.tsx
+++ b/packages/email/src/index.tsx
@@ -61,7 +61,7 @@ export async function sendEmail(
const headers: Record = {};
if ('category' in template && template.category) {
const unsubscribeUrl = getUnsubscribeUrl(to, template.category);
- (data as any).unsubscribeUrl = unsubscribeUrl;
+ (props.data as any).unsubscribeUrl = unsubscribeUrl;
headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`;
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
}
diff --git a/packages/email/src/unsubscribe.ts b/packages/email/src/unsubscribe.ts
index baeef6fd..1a6ced12 100644
--- a/packages/email/src/unsubscribe.ts
+++ b/packages/email/src/unsubscribe.ts
@@ -1,4 +1,4 @@
-import { createHmac } from 'crypto';
+import { createHmac, timingSafeEqual } from 'crypto';
const SECRET =
process.env.UNSUBSCRIBE_SECRET ||
@@ -17,7 +17,18 @@ export function verifyUnsubscribeToken(
token: string,
): boolean {
const expectedToken = generateUnsubscribeToken(email, category);
- return token === expectedToken;
+ 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 {
diff --git a/packages/trpc/src/routers/email.ts b/packages/trpc/src/routers/email.ts
index 8bb95f66..d912cb0a 100644
--- a/packages/trpc/src/routers/email.ts
+++ b/packages/trpc/src/routers/email.ts
@@ -2,6 +2,7 @@ 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({
@@ -18,7 +19,7 @@ export const emailRouter = createTRPCRouter({
// Verify token
if (!verifyUnsubscribeToken(email, category, token)) {
- throw new Error('Invalid unsubscribe link');
+ throw TRPCBadRequestError('Invalid unsubscribe link');
}
// Upsert the unsubscribe record