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
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. |
|
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 postnewsletter: 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: falsehides 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