Issue: chrishannah/miniship#25 Feature: Email Notifications (Pro Feature) Estimated Time: 6-8 hours Complexity: Medium-High Risk: Medium (external service integration, email delivery)
Implement email notification system for Miniship changelogs. When a release is published, subscribers receive an email notification. This is a Pro tier feature.
-
Subscriber Management
- Database schema for subscribers
- Subscribe/unsubscribe UI
- Subscription preferences
-
Email Service Integration
- Resend API integration
- Email templates
- Delivery tracking
-
Notification Triggers
- Hook into release publish action
- Queue system for batch sending
- Rate limiting
-
User Controls
- Notification settings per changelog
- Enable/disable notifications
- Subscriber management (Pro only)
subscribers table:
export const subscribers = pgTable('subscribers', {
id: uuid('id').primaryKey().defaultRandom(),
changelogId: uuid('changelog_id').notNull().references(() => changelogs.id, { onDelete: 'cascade' }),
email: text('email').notNull(),
verified: boolean('verified').default(false).notNull(),
verificationToken: text('verification_token'), // For double opt-in
unsubscribeToken: text('unsubscribe_token').notNull(), // For one-click unsubscribe
subscribedAt: timestamp('subscribed_at').defaultNow().notNull(),
unsubscribedAt: timestamp('unsubscribed_at'),
});
// Unique constraint: one email per changelog
export const subscribersIndex = uniqueIndex('subscribers_changelog_email_idx')
.on(subscribers.changelogId, subscribers.email);changelog_notification_settings table:
export const changelogNotificationSettings = pgTable('changelog_notification_settings', {
changelogId: uuid('changelog_id').primaryKey().references(() => changelogs.id, { onDelete: 'cascade' }),
enabled: boolean('enabled').default(true).notNull(),
sendOnPublish: boolean('send_on_publish').default(true).notNull(),
fromName: text('from_name'), // Custom sender name (Pro)
replyTo: text('reply_to'), // Custom reply-to email (Pro)
});notification_logs table (optional, for debugging):
export const notificationLogs = pgTable('notification_logs', {
id: uuid('id').primaryKey().defaultRandom(),
releaseId: uuid('release_id').notNull().references(() => releases.id, { onDelete: 'cascade' }),
subscriberEmail: text('subscriber_email').notNull(),
sentAt: timestamp('sent_at').defaultNow().notNull(),
status: text('status').notNull(), // 'sent', 'failed', 'bounced'
resendId: text('resend_id'), // Resend message ID
error: text('error'),
});Files:
lib/email.ts- Resend client and helpers
Tasks:
- Install
resendpackage - Create Resend API key (env:
RESEND_API_KEY) - Configure sender domain in Resend dashboard
- Create email client wrapper
Code:
// lib/email.ts
import { Resend } from 'resend';
export const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendReleaseNotification({
to,
fromName,
replyTo,
changelogName,
releaseTitle,
releaseVersion,
releaseUrl,
items,
unsubscribeUrl,
}: {
to: string;
fromName?: string | null;
replyTo?: string | null;
changelogName: string;
releaseTitle: string;
releaseVersion: string;
releaseUrl: string;
items: Array<{ type: string; content: string; icon: string }>;
unsubscribeUrl: string;
}) {
const subject = `New Release: ${releaseTitle} - ${changelogName}`;
return await resend.emails.send({
from: fromName
? `${fromName} <notifications@miniship.app>`
: 'Miniship Notifications <notifications@miniship.app>',
to,
replyTo: replyTo || undefined,
subject,
html: renderEmailTemplate({
changelogName,
releaseTitle,
releaseVersion,
releaseUrl,
items,
unsubscribeUrl,
}),
});
}Files:
emails/release-notification.tsx(React Email component)
Tasks:
- Install
@react-email/components - Create responsive email template
- Match Miniship branding
- Include unsubscribe link
- Test in email clients
Template structure:
// emails/release-notification.tsx
import {
Body,
Container,
Head,
Heading,
Html,
Link,
Section,
Text,
} from '@react-email/components';
export default function ReleaseNotification({
changelogName,
releaseTitle,
releaseVersion,
releaseUrl,
items,
unsubscribeUrl,
}: ReleaseNotificationProps) {
return (
<Html>
<Head />
<Body style={main}>
<Container style={container}>
<Heading style={h1}>{changelogName}</Heading>
<Text style={subtitle}>New Release: {releaseTitle}</Text>
<Section style={itemsSection}>
{items.map((item, idx) => (
<div key={idx} style={itemRow}>
<span style={icon}>{item.icon}</span>
<Text style={itemText}>{item.content}</Text>
</div>
))}
</Section>
<Link href={releaseUrl} style={button}>
View Full Changelog
</Link>
<Text style={footer}>
<Link href={unsubscribeUrl}>Unsubscribe</Link>
</Text>
</Container>
</Body>
</Html>
);
}
const main = { backgroundColor: '#f5f5f5', fontFamily: 'system-ui' };
const container = { margin: '0 auto', padding: '20px', maxWidth: '600px' };
// ... more stylesFiles:
drizzle/migrations/XXXX_add_email_notifications.sql
Tasks:
- Create migration for new tables
- Run migration:
pnpm db:pushorpnpm db:migrate - Verify schema in database
Files:
app/api/changelogs/[id]/subscribe/route.ts(POST)app/api/changelogs/[id]/unsubscribe/route.ts(POST, GET)
Subscribe endpoint:
// POST /api/changelogs/[id]/subscribe
export async function POST(
request: Request,
{ params }: { params: { id: string } }
) {
const { email } = await request.json();
// Validate email
if (!isValidEmail(email)) {
return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
}
// Check if changelog exists and is public
const changelog = await db.query.changelogs.findFirst({
where: eq(changelogs.id, params.id),
});
if (!changelog) {
return NextResponse.json({ error: 'Changelog not found' }, { status: 404 });
}
// Create or update subscriber
const unsubscribeToken = randomBytes(32).toString('hex');
await db.insert(subscribers).values({
changelogId: params.id,
email,
verified: true, // Can add double opt-in later
unsubscribeToken,
}).onConflictDoUpdate({
target: [subscribers.changelogId, subscribers.email],
set: {
unsubscribedAt: null, // Re-subscribe if previously unsubscribed
subscribedAt: new Date(),
},
});
return NextResponse.json({ success: true });
}Unsubscribe endpoint:
// GET /api/changelogs/[id]/unsubscribe?token=xxx
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const { searchParams } = new URL(request.url);
const token = searchParams.get('token');
if (!token) {
return NextResponse.json({ error: 'Missing token' }, { status: 400 });
}
// Find subscriber by token
const subscriber = await db.query.subscribers.findFirst({
where: and(
eq(subscribers.changelogId, params.id),
eq(subscribers.unsubscribeToken, token)
),
});
if (!subscriber) {
return NextResponse.json({ error: 'Invalid token' }, { status: 404 });
}
// Mark as unsubscribed
await db.update(subscribers)
.set({ unsubscribedAt: new Date() })
.where(eq(subscribers.id, subscriber.id));
// Redirect to unsubscribe confirmation page
return redirect(`/unsubscribed?success=true`);
}Files:
app/api/changelogs/[id]/releases/[releaseId]/publish/route.ts(update)
Tasks:
- Hook into existing publish endpoint
- Fetch notification settings
- Fetch subscribers
- Queue email sends
- Handle errors gracefully
Updated publish logic:
// After marking release as published
const release = await db.query.releases.findFirst({
where: eq(releases.id, releaseId),
with: { items: { with: { entryType: true } } },
});
// Check if notifications are enabled
const settings = await db.query.changelogNotificationSettings.findFirst({
where: eq(changelogNotificationSettings.changelogId, params.id),
});
if (settings?.enabled && settings?.sendOnPublish) {
// Fetch active subscribers
const activeSubscribers = await db.query.subscribers.findMany({
where: and(
eq(subscribers.changelogId, params.id),
isNull(subscribers.unsubscribedAt)
),
});
// Send notifications (async, don't block response)
sendReleaseNotifications({
changelog,
release,
subscribers: activeSubscribers,
settings,
}).catch(console.error); // Log errors but don't fail publish
}Batch sending function:
async function sendReleaseNotifications({
changelog,
release,
subscribers,
settings,
}: {
changelog: Changelog;
release: Release & { items: (ReleaseItem & { entryType: EntryType })[] };
subscribers: Subscriber[];
settings: ChangelogNotificationSettings;
}) {
const releaseUrl = `https://miniship.app/${changelog.id}`;
// Send in batches to avoid rate limits
const BATCH_SIZE = 50;
for (let i = 0; i < subscribers.length; i += BATCH_SIZE) {
const batch = subscribers.slice(i, i + BATCH_SIZE);
await Promise.allSettled(
batch.map(async (subscriber) => {
const unsubscribeUrl = `https://miniship.app/api/changelogs/${changelog.id}/unsubscribe?token=${subscriber.unsubscribeToken}`;
try {
await sendReleaseNotification({
to: subscriber.email,
fromName: settings.fromName,
replyTo: settings.replyTo,
changelogName: changelog.name,
releaseTitle: release.title || release.version,
releaseVersion: release.version,
releaseUrl,
items: release.items.map(item => ({
type: item.entryType.name,
content: item.content,
icon: item.entryType.icon,
})),
unsubscribeUrl,
});
// Log success (optional)
await db.insert(notificationLogs).values({
releaseId: release.id,
subscriberEmail: subscriber.email,
status: 'sent',
});
} catch (error) {
// Log failure (optional)
await db.insert(notificationLogs).values({
releaseId: release.id,
subscriberEmail: subscriber.email,
status: 'failed',
error: String(error),
});
console.error('Failed to send notification:', error);
}
})
);
// Rate limiting pause between batches
if (i + BATCH_SIZE < subscribers.length) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
}Files:
app/[id]/page.tsx(update)components/SubscribeForm.tsx(new)
Tasks:
- Add subscribe form to public changelog page
- Show subscriber count (optional)
- Success/error toast notifications
- Email validation
Component:
'use client';
import { useState } from 'react';
import { showToast } from '@chrishannah/minibase-ui';
export function SubscribeForm({ changelogId }: { changelogId: string }) {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubscribe(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
const res = await fetch(`/api/changelogs/${changelogId}/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!res.ok) {
throw new Error('Failed to subscribe');
}
showToast('Subscribed! You\'ll receive email updates for new releases.', { duration: 5000 });
setEmail('');
} catch (error) {
showToast('Failed to subscribe. Please try again.', { duration: 5000 });
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubscribe} className="flex gap-2">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
className="px-3 py-2 border rounded flex-1"
/>
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-[--color-accent] text-white rounded hover:opacity-90 disabled:opacity-50"
>
{loading ? 'Subscribing...' : 'Subscribe'}
</button>
</form>
);
}Files:
app/dashboard/[id]/settings/page.tsx(update)app/api/changelogs/[id]/notification-settings/route.ts(new)
Tasks:
- Add notification settings section
- Toggle enable/disable notifications
- Custom from name (Pro only)
- Custom reply-to (Pro only)
- Show subscriber list (Pro only)
Settings UI:
// In settings page
<section className="border rounded p-6">
<h2 className="text-xl font-bold mb-4">Email Notifications</h2>
{user.tier === 'paid' ? (
<>
<label className="flex items-center gap-2 mb-4">
<input
type="checkbox"
checked={notificationSettings.enabled}
onChange={handleToggleNotifications}
/>
<span>Send email notifications to subscribers</span>
</label>
<div className="space-y-4">
<div>
<label className="block mb-1 text-sm">From Name (optional)</label>
<input
type="text"
value={notificationSettings.fromName || ''}
onChange={(e) => handleUpdateSettings({ fromName: e.target.value })}
placeholder="Your Name or Company"
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label className="block mb-1 text-sm">Reply-To Email (optional)</label>
<input
type="email"
value={notificationSettings.replyTo || ''}
onChange={(e) => handleUpdateSettings({ replyTo: e.target.value })}
placeholder="support@yourcompany.com"
className="w-full px-3 py-2 border rounded"
/>
</div>
</div>
<div className="mt-6">
<h3 className="font-bold mb-2">Subscribers ({subscribers.length})</h3>
<ul className="space-y-1">
{subscribers.map(sub => (
<li key={sub.id} className="text-sm">{sub.email}</li>
))}
</ul>
</div>
</>
) : (
<div className="text-center py-8">
<p className="mb-4">Email notifications are a Pro feature.</p>
<button className="px-4 py-2 bg-[--color-accent] text-white rounded">
Upgrade to Pro
</button>
</div>
)}
</section>Files:
app/unsubscribed/page.tsx(new)
Tasks:
- Simple confirmation page
- Re-subscribe option
- Link back to changelog
- Test email sends via Resend
- Verify email template renders correctly (Gmail, Outlook, Apple Mail)
- Test unsubscribe link works
- Test re-subscribe works
- Verify batch sending doesn't hit rate limits
- Subscribe endpoint validates email
- Duplicate subscriptions handled correctly
- Unsubscribe token works
- Notification settings save correctly
- Only Pro users can enable notifications
- Subscribe form on public changelog page
- Toast notifications on success/error
- Settings page shows subscriber count
- Settings page Pro gating works
- Unsubscribe page renders
- Invalid email addresses rejected
- Missing changelog returns 404
- Notification send failures don't break publish
- Unsubscribed users don't receive emails
- Rate limiting works for large subscriber lists
Add to .env.local and production:
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxx
Install new packages:
pnpm add resend @react-email/components- Email Validation: Validate email format before storing
- Unsubscribe Tokens: Use cryptographically secure random tokens
- Rate Limiting: Prevent abuse of subscribe endpoint
- Pro Feature Gating: Enforce Pro tier in API, not just UI
- SPAM Compliance: Include unsubscribe link in every email
- Double opt-in (send verification email before subscribing)
- Digest mode (weekly summary instead of per-release)
- Custom email templates (Pro+)
- Email preview before sending
- Delivery analytics (open rates, click rates)
- Webhook integration (notify external services)
-
Phase 1 - Core Functionality (MVP)
- Database schema
- Subscribe/unsubscribe API
- Email template
- Send on publish
-
Phase 2 - Settings UI
- Notification settings page
- Subscriber management
- Pro gating
-
Phase 3 - Polish
- Better email template design
- Analytics/logs
- Error monitoring
Risk: Email deliverability issues Mitigation: Use Resend (high deliverability), configure SPF/DKIM, monitor bounce rates
Risk: Rate limiting with large subscriber lists Mitigation: Batch sending with delays, use Resend's batch API
Risk: Notification failures break publish flow Mitigation: Async sending, catch errors, don't block publish response
Risk: SPAM complaints Mitigation: Clear opt-in, easy unsubscribe, comply with CAN-SPAM
- Subscriber sign-up rate (target: 5-10% of public changelog viewers)
- Email delivery rate (target: >95%)
- Unsubscribe rate (target: <5%)
- Pro conversion from notification feature (track separately)
- Database setup: 1 hour
- Resend integration: 1 hour
- Email template: 2 hours
- API endpoints: 2 hours
- UI components: 2 hours
- Testing: 1-2 hours
Total: 6-8 hours (can split across multiple sessions)
- Prioritize MVP over polish (get it working first)
- Consider starting with simple text emails before React Email
- Test with small subscriber lists first
- Monitor Resend dashboard for delivery metrics
- Document any API rate limits encountered