feat: Added platform emails for billing limits and disabled projects

This commit is contained in:
Dries Augustyns
2025-12-12 12:28:53 +01:00
parent cb40669385
commit 2485d2ff1d
29 changed files with 768 additions and 67 deletions
+2 -1
View File
@@ -21,7 +21,8 @@
"dependencies": {
"@plunk/db": "*",
"@plunk/types": "*",
"@react-email/components": "^1.0.0"
"@react-email/components": "^1.0.0",
"@react-email/tailwind": "^2.0.1"
},
"exports": {
".": "./dist/index.js"
+58
View File
@@ -0,0 +1,58 @@
import {Body, Container, Head, Html, Tailwind} from '@react-email/components';
import * as React from 'react';
interface EmailLayoutProps {
children: React.ReactNode;
}
const tailwindConfig = {
theme: {
extend: {
colors: {
brand: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
900: '#1e3a8a',
},
},
fontFamily: {
sans: [
'ui-sans-serif',
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI',
'Roboto',
'Helvetica Neue',
'Arial',
'sans-serif',
],
},
},
},
};
export function EmailLayout({children}: EmailLayoutProps) {
return (
<Tailwind config={tailwindConfig}>
<Html>
<Head>
<meta name="color-scheme" content="light" />
<meta name="supported-color-schemes" content="light" />
</Head>
<Body className="m-0 bg-gray-50 p-0 font-sans antialiased">
<Container
className="mx-auto my-8 max-w-[600px] overflow-hidden rounded-lg bg-white shadow-sm"
style={{border: '1px solid #e5e7eb'}}
>
{children}
</Container>
</Body>
</Html>
</Tailwind>
);
}
+41
View File
@@ -0,0 +1,41 @@
import {Link, Section, Text} from '@react-email/components';
import * as React from 'react';
interface FooterProps {
projectId?: string;
landingUrl?: string;
}
export function Footer({projectId, landingUrl = 'https://www.useplunk.com'}: FooterProps) {
return (
<Section className="border-t border-gray-100 bg-gray-50 px-8 py-8">
<Text className="mb-4 mt-0 text-center text-xs leading-relaxed text-gray-600">
This email was sent by Plunk. .
</Text>
{projectId && (
<Text className="mb-4 mt-0 text-center text-xs leading-relaxed text-gray-400">
Project ID: <span className="font-mono">{projectId}</span>
</Text>
)}
<Text className="mb-0 mt-0 text-center text-xs leading-relaxed text-gray-500">
<Link href={landingUrl} className="text-gray-500 no-underline hover:text-gray-700">
Plunk
</Link>
{' • '}
<Link
href={`${landingUrl}/privacy`}
className="text-gray-500 no-underline hover:text-gray-700"
>
Privacy
</Link>
{' • '}
<Link
href={`${landingUrl}/terms`}
className="text-gray-500 no-underline hover:text-gray-700"
>
Terms
</Link>
</Text>
</Section>
);
}
+10 -4
View File
@@ -1,10 +1,16 @@
import {Img} from '@react-email/components';
import {Img, Section} from '@react-email/components';
import * as React from 'react';
export function Header() {
return (
<>
<Img src="https://www.swyp.be/favicon/web-app-manifest-192x192.png" alt="Swyp Logo" width="40" height="40" />
</>
<Section className="border-b border-gray-100 bg-white px-8 py-8">
<Img
src="https://next.useplunk.com/assets/logo.png"
alt="Plunk"
width="40"
height="40"
className="mx-auto"
/>
</Section>
);
}
@@ -0,0 +1,115 @@
import {Heading, Link, Section, Text} from '@react-email/components';
import * as React from 'react';
import {EmailLayout} from '../common/EmailLayout';
import {Footer} from '../common/Footer';
import {Header} from '../common/Header';
interface BillingLimitExceededEmailProps {
projectName: string;
projectId: string;
usage: number;
limit: number;
sourceType: string;
dashboardUrl?: string;
landingUrl?: string;
}
export function BillingLimitExceededEmail({
projectName = 'My Project',
projectId = 'proj_example123',
usage = 10000,
limit = 10000,
sourceType = 'Transactional',
dashboardUrl = 'https://app.useplunk.com',
landingUrl = 'https://www.useplunk.com',
}: BillingLimitExceededEmailProps) {
return (
<EmailLayout>
<Header />
<Section className="px-8 pb-10 pt-10">
<Heading className="mb-2 mt-0 text-2xl font-semibold tracking-tight text-gray-900">
Email sending paused
</Heading>
<Text className="mb-8 mt-0 text-base leading-relaxed text-gray-600">
Your project <strong className="font-medium text-gray-900">{projectName}</strong> has reached your configured
monthly billing limit. Email sending has been paused to prevent charges beyond your set limit.
</Text>
<Section className="mb-8 overflow-hidden rounded-lg" style={{border: '1px solid #e5e7eb'}}>
<Section className="bg-gray-50 px-6 py-4">
<Text className="mb-0 mt-0 text-xs font-medium uppercase tracking-wider text-gray-500">
Usage this month
</Text>
</Section>
<Section className="px-6 py-6">
<Section className="mb-6">
<Section className="mb-2 flex items-baseline justify-between">
<Text className="mb-0 mt-0 text-sm text-gray-600">Emails sent</Text>
<Text className="mb-0 mt-0 text-sm font-medium text-gray-900">
{usage.toLocaleString()} / {limit.toLocaleString()}
</Text>
</Section>
<Section className="h-2 overflow-hidden rounded-full bg-gray-200">
<Section className="h-full bg-gray-900" style={{width: '100%'}} />
</Section>
</Section>
<Text className="mb-0 mt-0 text-xs text-gray-500">{sourceType} emails</Text>
</Section>
</Section>
<Section className="mb-8 rounded-lg bg-red-50 px-6 py-4" style={{border: '1px solid #fca5a5'}}>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-red-900">
All email sending is currently blocked. Your usage will reset at the start of next month, or you can
increase your billing limit to resume immediately.
</Text>
</Section>
<Heading className="mb-4 mt-0 text-lg font-semibold text-gray-900">How to resume sending</Heading>
<Section className="mb-8">
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Increase your billing limit</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Resume sending immediately by adjusting your monthly limit in billing settings
</Text>
</Section>
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Wait for monthly reset</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Your usage will automatically reset at the start of next month
</Text>
</Section>
<Section>
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Contact support</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Need immediate help? Our team is here to assist you
</Text>
</Section>
</Section>
<Section className="mb-6">
<Link
href={`${dashboardUrl}/settings?tab=billing`}
className="inline-block rounded-md bg-gray-900 px-6 py-3 text-sm font-medium text-white no-underline"
>
Adjust billing limit
</Link>
</Section>
<Section>
<Link href={dashboardUrl} className="text-sm text-gray-500" style={{textDecoration: 'none'}}>
View project dashboard
</Link>
</Section>
</Section>
<Footer projectId={projectId} landingUrl={landingUrl} />
</EmailLayout>
);
}
export default BillingLimitExceededEmail;
@@ -0,0 +1,119 @@
import {Heading, Link, Section, Text} from '@react-email/components';
import * as React from 'react';
import {EmailLayout} from '../common/EmailLayout';
import {Footer} from '../common/Footer';
import {Header} from '../common/Header';
interface BillingLimitWarningEmailProps {
projectName: string;
projectId: string;
usage: number;
limit: number;
percentage: number;
sourceType: string;
dashboardUrl?: string;
landingUrl?: string;
}
export function BillingLimitWarningEmail({
projectName = 'My Project',
projectId = 'proj_example123',
usage = 8500,
limit = 10000,
percentage = 85,
sourceType = 'Transactional',
dashboardUrl = 'https://app.useplunk.com',
landingUrl = 'https://www.useplunk.com',
}: BillingLimitWarningEmailProps) {
const percentageRounded = Math.round(percentage);
const remaining = limit - usage;
return (
<EmailLayout>
<Header />
<Section className="px-8 pb-10 pt-10">
<Heading className="mb-2 mt-0 text-2xl font-semibold tracking-tight text-gray-900">Usage limit warning</Heading>
<Text className="mb-8 mt-0 text-base leading-relaxed text-gray-600">
Your project <strong className="font-medium text-gray-900">{projectName}</strong> has used{' '}
<strong className="font-medium text-gray-900">{percentageRounded}%</strong> of your configured monthly billing
limit.
</Text>
<Section className="mb-8 overflow-hidden rounded-lg" style={{border: '1px solid #e5e7eb'}}>
<Section className="bg-gray-50 px-6 py-4">
<Text className="mb-0 mt-0 text-xs font-medium uppercase tracking-wider text-gray-500">
Usage this month
</Text>
</Section>
<Section className="px-6 py-6">
<Section className="mb-6">
<Section className="mb-2 flex items-baseline justify-between">
<Text className="mb-0 mt-0 text-sm text-gray-600">Emails sent</Text>
<Text className="mb-0 mt-0 text-sm font-medium text-gray-900">
{usage.toLocaleString()} / {limit.toLocaleString()}
</Text>
</Section>
<Section className="h-2 overflow-hidden rounded-full bg-gray-200">
<div className="h-full bg-gray-900" style={{width: `${percentageRounded}%`, height: '100%'}} />
</Section>
</Section>
<Text className="mb-0 mt-0 text-xs text-gray-500">{sourceType} emails</Text>
</Section>
</Section>
<Section className="mb-8 rounded-lg bg-amber-50 px-6 py-4" style={{border: '1px solid #fbbf24'}}>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-amber-900">
When you reach 100%, email sending will be paused to prevent charges beyond your configured limit. Your
usage will reset at the start of next month.
</Text>
</Section>
<Heading className="mb-4 mt-0 text-lg font-semibold text-gray-900">Recommended actions</Heading>
<Section className="mb-8">
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Increase your billing limit</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Adjust your monthly limit in billing settings to continue sending
</Text>
</Section>
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Monitor your usage</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Track your sending patterns in the dashboard
</Text>
</Section>
<Section>
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Optimize your sending</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Review and reduce email volume where possible
</Text>
</Section>
</Section>
<Section className="mb-6">
<Link
href={`${dashboardUrl}/settings?tab=billing`}
className="inline-block rounded-md bg-gray-900 px-6 py-3 text-sm font-medium text-white no-underline"
>
Adjust billing limit
</Link>
</Section>
<Section>
<Link href={dashboardUrl} className="text-sm text-gray-500" style={{textDecoration: 'none'}}>
View project dashboard
</Link>
</Section>
</Section>
<Footer projectId={projectId} landingUrl={landingUrl} />
</EmailLayout>
);
}
export default BillingLimitWarningEmail;
@@ -0,0 +1,96 @@
import {Heading, Link, Section, Text} from '@react-email/components';
import * as React from 'react';
import {EmailLayout} from '../common/EmailLayout';
import {Footer} from '../common/Footer';
import {Header} from '../common/Header';
interface ProjectDisabledEmailProps {
projectName: string;
projectId: string;
violations: string[];
dashboardUrl?: string;
landingUrl?: string;
}
export function ProjectDisabledEmail({
projectName = 'My Project',
projectId = 'proj_example123',
violations = ['Bounce rate exceeded 10% threshold', 'Complaint rate exceeded 0.5% threshold'],
dashboardUrl = 'https://app.useplunk.com',
landingUrl = 'https://www.useplunk.com',
}: ProjectDisabledEmailProps) {
return (
<EmailLayout>
<Header />
<Section className="px-8 pb-10 pt-10">
<Heading className="mb-2 mt-0 text-2xl font-semibold tracking-tight text-gray-900">Project disabled</Heading>
<Text className="mb-8 mt-0 text-base leading-relaxed text-gray-600">
Your project <strong className="font-medium text-gray-900">{projectName}</strong> has been automatically
disabled to protect your sender reputation.
</Text>
<Section className="mb-8 overflow-hidden rounded-lg" style={{border: '1px solid #e5e7eb'}}>
<Section className="bg-gray-50 px-6 py-4">
<Text className="mb-0 mt-0 text-xs font-medium uppercase tracking-wider text-gray-500">
Issues detected
</Text>
</Section>
<Section className="px-6 py-6">
{violations.map((violation, index) => (
<Section key={index} className="mb-3 last:mb-0">
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-700">{violation}</Text>
</Section>
))}
</Section>
</Section>
<Section className="mb-8 rounded-lg bg-red-50 px-6 py-4" style={{border: '1px solid #fca5a5'}}>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-red-900">
High bounce or complaint rates can severely damage your sender reputation and email deliverability. We've
disabled your project to prevent further issues.
</Text>
</Section>
<Heading className="mb-4 mt-0 text-lg font-semibold text-gray-900">Steps to restore your project</Heading>
<Section className="mb-8">
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Review your email lists</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Remove invalid or unengaged contacts
</Text>
</Section>
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Verify recipient consent</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Ensure you have proper consent from all recipients
</Text>
</Section>
<Section>
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Check email content</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Verify your content follows best practices and isn't triggering spam filters
</Text>
</Section>
</Section>
<Section className="mb-6">
<Link
href={dashboardUrl}
className="inline-block rounded-md bg-gray-900 px-6 py-3 text-sm font-medium text-white no-underline"
>
View project dashboard
</Link>
</Section>
</Section>
<Footer projectId={projectId} landingUrl={landingUrl} />
</EmailLayout>
);
}
export default ProjectDisabledEmail;
+3
View File
@@ -0,0 +1,3 @@
export {ProjectDisabledEmail} from './ProjectDisabled';
export {BillingLimitWarningEmail} from './BillingLimitWarning';
export {BillingLimitExceededEmail} from './BillingLimitExceeded';
+1
View File
@@ -1 +1,2 @@
export * from './send';
export * from './notify';
+40
View File
@@ -0,0 +1,40 @@
import {render} from '@react-email/components';
import type {ReactElement} from 'react';
import {sendEmail} from './send';
/**
* Check if platform email notifications are enabled
* Requires PLUNK_API_KEY to be set in environment
*/
export function isPlatformEmailEnabled(): boolean {
return !!process.env.PLUNK_API_KEY && !!process.env.PLUNK_FROM_ADDRESS;
}
/**
* Send a platform notification email (only if PLUNK_API_KEY is configured)
* @param to - Recipient email address
* @param subject - Email subject line
* @param template - React email template component
*/
export async function sendPlatformEmail(to: string, subject: string, template: ReactElement): Promise<void> {
// Skip if platform emails are not enabled
if (!isPlatformEmailEnabled()) {
return;
}
try {
// Render React email template to HTML
const html = await render(template);
// Send email using the platform
await sendEmail({
to,
from: process.env.PLUNK_FROM_ADDRESS as string,
subject,
body: html,
});
} catch (error) {
// Log error but don't throw - notifications should not break the main flow
console.error('[Platform Email] Failed to send notification:', error);
}
}
+4 -2
View File
@@ -1,11 +1,12 @@
interface SendEmailParams {
to: string;
from: string;
subject: string;
body: string;
}
export async function sendEmail({to, subject, body}: SendEmailParams) {
const apiUrl = process.env.API_URI ?? 'http://localhost:8080';
export async function sendEmail({to, from, subject, body}: SendEmailParams) {
const apiUrl = process.env.API_URI;
const res = await fetch(`${apiUrl}/v1/send`, {
method: 'POST',
headers: {
@@ -14,6 +15,7 @@ export async function sendEmail({to, subject, body}: SendEmailParams) {
},
body: JSON.stringify({
to,
from,
subject,
body,
}),
+8 -10
View File
@@ -340,16 +340,14 @@ export const ActionSchemas = {
template: uuid.optional(),
subscribed: z.boolean().optional().default(false),
name: z.string().optional(),
from: z
.union([
email, // Simple email string (backward compatible)
z.object({
// Object with name and email
name: z.string().optional(),
email: email,
}),
])
.optional(),
from: z.union([
email, // Simple email string (backward compatible)
z.object({
// Object with name and email
name: z.string().optional(),
email: email,
}),
]),
reply: email.optional(),
headers: z.record(z.string().max(998)).optional(),
data: jsonSchema.optional(),