Skip to content

Instantly share code, notes, and snippets.

@chrishannah
Created April 19, 2026 20:03
Show Gist options
  • Select an option

  • Save chrishannah/ff832b67ad73d7f8f759c8659c3ab997 to your computer and use it in GitHub Desktop.

Select an option

Save chrishannah/ff832b67ad73d7f8f759c8659c3ab997 to your computer and use it in GitHub Desktop.
Miniship Email Notifications Implementation Plan (Issue #25)

Email Notifications Implementation Plan

Issue: chrishannah/miniship#25 Feature: Email Notifications (Pro Feature) Estimated Time: 6-8 hours Complexity: Medium-High Risk: Medium (external service integration, email delivery)

Overview

Implement email notification system for Miniship changelogs. When a release is published, subscribers receive an email notification. This is a Pro tier feature.

Architecture

Components

  1. Subscriber Management

    • Database schema for subscribers
    • Subscribe/unsubscribe UI
    • Subscription preferences
  2. Email Service Integration

    • Resend API integration
    • Email templates
    • Delivery tracking
  3. Notification Triggers

    • Hook into release publish action
    • Queue system for batch sending
    • Rate limiting
  4. User Controls

    • Notification settings per changelog
    • Enable/disable notifications
    • Subscriber management (Pro only)

Database Schema

New Tables

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'),
});

Implementation Steps

1. Setup Resend Integration

Files:

  • lib/email.ts - Resend client and helpers

Tasks:

  • Install resend package
  • 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,
    }),
  });
}

2. Create Email Template

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 styles

3. Database Migration

Files:

  • drizzle/migrations/XXXX_add_email_notifications.sql

Tasks:

  • Create migration for new tables
  • Run migration: pnpm db:push or pnpm db:migrate
  • Verify schema in database

4. Subscribe/Unsubscribe API

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`);
}

5. Send Notifications on Publish

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));
    }
  }
}

6. Subscribe UI (Public Changelog Page)

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>
  );
}

7. Notification Settings (Pro Feature)

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>

8. Unsubscribe Confirmation Page

Files:

  • app/unsubscribed/page.tsx (new)

Tasks:

  • Simple confirmation page
  • Re-subscribe option
  • Link back to changelog

Testing Checklist

Email Delivery

  • 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

API Endpoints

  • Subscribe endpoint validates email
  • Duplicate subscriptions handled correctly
  • Unsubscribe token works
  • Notification settings save correctly
  • Only Pro users can enable notifications

UI

  • Subscribe form on public changelog page
  • Toast notifications on success/error
  • Settings page shows subscriber count
  • Settings page Pro gating works
  • Unsubscribe page renders

Edge Cases

  • 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

Environment Variables

Add to .env.local and production:

RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxx

Dependencies

Install new packages:

pnpm add resend @react-email/components

Security Considerations

  1. Email Validation: Validate email format before storing
  2. Unsubscribe Tokens: Use cryptographically secure random tokens
  3. Rate Limiting: Prevent abuse of subscribe endpoint
  4. Pro Feature Gating: Enforce Pro tier in API, not just UI
  5. SPAM Compliance: Include unsubscribe link in every email

Future Enhancements

  • 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)

Rollout Plan

  1. Phase 1 - Core Functionality (MVP)

    • Database schema
    • Subscribe/unsubscribe API
    • Email template
    • Send on publish
  2. Phase 2 - Settings UI

    • Notification settings page
    • Subscriber management
    • Pro gating
  3. Phase 3 - Polish

    • Better email template design
    • Analytics/logs
    • Error monitoring

Risks & Mitigations

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

Success Metrics

  • 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)

Timeline Estimate

  • 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)

Notes

  • 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment