Issue: chrishannah/miniship#25
Feature: Email notification system for changelog subscribers
Tier: Pro Feature
Estimated Time: 8-12 hours
Build a complete email notification system that allows users to subscribe to changelogs and receive email updates when new releases are published. This is a Pro-tier feature.
- Database Schema - Subscriber management tables
- Subscription API - Subscribe/unsubscribe endpoints
- Email Service - Resend integration for sending emails
- Email Templates - HTML email templates for notifications
- Webhook Handler - Trigger emails when releases are published
- UI Components - Subscribe forms and management interface
- Settings Page - Notification preferences for changelog owners
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(),
subscribedAt: timestamp('subscribed_at').notNull().defaultNow(),
unsubscribeToken: text('unsubscribe_token').notNull().unique(),
confirmed: boolean('confirmed').notNull().default(false),
confirmedAt: timestamp('confirmed_at'),
});
// Indexes
CREATE INDEX idx_subscribers_changelog ON subscribers(changelog_id);
CREATE INDEX idx_subscribers_email ON subscribers(email);
CREATE UNIQUE INDEX idx_subscribers_changelog_email ON subscribers(changelog_id, email);
CREATE INDEX idx_subscribers_token ON subscribers(unsubscribe_token);notification_settings table:
export const notificationSettings = pgTable('notification_settings', {
id: uuid('id').primaryKey().defaultRandom(),
changelogId: uuid('changelog_id').notNull().references(() => changelogs.id, { onDelete: 'cascade' }).unique(),
enabled: boolean('enabled').notNull().default(true),
sendOnPublish: boolean('send_on_publish').notNull().default(true),
emailTemplate: text('email_template'), // 'default' or 'custom'
fromName: text('from_name'), // Optional custom sender name
replyTo: text('reply_to'), // Optional reply-to email
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
});Purpose: Subscribe an email to a changelog
Request:
{
changelogId: string; // UUID
email: string;
}Flow:
- Validate email format
- Check if already subscribed (return success if so)
- Generate unique unsubscribe token (crypto.randomUUID())
- Insert into subscribers table (confirmed: false)
- Send confirmation email via Resend
- Return success message
Response:
{
success: true,
message: "Please check your email to confirm your subscription"
}Purpose: Confirm email subscription
Flow:
- Find subscriber by unsubscribeToken
- If not found → 404
- If already confirmed → show "Already confirmed" page
- Set confirmed: true, confirmedAt: now()
- Redirect to success page
Purpose: Unsubscribe from notifications
Flow:
- Find subscriber by unsubscribeToken
- If not found → 404 (or show "Already unsubscribed")
- Delete subscriber record
- Show confirmation page
Install Resend SDK:
pnpm add resendEnvironment Variables:
RESEND_API_KEY=re_xxx
RESEND_FROM_EMAIL=notifications@miniship.appCreate lib/resend.ts:
import { Resend } from 'resend';
export const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendConfirmationEmail(
email: string,
changelogName: string,
confirmUrl: string
) {
return resend.emails.send({
from: process.env.RESEND_FROM_EMAIL!,
to: email,
subject: `Confirm your subscription to ${changelogName}`,
html: confirmationEmailTemplate({ changelogName, confirmUrl }),
});
}
export async function sendReleaseNotification(
email: string,
changelog: { name: string; url: string },
release: { version: string; title: string; description?: string; items: any[] },
unsubscribeUrl: string
) {
return resend.emails.send({
from: process.env.RESEND_FROM_EMAIL!,
to: email,
subject: `${changelog.name} - New release: ${release.version}`,
html: releaseEmailTemplate({ changelog, release, unsubscribeUrl }),
});
}File: lib/email-templates/confirmation.tsx (or .ts with HTML strings)
Content:
- Welcome message
- Changelog name
- Confirm subscription button (link to /api/subscribe/confirm?token=xxx)
- Privacy note
Example:
export function confirmationEmailTemplate({
changelogName,
confirmUrl,
}: {
changelogName: string;
confirmUrl: string;
}) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: system-ui, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.button { display: inline-block; padding: 12px 24px; background: #eb4034; color: white; text-decoration: none; border-radius: 2px; }
</style>
</head>
<body>
<div class="container">
<h1>Confirm your subscription</h1>
<p>You've requested to subscribe to updates from <strong>${changelogName}</strong>.</p>
<p>Click the button below to confirm your subscription:</p>
<p>
<a href="${confirmUrl}" class="button">Confirm Subscription</a>
</p>
<p style="color: #666; font-size: 14px;">
If you didn't request this, you can safely ignore this email.
</p>
</div>
</body>
</html>
`;
}File: lib/email-templates/release.tsx
Content:
- Changelog name
- Release version + title
- Release description (if present)
- List of items grouped by type (Added, Fixed, etc.)
- View full changelog link
- Unsubscribe link (footer)
Example:
export function releaseEmailTemplate({
changelog,
release,
unsubscribeUrl,
}: {
changelog: { name: string; url: string };
release: { version: string; title: string; description?: string; items: any[] };
unsubscribeUrl: string;
}) {
const itemsByType = release.items.reduce((acc, item) => {
if (!acc[item.type.name]) acc[item.type.name] = [];
acc[item.type.name].push(item);
return acc;
}, {} as Record<string, any[]>);
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: system-ui, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.release-header { border-bottom: 2px solid #eb4034; padding-bottom: 10px; margin-bottom: 20px; }
.type-section { margin-bottom: 20px; }
.type-title { font-weight: bold; margin-bottom: 8px; }
.item { margin-left: 20px; margin-bottom: 4px; }
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #ddd; color: #666; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<div class="release-header">
<h1>${changelog.name}</h1>
<h2>Version ${release.version}${release.title !== release.version ? ` - ${release.title}` : ''}</h2>
</div>
${release.description ? `<p>${release.description}</p>` : ''}
${Object.entries(itemsByType).map(([typeName, items]) => `
<div class="type-section">
<div class="type-title">${typeName}</div>
${(items as any[]).map(item => `
<div class="item">• ${item.content}</div>
`).join('')}
</div>
`).join('')}
<p>
<a href="${changelog.url}" style="color: #eb4034;">View full changelog →</a>
</p>
<div class="footer">
<p>You're receiving this because you subscribed to updates from ${changelog.name}.</p>
<p><a href="${unsubscribeUrl}" style="color: #666;">Unsubscribe</a></p>
</div>
</div>
</body>
</html>
`;
}When a release is published (status changed from draft to published), trigger email notifications.
File: app/dashboard/[id]/releases/[releaseId]/page.tsx (or wherever publish happens)
Add to publish logic:
// After updating release to published
if (releaseData.status === 'published' && existingRelease.status === 'draft') {
// Trigger email notifications
await sendReleaseNotifications(changelog.id, release.id);
}Function: lib/notifications.ts
import { db } from '@/lib/db';
import { subscribers, notificationSettings } from '@/lib/db/schema';
import { sendReleaseNotification } from '@/lib/resend';
import { eq, and } from 'drizzle-orm';
export async function sendReleaseNotifications(
changelogId: string,
releaseId: string
) {
// 1. Check if notifications are enabled for this changelog
const settings = await db.query.notificationSettings.findFirst({
where: eq(notificationSettings.changelogId, changelogId),
});
if (!settings || !settings.enabled || !settings.sendOnPublish) {
return; // Notifications disabled
}
// 2. Fetch changelog details
const changelog = await db.query.changelogs.findFirst({
where: eq(changelogs.id, changelogId),
});
if (!changelog) return;
// 3. Fetch release with items
const release = await db.query.releases.findFirst({
where: eq(releases.id, releaseId),
with: {
items: {
with: {
type: true,
},
},
},
});
if (!release) return;
// 4. Fetch confirmed subscribers
const subs = await db.query.subscribers.findMany({
where: and(
eq(subscribers.changelogId, changelogId),
eq(subscribers.confirmed, true)
),
});
// 5. Send emails (batch if many subscribers)
const changelogUrl = `https://miniship.app/${changelog.id}`;
for (const sub of subs) {
const unsubscribeUrl = `https://miniship.app/api/unsubscribe?token=${sub.unsubscribeToken}`;
try {
await sendReleaseNotification(
sub.email,
{ name: changelog.name, url: changelogUrl },
{
version: release.version,
title: release.title || release.version,
description: release.description || undefined,
items: release.items,
},
unsubscribeUrl
);
} catch (error) {
console.error(`Failed to send notification to ${sub.email}:`, error);
// Continue with other subscribers (don't fail entire batch)
}
}
console.log(`Sent ${subs.length} email notifications for release ${releaseId}`);
}File: components/SubscribeForm.tsx
Features:
- Email input field
- Subscribe button
- Success/error messages
- Only show if notifications are enabled for this changelog
Example:
'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/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ changelogId, email }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to subscribe');
}
showToast({ message: data.message, type: 'success' });
setEmail('');
} catch (error: any) {
showToast({ message: error.message, type: 'error' });
} 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="flex-1 px-4 py-2 border border-gray-300 rounded"
/>
<button
type="submit"
disabled={loading}
className="px-6 py-2 bg-accent text-white rounded hover:opacity-90 disabled:opacity-50"
>
{loading ? 'Subscribing...' : 'Subscribe'}
</button>
</form>
);
}Add to: app/[id]/page.tsx (public changelog page)
File: app/dashboard/[id]/settings/notifications/page.tsx
Features (Pro users only):
- Enable/disable email notifications
- Toggle "Send on publish" option
- View subscriber count
- Export subscriber list (CSV)
- Custom from name / reply-to (optional)
- Preview email template
Example:
'use client';
import { useState, useEffect } from 'react';
import { requirePaidUser } from '@chrishannah/minibase-auth';
export default function NotificationSettingsPage({ params }: { params: { id: string } }) {
const [settings, setSettings] = useState<any>(null);
const [subscriberCount, setSubscriberCount] = useState(0);
// Fetch settings and subscriber count
// Render toggle switches
// Show subscriber list
return (
<div>
<h1>Email Notifications</h1>
{/* Settings form */}
<p>Subscribers: {subscriberCount}</p>
</div>
);
}| Endpoint | Method | Purpose |
|---|---|---|
/api/subscribe |
POST | Subscribe email to changelog |
/api/subscribe/confirm |
GET | Confirm email subscription |
/api/unsubscribe |
GET | Unsubscribe from changelog |
/api/changelogs/[id]/notification-settings |
GET | Fetch notification settings |
/api/changelogs/[id]/notification-settings |
PATCH | Update notification settings |
/api/changelogs/[id]/subscribers |
GET | List subscribers (pro only) |
/api/changelogs/[id]/subscribers/export |
GET | Export subscribers CSV (pro only) |
- Notification settings page: Require paid tier (use
requirePaidUser) - Public subscribe form: Available to all (free users can enable, but only paid users can customize)
- Unsubscribe tokens must be cryptographically secure (crypto.randomUUID())
- Email confirmation required (double opt-in)
- GDPR compliance: Easy unsubscribe, data export on request
- Limit subscription attempts per IP (prevent spam)
- Use Vercel rate limiting or custom middleware
- Subscribe to changelog (free user)
- Receive confirmation email
- Confirm subscription via email link
- Publish release → receive notification email
- Unsubscribe via email link
- Re-subscribe (should work)
- Subscribe with already-subscribed email (no error)
- Notification settings page (paid user only)
- Disable notifications → no emails sent
- Enable notifications → emails resume
- Subscriber count updates correctly
- Email templates render correctly
- Unsubscribe link works from email
- Invalid tokens return 404
- Add
RESEND_API_KEYto Vercel environment variables - Add
RESEND_FROM_EMAILto Vercel environment variables - Configure Resend domain (notifications.miniship.app or similar)
- Verify domain in Resend dashboard
- Run database migration for new tables
- Test email delivery in production
- Monitor Resend logs for failures
- Add error tracking for failed email sends
- Custom email templates per changelog (Pro users)
- Weekly digest option (batched updates)
- Webhook notifications (for integrations)
- Slack/Discord notifications
- SMS notifications (via Twilio)
- Subscriber analytics (open rates, click rates)
- Existing users: Notification settings default to enabled
- Default template: Use built-in HTML template
- No subscribers initially (users must opt-in via public page)
- Database schema & migrations: 1 hour
- Subscription API endpoints: 2 hours
- Email service integration (Resend): 2 hours
- Email templates: 2 hours
- Webhook handler (publish trigger): 1 hour
- UI components (subscribe form): 1.5 hours
- Settings page: 2 hours
- Testing & bug fixes: 1.5 hours
Total: ~12 hours
Medium-High - Pro feature that adds significant value and differentiates Miniship from competitors. Should be implemented before launch to enable Pro tier upsells.
- Resend account and API key
- Domain verification in Resend
- Database migration tools (Drizzle)
@chrishannah/minibase-authpackage (for tier checks)
- Should free users be able to enable basic notifications?
- What's the subscriber limit (if any)?
- Custom sender domains (Pro feature)?
- Batch email sending strategy (100s of subscribers)?
- Bounce/complaint handling via Resend webhooks?
End of Implementation Plan
Created: 2026-03-24
Issue: chrishannah/miniship#25
Estimated Time: 8-12 hours