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

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