Skip to content

Instantly share code, notes, and snippets.

@chrishannah
Created March 24, 2026 08:00
Show Gist options
  • Select an option

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

Select an option

Save chrishannah/1dfd10c76d80cd4dd2334ffb9fa0a077 to your computer and use it in GitHub Desktop.
Implementation Plan: Email Notifications (Pro Feature) - miniship#25

Implementation Plan: Email Notifications (Pro Feature)

Issue: chrishannah/miniship#25
Feature: Email notification system for changelog subscribers
Tier: Pro Feature
Estimated Time: 8-12 hours

Overview

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.


Architecture

Components

  1. Database Schema - Subscriber management tables
  2. Subscription API - Subscribe/unsubscribe endpoints
  3. Email Service - Resend integration for sending emails
  4. Email Templates - HTML email templates for notifications
  5. Webhook Handler - Trigger emails when releases are published
  6. UI Components - Subscribe forms and management interface
  7. Settings Page - Notification preferences for changelog owners

1. 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(),
  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(),
});

2. Subscription API

POST /api/subscribe

Purpose: Subscribe an email to a changelog

Request:

{
  changelogId: string; // UUID
  email: string;
}

Flow:

  1. Validate email format
  2. Check if already subscribed (return success if so)
  3. Generate unique unsubscribe token (crypto.randomUUID())
  4. Insert into subscribers table (confirmed: false)
  5. Send confirmation email via Resend
  6. Return success message

Response:

{
  success: true,
  message: "Please check your email to confirm your subscription"
}

GET /api/subscribe/confirm?token=xxx

Purpose: Confirm email subscription

Flow:

  1. Find subscriber by unsubscribeToken
  2. If not found → 404
  3. If already confirmed → show "Already confirmed" page
  4. Set confirmed: true, confirmedAt: now()
  5. Redirect to success page

GET /api/unsubscribe?token=xxx

Purpose: Unsubscribe from notifications

Flow:

  1. Find subscriber by unsubscribeToken
  2. If not found → 404 (or show "Already unsubscribed")
  3. Delete subscriber record
  4. Show confirmation page

3. Email Service (Resend Integration)

Setup

Install Resend SDK:

pnpm add resend

Environment Variables:

RESEND_API_KEY=re_xxx
RESEND_FROM_EMAIL=notifications@miniship.app

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

4. Email Templates

Confirmation Email Template

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

Release Notification Email Template

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

5. Webhook Handler (Trigger on Release Publish)

Approach: Server Action in Release Creation/Update

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

6. UI Components

Subscribe Form (Public Changelog Page)

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)


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

7. API Endpoints Summary

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)

8. Security & Permissions

Tier Check

  • Notification settings page: Require paid tier (use requirePaidUser)
  • Public subscribe form: Available to all (free users can enable, but only paid users can customize)

Data Protection

  • Unsubscribe tokens must be cryptographically secure (crypto.randomUUID())
  • Email confirmation required (double opt-in)
  • GDPR compliance: Easy unsubscribe, data export on request

Rate Limiting

  • Limit subscription attempts per IP (prevent spam)
  • Use Vercel rate limiting or custom middleware

9. Testing Checklist

  • 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

10. Deployment Checklist

  • Add RESEND_API_KEY to Vercel environment variables
  • Add RESEND_FROM_EMAIL to 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

11. Future Enhancements

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

12. Migration Notes

  • Existing users: Notification settings default to enabled
  • Default template: Use built-in HTML template
  • No subscribers initially (users must opt-in via public page)

Time Breakdown

  • 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


Priority

Medium-High - Pro feature that adds significant value and differentiates Miniship from competitors. Should be implemented before launch to enable Pro tier upsells.


Dependencies

  • Resend account and API key
  • Domain verification in Resend
  • Database migration tools (Drizzle)
  • @chrishannah/minibase-auth package (for tier checks)

Questions to Resolve Before Implementation

  1. Should free users be able to enable basic notifications?
  2. What's the subscriber limit (if any)?
  3. Custom sender domains (Pro feature)?
  4. Batch email sending strategy (100s of subscribers)?
  5. Bounce/complaint handling via Resend webhooks?

End of Implementation Plan

Created: 2026-03-24
Issue: chrishannah/miniship#25
Estimated Time: 8-12 hours

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment