Files
wiki/.cursor/plans/agentmail_newsletter_integration_03119c86.plan.md
Wayne Sutton a87db9d171 docs: add changelog entries for v1.33.1 through v1.37.0
Add missing changelog entries to content/pages/changelog-page.md:

v1.34.0 (2025-12-26): Blog page featured layout with hero post
- blogFeatured frontmatter field for posts
- Hero card displays first featured post with landscape image
- 2-column featured row for remaining featured posts
- 3-column grid for regular posts

v1.35.0 (2025-12-26): Image support at top of posts and pages
- showImageAtTop frontmatter field
- Full-width image display above post header
- Works for both posts and pages

v1.36.0 (2025-12-27): Social footer component
- Customizable social links (8 platform types)
- Copyright with auto-updating year
- showSocialFooter frontmatter field for per-page control
- Configurable via siteConfig.socialFooter

v1.37.0 (2025-12-27): Newsletter Admin UI
- Three-column admin interface at /newsletter-admin
- Subscriber management with search and filters
- Send newsletter panel (post selection or custom email)
- Weekly digest automation (Sunday 9am UTC)
- Developer notifications (subscriber alerts, weekly stats)
- Markdown-to-HTML conversion for custom emails
2025-12-27 15:32:07 -08:00

26 KiB

name, overview, todos
name overview todos
AgentMail Newsletter Integration Integrate AgentMail for newsletter subscriptions with email-only signup. All features are optional and configurable via siteConfig.ts and frontmatter. Phase 1 focuses on the signup form and subscriber management. Future phases add email sending, contact form, and advanced features.
id content status
p1a-site-config Add NewsletterConfig interface and defaults to siteConfig.ts (email-only signup) completed
id content status
p1a-schema-subscribers Add newsletterSubscribers table to convex/schema.ts completed
id content status dependencies
p1b-subscribe-mutation Create convex/newsletter.ts with subscribe mutation completed
p1a-schema-subscribers
id content status dependencies
p1b-unsubscribe-mutation Add unsubscribe mutation with token verification to newsletter.ts completed
p1a-schema-subscribers
id content status dependencies
p1b-subscriber-queries Add getSubscriberCount and getActiveSubscribers queries completed
p1a-schema-subscribers
id content status dependencies
p1c-newsletter-component Create NewsletterSignup.tsx component (email-only input) completed
p1a-site-config
id content status dependencies
p1c-css-styling Add newsletter component styles to global.css (all themes) completed
p1c-newsletter-component
id content status dependencies
p1d-home-integration Add NewsletterSignup to Home.tsx (configurable position) completed
p1c-newsletter-component
p1b-subscribe-mutation
id content status dependencies
p1d-blog-page-integration Add NewsletterSignup to Blog.tsx page completed
p1c-newsletter-component
p1b-subscribe-mutation
id content status dependencies
p1d-post-integration Add NewsletterSignup to Post.tsx with frontmatter support completed
p1c-newsletter-component
p1b-subscribe-mutation
id content status dependencies
p1e-unsubscribe-page Create Unsubscribe.tsx page component completed
p1b-unsubscribe-mutation
id content status dependencies
p1e-unsubscribe-route Add /unsubscribe route to App.tsx completed
p1e-unsubscribe-page
id content status dependencies
p2-schema-sent-posts Add newsletterSentPosts table to track sent newsletters completed
p1a-schema-subscribers
id content status dependencies
p2-send-action Create sendPostNewsletter internalAction using AgentMail API completed
p2-schema-sent-posts
p1b-subscriber-queries
id content status dependencies
p2-send-script Create scripts/send-newsletter.ts CLI tool completed
p2-send-action
id content status dependencies
p2-package-scripts Add newsletter:send script to package.json completed
p2-send-script
id content status dependencies
p3-fork-config Update fork-config.json.example with newsletter settings completed
p1a-site-config
id content status dependencies
p3-fork-docs Update FORK_CONFIG.md with newsletter configuration docs completed
p3-fork-config
id content status
p3-save-prd Save final plan as prds/agentmail-newsletter-v1.md completed

AgentMail Newsletter Integration Plan (Phased)

Overview

Integrate AgentMail as an optional newsletter system with email-only subscriptions. All features are optional and controlled via siteConfig.ts and frontmatter.

Current Status: AGENTMAIL_API_KEY is configured in Convex environment variables.


How AgentMail Works

Sending Emails to Your Inbox

AgentMail uses a REST API to send emails. Each inbox has a unique address:

{username}@{domain}

For example: newsletter@mail.agentmail.to or newsletter@yourdomain.com

API Endpoint for Sending

// AgentMail API to send an email
const response = await fetch("https://api.agentmail.to/v1/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.AGENTMAIL_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: {
      email: "newsletter@mail.agentmail.to", // Your AgentMail inbox
      name: "Your Site Name",
    },
    to: [{ email: "subscriber@example.com" }],
    subject: "New Post: Title Here",
    html: "<h1>New post published!</h1><p>Read more...</p>",
    text: "New post published! Read more...", // Plain text fallback
  }),
});

Key Configuration

| Environment Variable | Description |

|---------------------|-------------|

| AGENTMAIL_API_KEY | Your AgentMail API key (already in Convex) |

| AGENTMAIL_INBOX | Your inbox address (e.g., newsletter@mail.agentmail.to) |


Phase 1: Newsletter Signup (Current Focus)

This phase implements the subscriber collection system.

Phase 1A: Configuration (Foundation)

Sequential - Do First

1A.1: Site Config Updates

File: src/config/siteConfig.ts

// Newsletter configuration (email-only signup)
export interface NewsletterConfig {
  enabled: boolean; // Master switch

  // AgentMail settings
  agentmail: {
    inbox: string; // Full inbox address (e.g., "newsletter@mail.agentmail.to")
  };

  // Signup form placement
  signup: {
    // Homepage signup
    home: {
      enabled: boolean;
      position: "above-footer" | "below-intro";
      title: string;
      description: string;
    };

    // Blog page (/blog) signup
    blogPage: {
      enabled: boolean;
      position: "above-footer" | "below-posts";
      title: string;
      description: string;
    };

    // Individual blog posts (can override via frontmatter)
    posts: {
      enabled: boolean; // Default for all posts
      position: "below-content";
      title: string;
      description: string;
    };
  };
}

// Add to SiteConfig interface
newsletter: NewsletterConfig;

// Default configuration (disabled)
newsletter: {
  enabled: false,
  agentmail: {
    inbox: "newsletter@mail.agentmail.to",
  },
  signup: {
    home: {
      enabled: false,
      position: "above-footer",
      title: "Stay Updated",
      description: "Get new posts delivered to your inbox.",
    },
    blogPage: {
      enabled: false,
      position: "above-footer",
      title: "Subscribe",
      description: "Get notified when new posts are published.",
    },
    posts: {
      enabled: false,
      position: "below-content",
      title: "Enjoyed this post?",
      description: "Subscribe for more updates.",
    },
  },
},

1A.2: Schema Updates

File: convex/schema.ts

// Newsletter subscribers table
newsletterSubscribers: defineTable({
  email: v.string(),
  subscribed: v.boolean(),
  subscribedAt: v.number(),
  unsubscribedAt: v.optional(v.number()),
  source: v.string(), // "home", "blog-page", "post", "post:slug-name"
  unsubscribeToken: v.string(),
})
  .index("by_email", ["email"])
  .index("by_subscribed", ["subscribed"]),

Phase 1B: Backend Functions

Can start after schema is deployed

1B.1: Subscribe Mutation

File: convex/newsletter.ts

import { mutation, query, internalQuery } from "./_generated/server";
import { v } from "convex/values";

// Generate secure unsubscribe token
function generateToken(): string {
  const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
  let token = "";
  for (let i = 0; i < 32; i++) {
    token += chars[Math.floor(Math.random() * chars.length)];
  }
  return token;
}

// Subscribe to newsletter (email only)
export const subscribe = mutation({
  args: {
    email: v.string(),
    source: v.string(),
  },
  returns: v.object({
    success: v.boolean(),
    message: v.string(),
  }),
  handler: async (ctx, args) => {
    const email = args.email.toLowerCase().trim();

    // Validate email format
    if (!email || !email.includes("@") || !email.includes(".")) {
      return { success: false, message: "Please enter a valid email address." };
    }

    // Check if already subscribed
    const existing = await ctx.db
      .query("newsletterSubscribers")
      .withIndex("by_email", (q) => q.eq("email", email))
      .first();

    if (existing && existing.subscribed) {
      return { success: false, message: "You're already subscribed!" };
    }

    const token = generateToken();

    if (existing) {
      // Re-subscribe
      await ctx.db.patch(existing._id, {
        subscribed: true,
        subscribedAt: Date.now(),
        source: args.source,
        unsubscribeToken: token,
        unsubscribedAt: undefined,
      });
    } else {
      // New subscriber
      await ctx.db.insert("newsletterSubscribers", {
        email,
        subscribed: true,
        subscribedAt: Date.now(),
        source: args.source,
        unsubscribeToken: token,
      });
    }

    return { success: true, message: "Thanks for subscribing!" };
  },
});

1B.2: Unsubscribe Mutation

// Unsubscribe from newsletter
export const unsubscribe = mutation({
  args: {
    email: v.string(),
    token: v.string(),
  },
  returns: v.object({
    success: v.boolean(),
    message: v.string(),
  }),
  handler: async (ctx, args) => {
    const email = args.email.toLowerCase().trim();

    const subscriber = await ctx.db
      .query("newsletterSubscribers")
      .withIndex("by_email", (q) => q.eq("email", email))
      .first();

    if (!subscriber) {
      return { success: false, message: "Email not found." };
    }

    if (subscriber.unsubscribeToken !== args.token) {
      return { success: false, message: "Invalid unsubscribe link." };
    }

    if (!subscriber.subscribed) {
      return { success: true, message: "You're already unsubscribed." };
    }

    await ctx.db.patch(subscriber._id, {
      subscribed: false,
      unsubscribedAt: Date.now(),
    });

    return { success: true, message: "You've been unsubscribed." };
  },
});

1B.3: Subscriber Queries

// Get subscriber count (for stats page)
export const getSubscriberCount = query({
  args: {},
  returns: v.number(),
  handler: async (ctx) => {
    const subscribers = await ctx.db
      .query("newsletterSubscribers")
      .withIndex("by_subscribed", (q) => q.eq("subscribed", true))
      .collect();
    return subscribers.length;
  },
});

// Get active subscribers (internal, for sending)
export const getActiveSubscribers = internalQuery({
  args: {},
  returns: v.array(
    v.object({
      email: v.string(),
      unsubscribeToken: v.string(),
    }),
  ),
  handler: async (ctx) => {
    const subscribers = await ctx.db
      .query("newsletterSubscribers")
      .withIndex("by_subscribed", (q) => q.eq("subscribed", true))
      .collect();

    return subscribers.map((s) => ({
      email: s.email,
      unsubscribeToken: s.unsubscribeToken,
    }));
  },
});

Phase 1C: Frontend Component

Can work in parallel with Phase 1B

1C.1: NewsletterSignup Component

File: src/components/NewsletterSignup.tsx

import { useState } from "react";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import siteConfig from "../config/siteConfig";

interface NewsletterSignupProps {
  source: "home" | "blog-page" | "post";
  postSlug?: string; // For tracking which post they subscribed from
  title?: string;
  description?: string;
}

export default function NewsletterSignup({
  source,
  postSlug,
  title,
  description,
}: NewsletterSignupProps) {
  const [email, setEmail] = useState("");
  const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
  const [message, setMessage] = useState("");

  const subscribe = useMutation(api.newsletter.subscribe);

  // Check if newsletter is enabled
  if (!siteConfig.newsletter?.enabled) return null;

  // Get config for this placement
  const config = source === "home"
    ? siteConfig.newsletter.signup.home
    : source === "blog-page"
    ? siteConfig.newsletter.signup.blogPage
    : siteConfig.newsletter.signup.posts;

  if (!config.enabled) return null;

  const displayTitle = title || config.title;
  const displayDescription = description || config.description;

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!email.trim()) {
      setStatus("error");
      setMessage("Please enter your email.");
      return;
    }

    setStatus("loading");

    try {
      const sourceValue = postSlug ? `post:${postSlug}` : source;
      const result = await subscribe({ email, source: sourceValue });

      if (result.success) {
        setStatus("success");
        setMessage(result.message);
        setEmail("");
      } else {
        setStatus("error");
        setMessage(result.message);
      }
    } catch {
      setStatus("error");
      setMessage("Something went wrong. Please try again.");
    }
  };

  return (
    <section className="newsletter-signup">
      <div className="newsletter-signup__content">
        <h3 className="newsletter-signup__title">{displayTitle}</h3>
        {displayDescription && (
          <p className="newsletter-signup__description">{displayDescription}</p>
        )}

        {status === "success" ? (
          <p className="newsletter-signup__success">{message}</p>
        ) : (
          <form onSubmit={handleSubmit} className="newsletter-signup__form">
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              placeholder="your@email.com"
              className="newsletter-signup__input"
              disabled={status === "loading"}
              aria-label="Email address"
            />
            <button
              type="submit"
              className="newsletter-signup__button"
              disabled={status === "loading"}
            >
              {status === "loading" ? "..." : "Subscribe"}
            </button>
          </form>
        )}

        {status === "error" && (
          <p className="newsletter-signup__error">{message}</p>
        )}
      </div>
    </section>
  );
}

1C.2: CSS Styling

File: src/styles/global.css (add to existing file)

/* ═══════════════════════════════════════════════════════════════════════════
   Newsletter Signup Component
   ═══════════════════════════════════════════════════════════════════════════ */

.newsletter-signup {
  margin: 2rem 0;
  padding: 1.5rem;
  border: 1px solid var(--border-color);
  border-radius: 8px;
  background: var(--bg-secondary);
}

.newsletter-signup__content {
  max-width: 480px;
  margin: 0 auto;
  text-align: center;
}

.newsletter-signup__title {
  font-size: 1.25rem;
  font-weight: 600;
  margin: 0 0 0.5rem 0;
  color: var(--text-primary);
}

.newsletter-signup__description {
  font-size: 0.9rem;
  color: var(--text-secondary);
  margin: 0 0 1rem 0;
}

.newsletter-signup__form {
  display: flex;
  gap: 0.5rem;
  justify-content: center;
}

.newsletter-signup__input {
  flex: 1;
  max-width: 280px;
  padding: 0.625rem 0.875rem;
  font-size: 0.9rem;
  border: 1px solid var(--border-color);
  border-radius: 6px;
  background: var(--bg-primary);
  color: var(--text-primary);
  outline: none;
  transition: border-color 0.2s;
}

.newsletter-signup__input:focus {
  border-color: var(--accent-color, var(--text-primary));
}

.newsletter-signup__input::placeholder {
  color: var(--text-tertiary);
}

.newsletter-signup__button {
  padding: 0.625rem 1.25rem;
  font-size: 0.9rem;
  font-weight: 500;
  border: none;
  border-radius: 6px;
  background: var(--text-primary);
  color: var(--bg-primary);
  cursor: pointer;
  transition: opacity 0.2s;
  white-space: nowrap;
}

.newsletter-signup__button:hover:not(:disabled) {
  opacity: 0.85;
}

.newsletter-signup__button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.newsletter-signup__success {
  padding: 0.75rem;
  background: var(--success-bg, rgba(34, 197, 94, 0.1));
  color: var(--success-text, #22c55e);
  border-radius: 6px;
  font-size: 0.9rem;
}

.newsletter-signup__error {
  margin-top: 0.5rem;
  font-size: 0.85rem;
  color: var(--error-color, #ef4444);
}

/* Mobile responsive */
@media (max-width: 480px) {
  .newsletter-signup__form {
    flex-direction: column;
  }

  .newsletter-signup__input {
    max-width: 100%;
  }

  .newsletter-signup__button {
    width: 100%;
  }
}

Phase 1D: Integration Points

After component and mutations are ready

1D.1: Home Page Integration

File: src/pages/Home.tsx

// Import at top
import NewsletterSignup from "../components/NewsletterSignup";

// Add before footer section (find the footer JSX)
{siteConfig.newsletter?.enabled &&
 siteConfig.newsletter.signup.home.enabled && (
  <NewsletterSignup source="home" />
)}

1D.2: Blog Page Integration

File: src/pages/Blog.tsx

// Import at top
import NewsletterSignup from "../components/NewsletterSignup";

// Add before footer or after posts list
{siteConfig.newsletter?.enabled &&
 siteConfig.newsletter.signup.blogPage.enabled && (
  <NewsletterSignup source="blog-page" />
)}

1D.3: Post Integration with Frontmatter

File: src/pages/Post.tsx

Posts can override the newsletter signup via frontmatter:

  • newsletter: false - Hide newsletter on this post
  • newsletter: true - Show newsletter (even if posts default is false)
  • No frontmatter - Use siteConfig default
// Import at top
import NewsletterSignup from "../components/NewsletterSignup";

// After post content, check frontmatter
const showNewsletter = post.newsletter !== undefined
  ? post.newsletter
  : siteConfig.newsletter?.signup.posts.enabled;

{siteConfig.newsletter?.enabled && showNewsletter && (
  <NewsletterSignup source="post" postSlug={post.slug} />
)}

Schema update for posts table:

// Add to posts table in schema.ts
newsletter: v.optional(v.boolean()), // Override newsletter signup display

Phase 1E: Unsubscribe Flow

After mutations are ready

1E.1: Unsubscribe Page

File: src/pages/Unsubscribe.tsx

import { useSearchParams, Link } from "react-router-dom";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { useState, useEffect } from "react";

export default function Unsubscribe() {
  const [searchParams] = useSearchParams();
  const email = searchParams.get("email");
  const token = searchParams.get("token");

  const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
  const [message, setMessage] = useState("");

  const unsubscribeMutation = useMutation(api.newsletter.unsubscribe);

  useEffect(() => {
    if (email && token) {
      handleUnsubscribe();
    }
  }, []);

  const handleUnsubscribe = async () => {
    if (!email || !token) {
      setStatus("error");
      setMessage("Invalid unsubscribe link.");
      return;
    }

    setStatus("loading");

    try {
      const result = await unsubscribeMutation({ email, token });
      setStatus(result.success ? "success" : "error");
      setMessage(result.message);
    } catch {
      setStatus("error");
      setMessage("Something went wrong. Please try again.");
    }
  };

  return (
    <div className="unsubscribe-page">
      <h1>Unsubscribe</h1>

      {status === "loading" && <p>Processing...</p>}

      {status === "success" && (
        <>
          <p className="unsubscribe-success">{message}</p>
          <Link to="/" className="unsubscribe-home-link">Back to home</Link>
        </>
      )}

      {status === "error" && (
        <p className="unsubscribe-error">{message}</p>
      )}

      {status === "idle" && !email && !token && (
        <p>Use the unsubscribe link from your email.</p>
      )}
    </div>
  );
}

1E.2: Add Route

File: src/App.tsx

import Unsubscribe from "./pages/Unsubscribe";

// Add route
<Route path="/unsubscribe" element={<Unsubscribe />} />

Phase 2: Newsletter Sending (Future)

After Phase 1 is complete and subscribers are collecting.

2.1: Sent Posts Tracking

Schema addition:

newsletterSentPosts: defineTable({
  postSlug: v.string(),
  sentAt: v.number(),
  sentCount: v.number(),
})
  .index("by_postSlug", ["postSlug"]),

2.2: Send Newsletter Action

File: convex/newsletter.ts (addition)

import { internalAction } from "./_generated/server";

export const sendPostNewsletter = internalAction({
  args: {
    postSlug: v.string(),
    siteUrl: v.string(),
  },
  returns: v.object({
    success: v.boolean(),
    sentCount: v.number(),
    message: v.string(),
  }),
  handler: async (ctx, args) => {
    // Get subscribers
    const subscribers = await ctx.runQuery(
      internal.newsletter.getActiveSubscribers,
    );

    if (subscribers.length === 0) {
      return { success: false, sentCount: 0, message: "No subscribers." };
    }

    // Get post details
    const post = await ctx.runQuery(internal.posts.getPostBySlugInternal, {
      slug: args.postSlug,
    });

    if (!post) {
      return { success: false, sentCount: 0, message: "Post not found." };
    }

    const apiKey = process.env.AGENTMAIL_API_KEY;
    const inbox = process.env.AGENTMAIL_INBOX;

    if (!apiKey || !inbox) {
      throw new Error("AGENTMAIL_API_KEY or AGENTMAIL_INBOX not configured");
    }

    let sentCount = 0;

    for (const subscriber of subscribers) {
      const unsubscribeUrl = `${args.siteUrl}/unsubscribe?email=${encodeURIComponent(subscriber.email)}&token=${subscriber.unsubscribeToken}`;

      const html = `
        <h1>${post.title}</h1>
        <p>${post.description}</p>
        <p><a href="${args.siteUrl}/${post.slug}">Read more</a></p>
        <hr>
        <p style="font-size: 12px; color: #666;">
          <a href="${unsubscribeUrl}">Unsubscribe</a>
        </p>
      `;

      try {
        await fetch("https://api.agentmail.to/v1/emails", {
          method: "POST",
          headers: {
            Authorization: `Bearer ${apiKey}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            from: { email: inbox },
            to: [{ email: subscriber.email }],
            subject: `New: ${post.title}`,
            html,
          }),
        });
        sentCount++;
      } catch (error) {
        console.error(`Failed to send to ${subscriber.email}:`, error);
      }
    }

    // Record sent
    await ctx.runMutation(internal.newsletter.recordPostSent, {
      postSlug: args.postSlug,
      sentCount,
    });

    return {
      success: true,
      sentCount,
      message: `Sent to ${sentCount} subscribers.`,
    };
  },
});

2.3: CLI Send Script

File: scripts/send-newsletter.ts

import { ConvexHttpClient } from "convex/browser";
import { internal } from "../convex/_generated/api";
import dotenv from "dotenv";

dotenv.config({ path: ".env.local" });

const client = new ConvexHttpClient(process.env.VITE_CONVEX_URL!);

const postSlug = process.argv[2];
const siteUrl = process.env.SITE_URL || "https://markdown.fast";

if (!postSlug) {
  console.error("Usage: npm run newsletter:send <post-slug>");
  process.exit(1);
}

async function send() {
  const result = await client.action(internal.newsletter.sendPostNewsletter, {
    postSlug,
    siteUrl,
  });

  console.log(result.message);
}

send();

Future Features (Not in Phase 1 or 2)

These features are planned for later phases:

| Feature | Description | Priority |

|---------|-------------|----------|

| Contact Form | Name, email, message form using AgentMail | Medium |

| Email-to-Post | Send email to inbox, creates draft post | Low |

| Weekly Digest | Automated weekly email with new posts | Low |

| Developer Notifications | New subscriber alerts, stats summaries | Low |

| Double Opt-in | Confirmation email before subscribing | Medium |

| Subscriber Admin | View/manage subscribers in admin UI | Medium |

Contact Form (Phase 3)

Will use a separate inbox and include:

  • Name field (required)
  • Email field (required)
  • Message textarea (required)
  • Sends to: contact@mail.agentmail.to

Phase Dependencies Diagram

Phase 1A (Config + Schema) ─┬─► Phase 1B (Backend) ─┬─► Phase 1D (Integration)
                            │                       │
                            └─► Phase 1C (Frontend) ┘
                                                    │
                                                    └─► Phase 1E (Unsubscribe)
                                                              │
                                                              ▼
                                                        Phase 2 (Sending)
                                                              │
                                                              ▼
                                                        Phase 3 (Contact)

Environment Variables

| Variable | Location | Description |

|----------|----------|-------------|

| AGENTMAIL_API_KEY | Convex Dashboard | Your AgentMail API key |

| AGENTMAIL_INBOX | Convex Dashboard | Your inbox (e.g., newsletter@mail.agentmail.to) |

| SITE_URL | .env.local | Your site URL for unsubscribe links |


Testing Checklist

Phase 1

  • Newsletter signup appears on homepage when enabled
  • [ x Newsletter signup appears on /blog page when enabled
  • Newsletter signup appears on posts when enabled
  • Frontmatter newsletter: false hides signup on specific post
  • [ x] Email saves to Convex database
  • [ x] Duplicate email shows "already subscribed" message
  • Unsubscribe link works with token verification
  • All color themes display correctly

Phase 2

  • npm run newsletter:send <slug> sends to all subscribers
  • Sent posts are tracked to prevent duplicates
  • Unsubscribe link in email works