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